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 上高并发服务的首选。
二、触发语义: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 四件事,各平台实现独立文件,用宏切换。
总结来说,select/poll 是通用但低效的兜底,epoll/kqueue 是 Unix 系高并发的就绪通知主力,IOCP 是 Windows 的完成通知模型。Reactor 的接入方式是把它们统一到 Poller 抽象之下,由事件循环调用 poll 取就绪事件,再分发给 Handler。理解每种机制的扫描方式、通知语义和系统调用开销,才能在选型和排障时做出正确判断。