一、前言:单线程的“魔法”从何而来?

Redis 以其惊人的性能闻名于世,而其核心秘密之一,就是单线程 + IO 多路复用的网络模型。一个线程如何能同时处理数万甚至数十万的并发连接?这听起来像是一个悖论。

答案就在于 IO 多路复用(I/O Multiplexing) 技术。它并非 Redis 的发明,而是操作系统提供的一种高效 IO 机制。Redis 的伟大之处,在于它精准地选择了这项技术,并将其发挥到了极致。

💡 核心价值
IO 多路复用允许一个线程同时监控多个文件描述符(如 socket),并在其中任何一个就绪(可读/可写)时得到通知。这彻底解决了传统阻塞 IO 模型在高并发下的资源瓶颈问题

本文将带你:

  • 彻底搞懂 IO 多路复用的核心思想
  • 深度对比 select/poll/epoll 三大实现
  • 剖析 Redis 源码中如何封装和使用 epoll

二、IO 多路复用:一个高效的“侦察兵”

2.1 核心思想

想象你是一家大型餐厅的经理,手下有成千上万的餐桌(socket 连接)。你的任务是,当任何一桌客人需要服务(数据到达)时,你都要立刻知道。

  • 阻塞 IO 模型:你需要为每一桌都配一个服务员(线程),服务员站在桌边傻等。成本极高。
  • 非阻塞 IO 模型:你亲自不停地在所有桌子间跑来跑去问“需要服务吗?”。CPU 被你跑废了。
  • IO 多路复用模型:你雇佣了一个超级高效的侦察兵(epoll。你只需要告诉侦察兵:“帮我盯着这些桌子”。然后你就可以去休息(epoll_wait 阻塞)。一旦有任何一桌客人举手,侦察兵会立刻跑来叫醒你,并告诉你具体是哪几桌。

✅ 本质将“轮询”的工作交给内核(侦察兵)去做,应用程序(经理)只需被动等待通知

2.2 技术定义

IO 多路复用是一种同步 IO 模型,它允许一个线程通过一个系统调用,同时监视多个文件描述符的 IO 状态。当被监视的 fd 中至少有一个进入就绪状态(例如,socket 接收缓冲区有数据可读),该系统调用就会返回。


三、Linux 三大 IO 多路复用实现详解

Linux 提供了三种 IO 多路复用的系统调用:selectpoll 和 epoll。它们代表了这项技术的演进历程。

3.1 select:开山鼻祖,但力不从心

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 工作原理
    • 应用程序将所有要监听的 fd 打包到一个 fd_set 位图中,传给内核。
    • 内核收到后,会线性遍历这个集合中的每一个 fd,检查其状态。
    • 如果有就绪的 fd,内核会修改传入的 fd_set,标记出就绪的 fd,然后返回。
  • 致命缺陷
    1. FD 数量限制fd_set 的大小固定(通常为 1024),即最多只能监听 1024 个连接。
    2. O(N) 时间复杂度:每次调用,内核和用户态都需要线性扫描整个 fd 集合,无论有多少 fd 是活跃的。对于 10,000 个连接,即使只有 1 个活跃,也要扫描 10,000 次。
    3. 重复拷贝:每次调用都需要将整个 fd_set 从用户空间拷贝到内核空间,返回时再拷贝回来,开销巨大。

3.2 pollselect 的小修小补

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • 改进
    • 使用 pollfd 结构体数组代替 fd_set 位图,突破了 1024 的连接数限制
  • 未解决的问题
    • 依然是 O(N) 时间复杂度,内核依然需要线性遍历所有 fd。
    • 依然存在用户态与内核态之间的大量数据拷贝

3.3 epoll:为高性能而生的终极解决方案

epoll 是 Linux 2.6 内核引入的,专为解决 select/poll 在高并发场景下的性能瓶颈。

// 核心 API
int epoll_create(int size); // 创建一个 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除监听的 fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件
  • 革命性设计
    1. 分离关注点epoll 将“注册监听”和“等待事件”分成了两个独立的系统调用 (epoll_ctl 和 epoll_wait)。这意味着,添加/删除监听的 fd 不需要每次都传递整个列表
    2. 内核级高效数据结构
      • 红黑树 (Red-Black Tree):内核使用红黑树来存储所有被监听的 fd。这使得 epoll_ctl 的增删改操作时间复杂度仅为 O(log N)
      • 就绪链表 (Ready List):当某个被监听的 fd 上有事件发生时,内核的中断处理程序会直接将该 fd 加入到一个就绪链表中。
    3. 零无效遍历epoll_wait 调用时,内核只需检查就绪链表是否为空。如果非空,就将链表中的元素(就绪的 fd)批量拷贝到用户提供的 events 数组中。时间复杂度是 O(1),与总的监听 fd 数量无关,只与本次就绪的 fd 数量有关。
    4. 内存映射优化epoll 在内核和用户空间之间使用了 mmap 等技术,极大地减少了数据拷贝的开销

✅ 总结优势

  • 无连接数上限(受限于系统资源)
  • O(1) 的事件检测效率
  • 极低的 CPU 和内存开销

四、Redis 源码中的 IO 多路复用

Redis 并没有直接使用裸的 epoll API,而是对其进行了一层精巧的抽象,以兼容不同操作系统的多路复用机制(如 BSD 的 kqueue)。

4.1 核心抽象:aeEventLoop

Redis 定义了一个名为 aeEventLoop 的结构体,作为其事件循环的核心。

// server.h
typedef struct aeEventLoop {
    int maxfd;   /* highest file descriptor currently registered */
    int setsize; /* max number of file descriptors tracked */
    long long timeEventNextId;
    aeFileEvent *events; /* Registered events */ // 存储所有注册的文件事件
    aeFiredEvent *fired; /* Fired events */      // 存储已触发的事件
    // ... 其他字段,如时间事件等
} aeEventLoop;

4.2 封装不同后端

Redis 通过条件编译,为不同的系统选择最优的后端:

// ae.c
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c" // Linux
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c" // BSD/macOS
        #else
        #include "ae_select.c" // fallback
        #endif
    #endif
#endif

4.3 ae_epoll.c 的关键实现

让我们看看 ae_epoll.c 中的核心函数是如何工作的:

  1. aeApiCreate:调用 epoll_create 创建 epoll 实例。
  2. aeApiAddEvent:调用 epoll_ctl(EPOLL_CTL_ADD/MOD) 将新的 fd 及其关心的事件(EPOLLIN/EPOLLOUT)注册到 epoll 实例中。
  3. aeApiPoll:这是最关键的函数,它封装了 epoll_wait
    static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
        // ...
        // 调用 epoll_wait,等待事件
        numevents = epoll_wait(state->epfd, state->events, eventLoop->setsize,
                timeout);
        // 将内核返回的就绪事件,填充到 eventLoop->fired 数组中
        for (j = 0; j < numevents; j++) {
            eventLoop->fired[j].fd = state->events[j].data.fd;
            eventLoop->fired[j].mask = 
                state->events[j].events & EPOLLIN ? AE_READABLE : 0 |
                state->events[j].events & EPOLLOUT ? AE_WRITABLE : 0;
        }
        return numevents;
    }

4.4 Redis 主循环

Redis 的主事件循环 aeMain 非常简洁:

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        // 处理一些定时任务
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        // 核心:等待并处理 IO 事件
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
    }
}

aeProcessEvents 内部最终会调用 aeApiPoll(即 epoll_wait),拿到就绪事件后,再逐个调用对应的事件处理器(如 acceptTcpHandler 处理新连接,readQueryFromClient 处理客户端请求)。


五、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐