3. IO多路复用技术选型

Reactor 模式的核心是“一个事件循环等待多个连接上的事件,再把就绪事件分发给处理器”。这个“等待多个连接”的能力,正是由 IO 多路复用机制提供的。不同操作系统给出的接口在数据结构、就绪通知方式、时间复杂度、触发语义上差异很大,直接决定了 Reactor 的接入层怎么写、能支撑多少并发、以及跨平台时要不要做条件编译。本节先把五种机制的内部机制讲清楚,再落到 Reactor 的接入方式上。

一、五种机制的机制差异

select 使用三个位图(fd_set)分别表示可读、可写、异常集合,调用时把用户态位图拷入内核,内核线性扫描所有被监视的 fd,返回后用户态还要再扫描一遍找出就绪项。它有两个硬限制:fd 数量受 FD_SETSIZE 约束(Linux 通常 1024),且每次调用都要重置并重新传入集合。时间复杂度 O(n),适合连接数少、跨平台要求高的场景。

poll 用 pollfd 数组替代位图,每个元素含 fd、events、revents,突破了 1024 的限制,也不再需要每次重置集合。但内核仍然线性扫描整个数组,返回后用户态同样要遍历,复杂度仍是 O(n)。它比 select 更好用,但没有质变。

epoll 是 Linux 特有的机制,用三个系统调用分工:epoll_create 创建实例,epoll_ctl 增删改被监视的 fd(内核用红黑树维护),epoll_wait 等待就绪事件。内核通过回调把就绪 fd 挂到就绪链表,epoll_wait 只返回就绪项,复杂度 O(1)(准确说是 O(就绪数))。它支持水平触发(LT)和边缘触发(ET)两种语义,ET 要求一次把数据读干净直到 EAGAIN,否则会丢事件。

kqueue 是 FreeBSD/macOS 的机制,用 kevent 结构统一描述事件,通过 changelist 批量提交变更、eventlist 批量取回就绪事件,一次系统调用即可完成注册与等待。它同样基于内核事件队列,复杂度与就绪数相关,语义上接近 ET,且能统一处理文件、信号、定时器等多种事件源。

IOCP 是 Windows 的完成端口,它不是“就绪通知”而是“完成通知”:应用先投递一个异步 IO 请求(如 WSARecv),内核完成数据拷贝后把完成包放入队列,工作线程通过 GetQueuedCompletionStatus 取出。它天然与线程池结合,没有“就绪后还要自己 read”的二次系统调用,是 Windows 上高并发服务的首选。

五种 IO 多路复用机制:通知模型与 Reactor 接入位置 select fd_set 位图 ×3 内核线性扫描 用户态再扫描 上限 FD_SETSIZE O(n) 每次重置 就绪通知 · LT poll pollfd 数组 内核线性扫描 revents 回填 无 1024 限制 O(n) 无需重置 就绪通知 · LT epoll (Linux) 红黑树 + 就绪链表 epoll_ctl 注册 epoll_wait 取就绪 O(就绪数) LT / ET 双语义 就绪通知 · LT/ET kqueue (BSD/macOS) kevent 统一描述 changelist 批量提交 eventlist 批量取回 O(就绪数) 文件/信号/定时器 就绪通知 · 类 ET IOCP (Windows) 投递异步 IO 请求 内核完成数据拷贝 完成包入队 线程池取完成包 无二次 read 完成通知 O(n) 扫描 O(就绪数) 事件驱动 完成驱动 Reactor 接入层:统一事件源抽象(Poller / EventDemultiplexer) add / mod / del(fd, events) poll(timeout) → 就绪列表 dispatch → Handler accept / read / write / close 选型要点: · 连接数 < 1k 且需跨平台 → select / poll;Linux 高并发 → epoll(ET + 非阻塞 + 读干净) · BSD/macOS → kqueue;Windows → IOCP(完成模型,配合线程池);跨平台库用条件编译封装同一 Poller 接口

二、触发语义:LT 与 ET 的取舍

epoll 和 kqueue 都涉及触发语义。水平触发(LT)下,只要缓冲区还有数据,epoll_wait 就会持续返回该 fd,编程简单,但可能重复通知。边缘触发(ET)只在状态变化时通知一次,必须配合非阻塞 fd,并在一次事件中循环 read 直到返回 EAGAIN,否则剩余数据不会再触发。Reactor 中若用 ET,Handler 的 onRead 必须写成“读干净”的循环;若用 LT,可以每次只读一次,逻辑更简单但系统调用次数更多。kqueue 的 EV_CLEAR 语义接近 ET,需要同样注意。

三、在 Reactor 中的接入方式

Reactor 通常把多路复用封装成一个 Poller(也叫 EventDemultiplexer)抽象,暴露三个方法:add(fd, events)、mod(fd, events)、del(fd),以及一个 poll(timeout) 返回就绪事件列表。不同平台用条件编译实现各自的 Poller:Linux 用 epoll_create1/epoll_ctl/epoll_wait,BSD/macOS 用 kqueue/kevent,Windows 用 CreateIoCompletionPort/GetQueuedCompletionStatus,select/poll 作为兜底实现。这样上层 Acceptor、Connection、Handler 完全不感知底层差异。

以 epoll 为例,接入的关键点有:

// 创建 epoll 实例
int epfd = epoll_create1(EPOLL_CLOEXEC);

// 注册监听 fd,边缘触发 + 非阻塞
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

// 事件循环
struct epoll_event events[1024];
while (running) {
    int n = epoll_wait(epfd, events, 1024, -1);
    for (int i = 0; i < n; ++i) {
        int fd = events[i].data.fd;
        if (fd == listen_fd) acceptor.onReadable();
        else if (events[i].events & EPOLLIN) conn[fd]->onReadable();
        else if (events[i].events & EPOLLOUT) conn[fd]->onWritable();
    }
}

注意 epoll 的 data 字段可以存 fd,也可以存指针,存指针能避免一次查表,但要注意生命周期。ET 模式下 accept 也要循环调用直到 EAGAIN,否则新连接可能堆积。写事件通常按需注册:只有发送缓冲区满时才注册 EPOLLOUT,写完立即取消,避免空转。

四、选型决策与跨平台封装

选型可以按下面的判断顺序:先看目标平台,Windows 必须用 IOCP 或 select;再看并发规模,超过千级连接优先 epoll/kqueue;再看是否需要统一处理定时器、信号,kqueue 有优势;最后看团队维护成本,跨平台项目通常用 libevent/libuv 这类库,它们内部已经做了 Poller 抽象。若自研,建议把 Poller 接口固定为 add/mod/del/poll 四件事,各平台实现独立文件,用宏切换。

实践建议:在 Linux 上做压测时,先用 LT 把逻辑跑通,再切 ET 并确认 onRead 循环读到 EAGAIN;用 strace 观察 epoll_wait 返回次数与 read 次数,若 read 次数远小于就绪次数,说明 ET 下没读干净。
常见误区:把 epoll 的 ET 当成“只通知一次就永远不再通知”。实际上只要缓冲区从空变为非空就会再次触发,真正会丢事件的是“一次没读完且不再有新数据到达”的情况。因此 ET 必须配非阻塞 fd 和读干净循环。

总结来说,select/poll 是通用但低效的兜底,epoll/kqueue 是 Unix 系高并发的就绪通知主力,IOCP 是 Windows 的完成通知模型。Reactor 的接入方式是把它们统一到 Poller 抽象之下,由事件循环调用 poll 取就绪事件,再分发给 Handler。理解每种机制的扫描方式、通知语义和系统调用开销,才能在选型和排障时做出正确判断。