1. 为什么reactor会出现在Linux内核视野里
先直接回答标题里的那句话——reactor反应堆,本质就是:谁有动静,谁写回调,一个循环盯着所有连接,有事才处理,没事就歇着。
这句话说起来很轻巧,但里面藏着一个服务端开发者迟早要面对的痛点。我在接触IO多路复用之前,写过的网络服务大概是这样的:accept一个连接,然后启动一个线程,或者fork一个进程,专门伺候这个连接,读写都在里面阻塞着。连接少的时候没问题,三五十个连接,线程池一开,跑得还挺欢。可一旦连接数往上走,比如几千、几万,这种“一连接一线程”的模型就开始露出狰狞的一面。
首先是线程数量的膨胀。一个线程哪怕什么都不干,栈空间也要占据实实在在的内存,默认8MB的栈,一万个线程就是80GB的地址空间开销,虽然实际物理内存不会全部分配,但光线程调度、上下文切换的代价就足以让性能暴跌。更严重的是,大部分连接大部分时间其实是安静的——用户打开了一个页面,正在阅读,这个TCP连接上根本没有数据流动。你用一个线程去阻塞read,本质上是让一个“服务员”陪着一位顾客发呆,而这期间可能有一百个顾客同时在呼喊要服务。
于是IO多路复用登场了。select、poll、epoll这三兄弟,做的事情用一句话讲就是:把“谁有数据”这件事从每个连接各自等,变成“用一套机制统一等”。而reactor这个模式,正是在这套统一的“事件等待”机制之上,把业务逻辑重新组织了一遍——不再是你等我、我等你那样串行依赖,而是由核心分发器统一管理事件和回调,让所有连接在一个线程里获得公平且高效的服务。
在Linux上,epoll是这个家族里最成熟、性能最强悍的实现。它不是像select那样每次调用把全部fd集合从用户态拷贝到内核态,也不是像poll那样用链表在用户态维护fd状态然后在内核态轮询一遍。epoll真正聪明的地方在于,它把“关注哪些fd”这件事注册进内核,由内核在fd状态变化时主动通知。这个机制,恰恰把reactor模式里“分发器”这个角色变成了内核级的支持——你只需要告诉epoll你关心哪些事,然后在一个循环里等它告诉你“谁有事了”,act之后,把对应的回调函数拿出来跑一遍就行。
所以reactor不是某种高不可攀的架构,它不过是对“IO多路复用怎么组织业务”的标准答案。在任何一份处理高并发网络IO的代码里,你最后都会长成它的形状。
2. epoll的三次调用:先把地基看透
要想亲手搭一个基于epoll的reactor,绕不开三个系统调用。我的经验是,新人在第一次看epoll相关代码时最喜欢干的事是“背API名字”,但背完就忘,过两周又回来查。真正有效的方式是把这三个调用背后的场景想明白:你在管理一群连接,你要做的是告诉内核“这群家伙我盯着呢”,然后不断问它“谁有动静了”。
2.1 epoll_create:建一个事件调查局
int epfd = epoll_create(1);早年间这个参数还要求传一个大于0的期望大小,内核版本4.5之后size参数实际上已经没有用途了,随便传个正数就行,内核会自动扩展结构。但代码里保留这个参数是为了兼容老版本。
这里的返回的epfd,你可以理解成事件调查局的“局号”。内核为这个epoll实例维护了一棵红黑树——注意,是红黑树,不是哈希表,用来存放你所有登记过的fd;另外还有一个就绪链表,存放那些确实发生了事件、可以回来领走的fd。树和链表之间是有联动关系的——常态下fd待在树上等待关注,一旦事件触发,它就会被挪到链表里,等待你用epoll_wait把它们领走。
合理类比是这样:红黑树像是一个巨大的档案柜,里面放着所有你关心的人(fd)以及你关心的方式(注册的事件类型);就绪链表像是“有情况专人接待”的快速通道,内核把有消息的人直接领进通道里,等你下班前打开门看一眼,登记一下,再把它们领出去处理事。
2.2 epoll_ctl:关注谁,怎么关注,你来填表
struct epoll_event ev; ev.events = EPOLLIN; // 关注可读事件 ev.data.fd = client_fd; // 携带的私有数据,这里直接塞fd epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);这个调用解决的三个问题:对谁(第三个参数)、做什么(第二个参数)、按什么规则(第一个参数指向的结构体)。其中EPOLL_CTL_ADD是新增关注,EPOLL_CTL_MOD是修改关注方式,比如一开始只关心可读、后来也要关心可写,EPOLL_CTL_DEL是取消关注,比如连接关闭了。
epoll_event里的data字段是一个union,最常见两种用法:放fd数值,或者放一个指向某个结构体的指针。在reactor里,这个指针的艺术含量极高——它指向的对象里通常携带着“这个fd发生可读事件时该调用哪个回调函数”,这正好是反应堆分发的核心依据。
补充一个操作细节:EPOLL_CTL_MOD经常被忽略,但实际应用里几乎躲不开。比如一个fd需要读数据,你没有一次读完,还剩一些没处理完,但你又不想一直阻塞在read上占用这个线程,那么你需要把它修改为只关心EPOLLOUT(可写)事件,等缓冲区可写了再继续发剩下的。没有MOD这个操作,你只能在ADD还是DEL之间反复横跳,性能损失还在其次,代码逻辑很容易写成一团乱麻。
2.3 epoll_wait:这一觉睡得值不值,取决于事件密度
struct epoll_event events[128]; int n = epoll_wait(epfd, events, 128, -1);在reactor的主循环里,这个调用就是那个“无限循环盯着所有连接”的引擎。它在等待什么?等待内核把就绪链表里的事件拷贝到你的events数组里。返回的n表示有多少个fd产生了事件。
最后一个参数是超时时间,单位是毫秒。传-1是永远等下去——内核的有趣机制在于,只要就绪链表不为空,epoll_wait会立刻返回;链表为空时,它才会让当前进程进入可中断的睡眠,等待内核唤醒。传0是一次性查询,非阻塞地看看现在有没有事件,通常用来实现特殊的分时逻辑。传正数则是限时等待。
这里的等待本质上是把“忙轮询”变成了“事件驱动唤醒”,功耗和CPU占用率都大幅下降。这也是reactor模式在Linux上有底气成为高性能服务端标配的根基。
trivial但容易被忽略的坑:epoll_wait返回的事件集合里可能出现同一个fd同时在EPOLLIN和EPOLLOUT两个mask里都置位的情况。处理文件描述符事件时,你要养成“先读后写”的习惯——优先处理EPOLLIN,因为读事件意味着对方发了数据或者对端关闭了连接,后者对你的业务往往比发送缓冲可写更紧急。如果顺序颠倒,可能会在连接已经半关闭的情况下仍然试图写数据,然后得到一个意想不到的RST。
3. reactor反应堆的核心组件:四个零件缺一不可
现在开始动手设计一个reactor,或者更准确地说,是设计“你自己业务里的reactor”。我习惯把reactor的组成拆成四个固定角色,不管代码怎么写、语言用什么,这四个角色最终都会以某种形态出现,区别只在于你是否有意识地让它们各司其职。
3.1 事件源:所有被关注的fd的统称
监听socket、已建立的socket、管道读端、定时器fd(timerfd)、信号fd(signalfd),凡是你能丢进epoll里的,都是事件源。在epoll视角里,它们不区分三六九等,都是一棵树上的叶子节点。
但业务上你要分辨清楚角色差异。监听socket的事件类型是EPOLLIN,代表“有新的连接请求到达”,处理动作是accept;client socket的EPOLLIN代表“对方发来数据”,处理动作是read;client socket的EPOLLOUT代表“发送缓冲区有空位了”,处理动作是write剩余数据。
把这些角色映射到上层的结构体设计上,你会发现最自然的做法是用一个结构体描述“一个事件源”,里面存fd、存事件类型、存回调函数指针,可能还要存自己的状态。对,这就是一个典型的handler,它是reactor四零件里的头号主角。
3.2 事件多路分发器:linux内核里的epoll
在这一篇的语境下,事件多路分发器就是epoll实例本身。它负责监控所有事件源。用一个非常粗线条的对照:传统模式是“每个连接有一个线程在阻塞read”,reactor模式是“一个线程通过epoll在阻塞地等所有连接的事件”。后者节省的不仅仅是线程资源,更重要的是它改变了“如何分配CPU时间”的方式——传统模式下,一个没数据饿着的read会让线程睡死过去,即使同一毫秒里有其他连接的数据到了,也没人处理;epoll模式下,只要内核的等待队列里有任何事件闹出动静,线程就被唤醒,处理完一批,再回到epoll_wait里继续等。
3.3 事件循环:reactor的引擎
也叫event loop。一段非常朴素的while(1)循环套在epoll_wait外面,先拿事件,再根据事件的类型回调对应的处理器。这段代码是整个服务的心跳,它往往还有一个别名:主循环。
事件循环本身极其简单,但它有一个隐形的约定:所有注册的回调函数都是在这个循环所在线程里执行的。这既是reactor的优点——单线程内无锁共享数据,也是它的限制——如果某个回调函数里出现一个耗时的阻塞操作(比如同步读一个大文件、同步查询数据库),整个循环都被卡住,其他所有连接的事件都得不到处理,直至这个回调返回。
这就是为什么工程里经常把“处理业务逻辑的两个阶段”拆开:IO阶段由reactor线程完成,业务计算阶段通过线程池异步完成。前者保障网络层的吞吐,后者保障耗时操作不拖垮事件循环。
3.4 回调注册与分发:让“事件”和“处理”拴成一对
事件循环拿到一个就绪事件后,做的最核心动作就是:通过事件源结构体里的回调函数指针,调用对应处理函数。这个动作称为“dispatch”或者“分发”。从内核角度看,epoll_wait负责把底层事件捞出来;从业务角度看,回调函数负责把事件转化为具体行为。中间这层映射,就是reactor这个模式里“反应”二字的来源——事件来了,不被消费掉,而是被反应到预设的处理逻辑上。
关键设计决策在于:回调函数的签名长得像什么。我在项目里常用的签名是:
typedef void (*event_handler)(event_loop *loop, int fd, void *user_data, uint32_t events);参数里带着event_loop指针的好处是:如果这个回调在处理中需要操作事件循环本身(比如读完数据后把fd的监听事件从EPOLLIN改成EPOLLOUT、或者把这个fd从epoll里移除),可以直接通过loop指针调用相关接口,不需要再传递一群人见人烦的全局句柄。
做到这一步,你手里的reactor已经具备一个基础的“形状”了。接下来,最值得花时间打磨的地方,是如何在“看起来全对”的代码里踩出几个能让你反省半天的坑。
4. 手拆一个基于epoll的最小reactor骨架
空谈架构很容易飘,直接上代码骨架更实在。这个骨架我故意保持最小化,目标是让你一行行看完后能自己复现跑起来,而不是看一个几千行的框架然后劝退。
4.1 数据结构设计:fd和回调和状态锁在一起
typedef struct event_loop event_loop; typedef void (*event_handler)(event_loop *loop, int fd, void *user_data, uint32_t events); // 每一个被关注的事件源对应一个n_reactor_item typedef struct n_reactor_item { int fd; uint32_t events; // 当前关注的事件类型 event_handler read_cb; // 可读回调 event_handler write_cb; // 可写回调 void *user_data; // 业务数据,例如一个session对象 } n_reactor_item;这里我把读回调和写回调分开,而不是合并成一个handler。好处在于,你可以在注册阶段就能清晰地表达意图:这个fd关心EPOLLIN时,设置read_cb;关心EPOLLOUT时,设置write_cb。分发阶段根据事件类型选择调用谁,逻辑更清爽。
user_data是万能插槽。业务层可能会准备一个结构体,里面存放协议解析的中间状态、发送队列、超时时间戳等,你只需要把这个结构体指针塞进去,事件分发时什么都不用做就能拿到完整上下文。
事件循环本体:
typedef struct event_loop { int epfd; int stop; struct epoll_event events[128]; n_reactor_item *items[FD_SETSIZE]; // 简化示例:用fd直接做数组下标 } event_loop;items数组用fd做下标,换来O(1)的定位:当epoll_wait返回一个fd事件,你直接从items[fd]里取到对应的事件源结构体。在生产级代码里你可能要换成hash map或者更复杂的索引结构,因为fd空间可能很稀疏,但在教学骨架里,数组大法最简单直观。
4.2 事件循环核心逻辑:注册、取出、分发
void event_loop_add(event_loop *loop, n_reactor_item *item, uint32_t events) { struct epoll_event ev; ev.events = events; ev.data.ptr = item; // 关键:epoll_event的data字段塞事件源指针 epoll_ctl(loop->epfd, EPOLL_CTL_ADD, item->fd, &ev); loop->items[item->fd] = item; item->events = events; } void event_loop_del(event_loop *loop, int fd) { epoll_ctl(loop->epfd, EPOLL_CTL_DEL, fd, NULL); loop->items[fd] = NULL; } void event_loop_run(event_loop *loop) { while (!loop->stop) { int n = epoll_wait(loop->epfd, loop->events, 128, -1); for (int i = 0; i < n; i++) { n_reactor_item *item = (n_reactor_item*)loop->events[i].data.ptr; uint32_t active_events = loop->events[i].events; if (active_events & (EPOLLIN | EPOLLERR | EPOLLHUP)) { if (item->read_cb) item->read_cb(loop, item->fd, item->user_data, active_events); } if (active_events & EPOLLOUT) { if (item->write_cb) item->write_cb(loop, item->fd, item->user_data, active_events); } } } }仔细看epoll_wait里从内核捞出来的events[i].data.ptr:它直接就是add时塞进去的事件源指针,这意味着epoll_wait帮你完成了“从fd事件到业务结构体”的映射,不需要再多做一次查找,这是一次性到位的高效分发。
EPOLLERR(错误)和EPOLLHUP(挂断)在多数内核版本里是即使你不注册也会被返回的事件。把它们和EPOLLIN放在一起判断是有讲究的:连接异常断开,往往伴随着可读事件一起出现,如果你只处理了EPOLLIN里的read正常路径,忽略了错误分支,可能把“对端崩溃”误当成“对方发来数据”,read一次后返回0,然后你以为连接正常关闭,但实际上很多中间状态(如TCP的RST)在那个场景下需要额外的清理逻辑。
4.3 监听socket的接入:accept也是一种读事件
监听socket在epoll里注册的也是EPOLLIN。当新连接到来时,主循环会通过上面那个分发逻辑走到read_cb,但这里的read不是recv数据,而是accept新连接。
void on_accept(event_loop *loop, int listen_fd, void *user_data, uint32_t events) { struct sockaddr_in addr; socklen_t len = sizeof(addr); int client_fd = accept(listen_fd, (struct sockaddr*)&addr, &len); if (client_fd >= 0) { set_nonblocking(client_fd); n_reactor_item *item = create_client_item(client_fd); // 业务层构造事件源结构体 event_loop_add(loop, item, EPOLLIN); // 默认先关心读 } }这里有一个我在真实项目里踩过的坑:如果不把client_fd设成非阻塞,那么read回调里如果对方数据还没到,阻塞在read上,整个事件循环就死了。所以你在这个环节务必要用fcntl打上O_NONBLOCK标记。之后在read_cb里,read返回-1且errno为EAGAIN时,不视为错误,而是“这次没数据可读”,正常返回,等下一轮epoll_wait的通知即可。
4.4 read回调里的关键动作:读完、改关注、决定是否移除
void on_read(event_loop *loop, int fd, void *user_data, uint32_t events) { char buf[4096]; n_reactor_item *item = lookup_item(loop, fd); ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 业务层处理数据,比如解析协议头、或者把数据追加到发送缓冲 handle_incoming_data(item, buf, n); } else if (n == 0) { // 对端关闭:清理 cleanup_connection(loop, fd); return; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) return; // 非阻塞下正常情况 // 其他错误:处理并清理 cleanup_connection(loop, fd); return; } }close一个从内核角度看没有任何事件待处理的fd,通常不会立刻出问题;但如果在同一个事件轮回里,你close的fd随后又被accept分配给了新的连接,然后旧的事件指针还残留在处理列表里,就会形成一个“幽灵事件源”。所以cleanup_connection里必须做两件事:先从epoll里DEL掉这个fd,再把items数组里的槽位清空,最后才close(fd)。顺序不能反。
这个骨架跑起来,你已经拥有一个可处理成千上万个连接的单线程reactor了。从吞吐量的角度,它的天花板取决于单个事件的处理耗时,以及每轮epoll_wait返回的事件数量。剩下的事情,基本都是在这个骨架上做精细调节。
5. 水平触发与边沿触发:真正决定性能上限的分岔口
epoll支持两种触发模式,水平触发(Level-Triggered,LT)和边沿触发(Edge-Triggered,ET)。这是reactor实现里绕不开的抉择,也是面试官最爱追问的细节。但我发现,很多新人对这两种模式的理解停留在“背定义”,没有真正想清楚它们对代码结构的影响。这里帮大家梳理一下。
5.1 LT(水平触发)和 ET(边沿触发)的本质差异
水平触发是epoll的默认模式,语义是:只要fd上有未读的数据,每次epoll_wait都会通知你。这意味着你哪怕一次只读1个字节,数据剩下100个字节,下一轮epoll_wait还是会把EPOLLIN重新报给你。
边沿触发则是:只有当fd的状态发生跳变时(从无数据变成有数据)才通知一次,之后即使还有数据没读完,也不会再通知,直到你再次读到EAGAIN。注意,ET模式的通知次数和“有多少数据到达”有关,而和“还剩多少数据”无关。
边界条件在这里非常关键。以read为例,如果某个时刻一个连接同时有多个TCP分节到达,内核把数据合并成一次跳变通知你。ET模式下你只收到一次事件。如果你在这次事件回调里只read了一次就返回,剩下的数据可能永远等不来下一次通知——因为你没有读到EAGAIN,内核认为你“没消费完但也没继续读”,事件不会再报了。这就是ET的核心约束:必须一直循环读,直到EAGAIN。
5.2 为什么ET省系统调用,但工程上难度陡增
ET模式的收益很直接:相同的数据量,你触发的epoll_wait通知次数可能大幅减少。比如一个连接持续不断发数据,LT下每轮epoll_wait都会把这个fd报一次,即使你的read已经把它读完处理干净了,因为对端又有新数据进来,状态保持“可读”,epoll_wait又会报;而ET下,内核只关心状态的上升沿,每来一波连续数据只报一次,你在这个回调里能读多少读多少,这样单帧通知的含金量高很多。在高吞吐场景里,减少epoll_wait返回的次数意味着减少用户态和内核态切换次数、减少对events数组的遍历成本,整体性能确有提升。
但代价是代码逻辑要变谨慎。LT模式下你可以在回调里只做“尽量读”然后交给下一轮;ET模式下,你必须在回调里把能读的都读完,直到read返回EAGAIN。写ET的read回调,惯用结构是:
void on_read_et(event_loop *loop, int fd, void *user_data, uint32_t events) { char buf[4096]; n_reactor_item *item = lookup_item(loop, fd); for (;;) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { handle_incoming_data(item, buf, n); } else if (n == 0) { cleanup_connection(loop, fd); return; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 读到没数据可读了,才算圆满完成 return; } // 真错误 cleanup_connection(loop, fd); return; } } }这里有个容易被人忽略的细节:如果没有先用fcntl把fd设置为非阻塞,那么在for循环的read里,当内核缓冲区已经没数据但你还在读时,线程会被阻塞住,整个事件循环直接卡死。用ET模式,非阻塞是硬前提,一个字节都不能妥协。
5.3 我的取舍经验:默认LT,除非你清楚自己的瓶颈
在绝大多数业务里,LT模式足够优秀,性能完全能满足几万连接的需求,而且代码心智负担小。选ET的普遍理由其实不是“性能必须翻倍”,而是“想尽量减少无效唤醒和系统调用次数”,但它引入的编程约束会让代码的长度和出错概率同步上升。我的建议很直白:如果你在做一个对改动风险敏感的存量系统,LT就是稳定的答案;如果你是做一个追求极限吞吐的新服务,并且在协议层设计上能容忍“一个事件回调里要把活干完”,那上ET,但必须配一套完善的测试来覆盖“半包”“粘包”“超大数据包”这些边界场景。
真正决定吞吐的往往不是LT还是ET,而是每轮epoll_wait里有多少事件是“有效的”。如果连接的数据到达非常稀疏,ET和LT差距几乎可以忽略;如果连接经常有连续大量数据涌入,ET的收益才体现得出来。所以选型前先量化自己的业务模式,而不是跟着别人的框架走。
5.4 关于EPOLLONESHOT的补充
另一个容易搞混的机制是EPOLLONESHOT。它的语义是:事件的某个fd被通知过一次后,如果没有重新注册,就不会再被通知。这通常用在多线程reactor模型里——一个fd的事件被某个工作线程处理后,不让其他线程也同时拿到这个fd的事件,避免并发处理同一个连接造成数据竞争。Linux下的epoll本身不支持跨线程对同一fd事件的串行仲裁,EPOLLONESHOT配合EPOLL_CTL_MOD重新注册,能实现“当前线程处理完,再把关注权放回池子”的效果。
但单线程reactor用不到它。没有并发消费,就不需要重放机制。新手如果从开源框架里看到这个东西,先要搞清楚对方是多线程模型还是单线程模型,再决定要不要在自己的代码里照搬。
6. 一个吃透reactor的实践场景:改造现有服务
理论堆得再高,动手过一遍才真正内化。我在这里提供一个很适合自己练手的改造实验,用到的都是最常见的网络编程场景。
6.1 从阻塞模型迁移到epoll reactor模型
假设你手里有一个老式的echo服务,代码特征是这样的:accept后创建线程,recv、send阻塞在线程里,连接结束关线程。连接数超过几百就开始拉胯。
改造第一步,梳理现有逻辑会接触哪些“事件”:
- 监听socket上的新连接请求事件:accept
- 已连接socket上对端发来数据的事件:recv
- 已连接socket上发送缓冲可写的事件:send缓冲的滞塞解除
- 连接关闭、错误异常事件:cleanup
然后把这些事件映射到reactor里的“回调”上。accept的回调你已经看到了,关键在read和write回调的配合。Echo服务不需要复杂的协议解析,read回调里读到数据后,最简单的做法是直接把它写回发送缓冲,然后立刻把这个fd从关注EPOLLIN改成关注EPOLLOUT——因为写回数据这件事,需要在“可写”时才去执行。下一次事件循环发现这个fd可写了,调用write回调,把数据写完后,再把关注事件改回EPOLLIN。
read事件回调:recv到数据 → 追加到发送队列 → MOD关心EPOLLOUT write事件回调:把发送队列里的数据写出去 → 如果队列空了 → MOD关心EPOLLIN这种“读-写-读-写”的事件关注切换,熟悉之后,你对reactor的理解会非常通透。它就是让fd的关注方式随着程序的意图动态变化,循环往复。
6.2 改造过程中的典型错误清单
我按踩到的概率排序列几个坑:
第一,忘记给client fd设置非阻塞。这是新手改造时最常犯的错,而且现象特别迷惑:程序明明没崩溃,但处理的连接数稍微一多,整个服务就像僵住一样。原因是某个read在还有数据时阻塞住了,事件循环不转了。
第二,read和write回调里出现了耗时的同步操作。比如把收到的数据写到磁盘、做复杂的JSON解析。事件循环被拖住,其他连接全部饿死,表现是服务间歇性抖动。正确做法是把耗时逻辑异步化,放到线程池里做。
第三,回调里缺失对EAGAIN的处理。LT下其实不太容易踩,因为LT总是重新通知;但如果你想起“优化一下”而切到ET,之前没写EAGAIN分支的代码就会在特定时序下读不全数据。我见过心跳包正常、但大数据包偶尔失效的诡异问题,最后定位就是这里。
第四,清理连接时对epoll和数组的移除顺序搞反。先close(fd)再DEL,在fd数量少的时候问题不大,但在fd复用频繁的高并发场景,可能造成刚被accept分配的fd意外被移出监听列表。
这个改造实验做完,你手里就不只是“看懂”reactor,而是拥有了“把一个老项目改成reactor”的第一手经验。过程中踩过的每一个坑,都对应一个被老手们反复强调的规定。
6.3 进一步扩展:多线程reactor的两种走向
单线程reactor的扩展天花板很明显:一个线程既负责accept又负责全部连接的IO和回调,即使LT模式和高效处理,CPU的利用上限就是单核。两个常见演进方向,一种是主reactor线程只accept,然后把client fd转交给多个子reactor线程,每个子reactor线程跑自己的事件循环;另一种是保持单一事件循环,但把耗时业务回调丢到线程池执行。前者的瓶颈在全是在“移交fd”时如何避免惊群和锁竞争,后者则要小心业务回调里对共享状态的并发访问。
我自己的经验是:大多数服务连单线程reactor的极限都没碰到,先把单线程做到极致,观测到CPU确实打满,再决定动不动多线程。过早引入多线程,各种锁和同步问题会盖过你原本要优化的业务问题,得不偿失。
7. 回看“一句话总结”里的每个词
回到最开始的那句话:谁有动静,谁写回调,一个循环盯着所有连接,有事才处理,没事就歇着。现在可以逐词对一遍了。
“谁有动静”——这是epoll在做的判断。内核帮你跟踪所有fd的状态变化,你不需要自己轮询每个连接。“谁写回调”——每个事件源结构体里挂着对应的read_cb、write_cb,这是reactor模式里业务逻辑的挂载点。“一个循环盯着所有连接”——事件循环,epoll_wait这个阻塞式调用本身赋予了“等”的能力,线程不会空转烧CPU。“有事才处理”——epoll_wait返回了才执行回调,无事时线程挂在内核等待队列里,几乎零消耗。“没事就歇着”——等同于最后那个-1超时参数带来的效果。
其实很多术语本质上就是一句大白话换了一身术语西装。reactor反应堆,字面上的“反应”指的就是“事件到来时被动的触发动作”,就像核反应堆里的链式反应,你丢进去一个中子,它会持续触发一系列反应。在网络服务语境里,一个TCP分节的到达触发accept或者read回调,read回调里可能又触发write回调的关注切换,层层推进。逻辑上,它就是“IO多路复用+回调函数”的标准组织形态,换一层皮而已。
对我个人来说,理解这层关系之后,再看任何基于事件循环的网络框架——无论叫Netty、叫Redis的ae事件循环、还是叫libevent——都会有一种“天下武功出少林”的通透感。它们底层各自有各自的内存管理、缓存策略和跨平台抽象,但核心骨架无一例外,都长成reactor的样子。
如果你正在学这部分,我的建议很明确:别看太多剖析文章,也不要急着引入框架。开一个终端,用C语言或者你熟悉的其他语言,从零搭一个最小可用的epoll reactor,然后在这个基础上加业务、加协议、压测、观察瓶颈。这个过程中遇到的所有疑问,都会比任何教程讲得更清楚。