连接数一上来,多线程阻塞模型就顶不住了,这是我在做服务器开发时最深的体会。本系列前面十几篇都在聊TCP/IP、socket编程、TCP拥塞控制这些基础,但真正到了写高并发服务器的环节,我发现在Linux上绕不开的其实是两个东西:一个是IO多路转接(select/poll/epoll),另一个是建立在它之上的reactor编程模型。这两者合在一起,才是现代Linux后端服务器(nginx、redis、netty这类项目)真正的骨架。
这篇文章就以“网络和Linux网络-15”这个系列位置为背景,认真拆一拆IO多路转接到底是怎么回事,reactor模式为什么能扛住成千上万的并发连接,以及如何自己动手用epoll+reactor写一个真正能跑的服务器。适合正在学Linux网络编程的学生、刚入行的后端开发,以及那些看了源码却感觉云里雾里的朋友。文章会尽量做到能直接照着写、照着排查问题的程度。
1. 为什么我们需要reactor:从阻塞模型到事件驱动
1.1 从多线程阻塞模型说起
先回忆一下最朴素的服务器写法。你用socket()、bind()、listen()把监听socket建好,然后进入一个死循环,调用accept()等待客户端连接。来一个连接,就创建一个线程(或者扔进线程池),这个线程里循环调用read()读数据、处理、write()回数据。
这个模型在连接数少、每个连接发起的请求频率不高的场景下完全够用,代码也简单。但一旦把连接数推到几千、几万,问题就暴露得很明显。
一个阻塞线程通常要占8MB左右的栈内存(取决于线程栈配置),起一万个线程光内存就是80GB量级,这还只是线程本身的开销,没算上下文切换成本。更麻烦的是,绝大多数连接处于“空闲”状态——客户端连上来但很长时间不发数据,可线程却得一直卡在read()上等着。这就相当于雇了一万个服务员,每人盯着一张桌子,但绝大多数桌子根本没有客人说话,纯粹浪费人力。
我早期写的一个小服务,连接从两三百涨到两千的时候,在线线程数量直接让CPU打满,大部分时间都花在内核态切换线程上下文上,真正在干业务的没几个。那个阶段我意识到,服务器编程的核心矛盾不是“怎么处理得快”,而是“如何用有限的线程等待大量的连接”。
1.2 reactor模式的核心思路:事件就是一切
reactor这个单词直译是“反应堆”,在软件工程里它描述的就是一种“倒过来”的IO模型。传统阻塞模型的思路是“每个线程去等一个连接”,而reactor把思路反过来——“用一个线程(或少量线程)统一等所有连接,哪个连接有数据了,就处理哪个连接”。
对应到真实生活,你可以把传统的阻塞多线程模型想象成一家餐厅给每桌客人都配一个专属服务员,一直站在桌边等着客人举手。而reactor模式是前厅只留一个服务员,他负责来回巡视,哪桌客人喊“点菜”了,他就过去服务一下,服务完继续巡视。如果是主从reactor,就相当于门口再放一个引导员,客人进门他先带位,然后再交给前厅服务员来点菜。
这个思路落实在Linux系统上,依赖的关键能力就是IO多路转接——让一个线程能同时“盯着”成千上万个socket,内核负责告诉我们哪些socket可读、哪些可写。这个能力在select、poll里就已经有了雏形,但真正在工程上大放异彩的是epoll。
所以reactor不是某个库,更不是某个框架,它是一种组织代码的模式。它把网络程序中“等待事件”和“处理事件”这两个阶段解耦,把时间片留给真正有事可做的socket。理解了这一点,后面看redis、nginx的源码会顺畅很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 地基:IO多路转接三兄弟怎么选
2.1 select:socket数组的暴力遍历
在Linux上,select是最早出现的IO多路转接方案,它的用法很直白:你把所有要监听的socket丢进一个“读集合”(fd_set),调用select()阻塞,等集合里任意一个socket可读/可写,select返回,然后你用FD_ISSET一个个检查到底哪个fd就绪了。
它的问题也是显而易见的。首先是fd_set大小默认被限制在1024,也就是说单个线程最多监听1024个fd,这在很多高并发场景下根本不够,即使可以重新编译内核调整FD_SETSIZE,也很别扭。其次是每次调用select都要把整个fd集合从用户态拷贝到内核态,而且返回后你得把整个集合重新遍历一遍,才能知道哪些fd有事。这个复杂度是O(n)的,连接越多,空跑越多。
更隐蔽的一个坑是select会“破坏”fd_set结构——内核会把未就绪的fd从集合中清掉,所以你每次调用之前都必须把fd重新加入集合,哪怕这些fd一次都没变化过。这一拷贝、一清空、一重建,在高频调用下开销非常可观。
2.2 poll:摆脱1024限制
poll解决的第一个问题就是FD_SETSIZE上限。它不再用位图,而是用一个struct pollfd数组,数组长度由你自己定,理论上只受系统fd数量限制。每个pollfd里用events表示你关心的事件,revents由内核返回实际发生的事件,不会破坏原始events,所以不用每次重新构造监听数组。
但poll和select的本质复杂度相同,都是每次调用时需要把所有fd复制到内核,返回后线性扫描所有fd检查revents。连接数量到上万之后,这个“每次都扫描全量”的代价是非常扎眼的。另外,poll没有提供类似“只返回活动fd”的机制,它只是把select的“小数组”换成了“大数组”,治标不治本。
2.3 epoll:就绪事件的红黑树与链表
epoll是Linux上有别于BSD的kqueue、Windows的IOCP的、真正为高并发设计的IO多路转接机制。它的核心变化有两个:内核帮你维护长期的事件表,并且只会把发生事件的fd放进就绪列表返回给你。
使用上分三步。第一步epoll_create创建一个epoll实例,内核里其实就是两个结构——一棵红黑树,用来存储你所有感兴趣监听的fd;一个就绪链表,用来存放已经触发事件的fd。第二步epoll_ctl用来向红黑树里添加、修改、删除fd及对应事件。第三步epoll_wait阻塞等待,一旦就绪链表不为空,就把就绪事件复制到用户态数组返回。
最关键的一点是:每次epoll_wait只返回真正有事件的fd,不需要遍历几千个无事件fd;而且添加、删除监听关系时,不需要每次重新拷贝全量fd集合。复杂度从select/poll的O(n)降到了接近O(1)(严格说是取决于就绪事件数量)。
用一张表总结更直观:
| 特性 | select | poll | epoll |
|---|---|---|---|
| fd数量上限 | 受FD_SETSIZE限制,通常1024 | 不受固定上限限制 | 受系统最大fd数限制 |
| 每次调用是否拷贝全量fd | 是 | 是 | 只有首次添加时会注册,后续增删通过epoll_ctl |
| 返回后需遍历多少fd | 全量 | 全量 | 仅就绪fd |
| 内核实现 | 数组线性扫描 | 链表线性扫描 | 红黑树 + 就绪链表回调 |
| 跨平台性 | Windows/Linux都支持 | 大部分Unix支持 | Linux独有 |
2.4 LT还是ET,reactor里应该选谁
epoll支持两种触发模式:水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET)。
LT是默认模式,它的行为很贴心:只要fd上还有数据没读完,每次epoll_wait都会重复通知你。这不会丢事件,代码写起来也简单,但代价是可能被“重复唤醒”。
ET则是“只提示一次”模式:只有当fd从未就绪变为就绪的那一刻,内核才通知你。如果这次没把数据全读完,内核不会再因为剩余数据通知你,直到下次新数据到来产生新的边沿跳变。
这一点是很多新手写ET模式卡到崩溃的根源。用ET,读数据时必须循环read直到返回EAGAIN,否则没读完就不会再被通知,数据会一直堆积在缓冲区里。
我在reactor服务器里,默认推荐LT。理由也很实在:reactor本身就已经把事件分发集中到了一个循环里,LT的“重复通知”代价并不大,反而能避免很多边缘触发下的状态判断错误。ET的收益主要在于事件通知次数更少、更接近底层,适合对性能极致敏感且代码能力足够稳的老手。新手一开始就选ET,八成会踩“数据读不干净”的坑,然后陷入“消息处理怎么老是延迟”的玄学排查。
3. reactor模式的三种常见形态
3.1 单线程reactor:一个线程扛一个服务
单线程reactor是最基础的形态。主线程运行一个事件循环(event loop),循环里调用epoll_wait,拿到就绪事件后逐个分发。处理accept、处理读、执行业务逻辑、处理写,全部都在同一个线程里按顺序完成。
这种模型最大的优点是没有并发问题——所有事件处理都在一个线程里串行执行,不需要加锁,不存在数据竞争。这也是redis单线程模型能这么稳的原因之一。它的限制也很明显:如果某个连接的处理逻辑里出现了耗时操作(比如一次复杂的数据库查询、一次大文件磁盘IO),整个事件循环都会被卡住,其他所有连接都跟着等待。所以单线程reactor适合“业务处理很快、CPU密集程度低”的中间件。如果业务太重,就得拆出去。
3.2 多线程reactor:把业务计算搬进线程池
针对“逻辑计算阻塞事件循环”的问题,最自然的优化是把网络IO和业务计算分离。reactor主线程仍然负责监听和读写socket,但只要一个连接的数据包解析出来、形成完整的业务请求,就把它丢进一个线程池。线程池里的工作线程完成业务计算,然后,再通过队列把响应送回reactor线程,由reactor线程负责最终写回客户端。
在这个模型里,reactor线程不干重活,保证了网络层始终能快速响应新事件;线程池负责处理吃CPU或吃IO的业务逻辑,两者职责清晰。这里最关键的细节是线程安全的交接,业务线程计算完结果后要通过一个锁保护的队列(或者无锁环形队列)把结果交还给reactor,而reactor可能在epoll_wait里阻塞着。如何唤醒它?常用的手段是使用eventfd或者pipe,往里面写一个字节,让epoll_wait立刻醒来处理新写入事件。
这个唤醒机制我建议所有写多线程reactor的人都熟练掌握。它本质上是把“线程间有新任务”这件事本身变成了一种事件,纳入到IO多路转接的管辖范围内,非常优雅。
3.3 主从reactor:accept与IO处理的分离
连接数进一步增长后,单线程处理accept加读写也可能成为瓶颈。主从reactor把“建立连接”和“连接上的IO读写”彻底分开:主reactor只负责监听listenfd,收到新连接后把已建立的socket交给从reactor(sub reactor);每个从reactor各自持有自己的epoll实例和事件循环,负责所管辖连接上的读写。
这就是nginx和netty的经典结构。主从reactor的好处是每个reactor线程的事件数量更少,锁竞争也更小,可以充分利用多核CPU横向扩展。实现的时候有几个容易出错的地方,比如“分配到哪个从reactor”的负载均衡策略(最简单的round-robin就行)、连接对象从主reactor转移到从reactor时有没有做好线程安全的交接。
从单线程reactor到主从reactor,本质上是把原来单一的event loop拆成了多个,但核心思想没变——事件驱动、非阻塞IO、权责分离。
4. 实战:用epoll手写一个单线程reactor服务器
4.1 程序设计
理论讲再多不落地都是空的。我以一段精简但结构完整的C代码为例,写一个基于epoll的单线程reactor回显服务器。它做了几件事:创建监听socket并设为非阻塞,注册到epoll实例;主循环处理就绪事件;accept新连接后创建conn对象并注册EPOLLIN;收到客户端数据后原样回写;客户端断开时清理资源。
这个例子选了LT模式,并且尽量保证代码可以独立编译运行。如果你本机有Linux环境,复制保存成reactor_echo.c,然后运行以下两条命令即可编译验证:
bash复制gcc -o reactor_echo reactor_echo.c -Wall
./reactor_echo 8888
再用工具做一次简单的验证,另一个终端执行:
bash复制echo "hello reactor" | nc 127.0.0.1 8888
如果打印出hello reactor,说明整个链路已经通了。
4.2 核心代码
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAX_EVENTS 1024
#define MAX_CONNS 4096
typedef struct conn {
int fd;
char rbuf[1024];
int rlen;
char wbuf[4096];
int wlen;
int closed;
} conn_t;
static conn_t conns[MAX_CONNS];
static void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static int listen_on(int port) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) { perror("socket"); exit(1); }
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(lfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); }
if (listen(lfd, 128) < 0) { perror("listen"); exit(1); }
set_nonblocking(lfd);
return lfd;
}
static void try_flush(conn_t *c, int epfd) {
if (c->wlen == 0) return;
int n = write(c->fd, c->wbuf, c->wlen);
if (n > 0) {
memmove(c->wbuf, c->wbuf + n, c->wlen - n);
c->wlen -= n;
}
struct epoll_event ev;
ev.data.fd = c->fd;
ev.events = EPOLLIN;
if (c->wlen > 0) {
ev.events |= EPOLLOUT;
}
epoll_ctl(epfd, EPOLL_CTL_MOD, c->fd, &ev);
}
static void handle_accept(int lfd, int epfd) {
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
while (1) {
int cfd = accept(lfd, (struct sockaddr*)&peer, &len);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
continue;
}
set_nonblocking(cfd);
conn_t *c = &conns[cfd];
memset(c, 0, sizeof(*c));
c->fd = cfd;
struct epoll_event ev;
ev.data.fd = cfd;
ev.events = EPOLLIN | EPOLLRDHUP;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
printf("accept new conn: %s:%d, fd=%d\n",
inet_ntoa(peer.sin_addr), ntohs(peer.sin_port), cfd);
}
}
static void handle_read(conn_t *c, int epfd) {
while (1) {
int n = read(c->fd, c->rbuf, sizeof(c->rbuf));
if (n > 0) {
c->rlen = n;
memcpy(c->wbuf + c->wlen, c->rbuf, n);
c->wlen += n;
try_flush(c, epfd);
} else if (n == 0) {
c->closed = 1;
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
c->closed = 1;
return;
}
}
}
static void handle_write(conn_t *c, int epfd) {
try_flush(c, epfd);
}
static void handle_close(conn_t *c, int epfd) {
if (c->closed || c->fd <= 0) return;
printf("close conn: fd=%d\n", c->fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, c->fd, NULL);
close(c->fd);
memset(c, 0, sizeof(*c));
c->fd = -1;
}
int main(int argc, char *argv[]) {
int port = 8888;
if (argc > 1) port = atoi(argv[1]);
int lfd = listen_on(port);
int epfd = epoll_create1(0);
struct epoll_event lsev;
lsev.events = EPOLLIN;
lsev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &lsev);
for (int i = 0; i < MAX_CONNS; i++) conns[i].fd = -1;
printf("reactor server listening on %d\n", port);
struct epoll_event events[MAX_EVENTS];
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait");
break;
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
uint32_t evs = events[i].events;
if (fd == lfd) {
handle_accept(lfd, epfd);
} else {
conn_t *c = &conns[fd];
if (evs & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
handle_close(c, epfd);
continue;
}
if (evs & EPOLLIN) {
handle_read(c, epfd);
if (c->closed) {
handle_close(c, epfd);
continue;
}
}
if (evs & EPOLLOUT) {
handle_write(c, epfd);
}
}
}
}
close(epfd);
close(lfd);
return 0;
}
4.3 关键细节:非阻塞与写缓冲
代码整体不长,但有几个细节值得展开说清楚,不然直接跑出问题都不知道原因。
第一个是非阻塞监听socket。我把listenfd也设置成了非阻塞。accept在非阻塞模式下,当没有新连接时返回-1且errno为EAGAIN,所以我用while循环反复accept直到EAGAIN。这么做的好处是,如果同一时刻有大量连接来,一次事件通知就可以把队列里的连接全部处理完,避免“每次只accept一个,然后还要等下一次epoll_wait”的额外延迟。如果不把listenfd设为非阻塞,accept会一直阻塞下去,直接卡死整个事件循环,这在任何情况下都是不能接受的。
第二个是写缓冲与EPOLLOUT的配合。回显服务器把读到的数据copy到wbuf,然后调用try_flush尝试立刻write。因为socket是非阻塞的,write不一定能一次写完。如果写完就算了,如果没写完,就把剩下的留在wbuf里,同时在epoll里注册EPOLLOUT事件。内核会在socket发送缓冲区有空余时触发EPOLLOUT,这时再继续write。这个“写不完就等可写事件”的机制是reactor里最容易被忽视、又是最容易出bug的地方。很多新手只注册EPOLLIN,数据量大时直接丢消息,原因就在这里。
第三个细节是EPOLLRDHUP。如果对端关闭连接而本端还在注册EPOLLIN,正常情况下read会返回0,我们据此关闭连接。但TCP还有个四报文挥手前半段的半关闭状态,EPOLLRDHUP可以帮我们更早观察到对端已关闭。对端close时触发EPOLLRDHUP,结合EPOLLIN来判断,可以更干净地处理连接清理。
第四个是LT模式下读数据的循环。我的handle_read用while循环读,直到read返回EAGAIN。这里要注意,就算用LT,一次事件循环里尽量把数据读完也是合理的,可以减少循环次数。但不要忘记,如果读到的数据必须一次处理掉,就必须保证处理逻辑足够快,否则阻塞在这里,对其他连接不公平。这也就是前面说的,reactor线程里只适合快速操作,重逻辑必须往外丢。
5. 多线程扩展与超时处理的接入点
5.1 线程池与eventfd唤醒
上面这个单线程例子能跑通,但你把它丢到真实环境里,连接数一过几千,某个慢客户端的半包数据就可能拖慢整个服务。所以真实工程项目几乎都会引入多线程或线程池。该怎么改?
我建议的最小改造方案是:reactor线程只负责解析出一个完整的请求包,然后把请求对象交给一个固定大小的线程池;线程池里的线程执行业务逻辑,得到响应后通过一个响应队列塞回来。这时reactor线程可能正阻塞在epoll_wait里,为了不干等,我们创建一个eventfd,注册到epoll实例里。业务线程往响应队列push一条消息后,往eventfd写一个8字节整数,epoll_wait立刻就醒了,reactor线程从队列里取出响应并写回客户端。
代码思路大致是:
c复制// 业务线程
push_response_queue(resp);
uint64_t one = 1;
write(eventfd, &one, sizeof(one));
// reactor 线程 event loop
if (evs & EPOLLIN && fd == eventfd) {
uint64_t tmp;
read(eventfd, &tmp, sizeof(tmp));
drain_response_queue();
}
这个看似简单的eventfd唤醒机制,是很多reactor框架(比如muduo、libevent)的核心设计之一。它把“线程间任务”和“网络IO事件”统一到了一个event loop里,避免了锁等待的情况下event loop卡死的问题。
5.2 心跳超时与空闲连接踢除
服务器开发迟早会遇到“空闲连接清理”的问题。客户端可能断网了、进程崩溃了,TCP正常情况下不会立刻感知到,那些半死连接会一直躺在内存里,占用fd和连接对象。reactor模型里处理这件事的标准手段是“定时器 + 心跳”。
最简单可靠的办法:每个连接记录last_active_time,每次收到数据就更新时间。reactor线程里用最小堆(或时间轮)管理定时器,定期检查是否有连接超过N秒没有数据。有的话就主动close。内核这种超时管理天然适合在reactor里做,因为事件循环本身就是高频度的,只要每轮循环顺带检查一下当前时间和超时队列的最小到期时间对比即可。
如果不想自己写最小堆,简单场景下用一个定时精度不太高的方式也可以——在epoll_wait的timeout参数上做文章:把epoll_wait的超时设为“最近一个即将到期的连接对应的剩余毫秒数”,这样epoll_wait最迟会在某个连接超时的时候返回,循环里再检查处理。这个方法能work,但要注意epoll_wait的超时精度和系统负载会影响准确性。工程上还是建议用独立的最小堆定时器管理空闲连接。
6. 常见问题与排查技巧实录
6.1 惊群问题
多线程或多进程reactor的一个高频坑是惊群(thundering herd)。经典场景是:多个线程都在同一个listenfd上调用epoll_wait,或者多个进程都监听了同一个listenfd。当新连接到达时,内核会把所有等待线程都唤醒,但最终只有一个能accept成功,其他线程白醒一场,白白消耗CPU。
Linux 4.5之后,可以在EPOLL_CTL_ADD的时候加上EPOLLEXCLUSIVE标志,让内核只唤醒等待队列中的一个线程,用来避免惊群。多进程场景下更常用的手段是SO_REUSEPORT,让多个进程各自创建一个listenfd、绑定同一个端口,由内核按哈希分发连接,这其实已经是另一种负载均衡思路了。遇到惊群相关性能毛刺,优先检查你是否在多个线程共享了同一个epoll实例。
6.2 EPOLLOUT没注册导致数据发不出去
这是我见过最多的一个bug。服务端要返回一个很大的响应(比如几MB),write一次写不完,代码里又没有注册EPOLLOUT,结果数据就永远停在应用层缓冲区里,客户端一直等不到完整响应。排查时先看是不是消息量一大就卡住,如果是,检查一下你的写路径是不是正确处理了“EAGAIN后注册EPOLLOUT”的逻辑。还有一个容易忽略的点,就是写完后要记得把EPOLLOUT从事件里移除(用EPOLL_CTL_MOD改回只关注EPOLLIN),否则socket一旦可写就会触发大量空转事件,一瞬间吃掉一片CPU。
6.3 半包粘包问题
reactor循环里每次read到的数据不一定是完整的一条消息。TCP是字节流,没有消息边界,客户端可能一次send产生两次read,也可能两次send合并成一次read。这就是半包粘包问题。
解决思路一般就是在应用层协议里定义消息边界。常见的方案有三种:固定长度(每个包长度都一样,缺点是不灵活);以特殊分隔符结束(比如HTTP的\r\n\r\n,简单但需要进行内容转义);自定义消息头+消息体(头部4字节或8字节存len,后面再跟len字节的body,最通用,适合二进制协议)。实现时,reactor线程里的缓冲区要能累积多个read的结果,每收到一段数据就尝试解析出一个完整帧,解析不出完整帧就继续等下一次数据。这个“粘包处理”代码其实是reactor服务器里最考验细心的地方。
6.4 fd耗尽与系统参数调整
连接数很高时,你会遇到accept返回EMFILE,也就是进程fd数量达到上限。治标的方法是调大上限,用ulimit -n查看当前限制,临时调大用ulimit -n 102400。但治本的方法是在accept之前先检查当前fd使用量,快满的时候先关闭一个空闲连接,或者直接拒绝新连接并回复一个503。nginx里有一个worker_connections配置,也是基于类似的“不要等到fd耗尽才开始处理”的考虑。
还有一个常见的坑:线上服务器压测,QPS上不去,一看dmesg发现“too many open files”,这就是没调整进程fd限制造成的。新写的服务在上线前,先去把系统级的/etc/security/limits.conf和systemd服务文件里的LimitNOFILE配置好。
7. 写在最后:我的一些实际体会
我在实际写这些服务器组件的过程中,最大的体会是:reactor模式并不难,难点在于边界情况的处理。你能把一个连接从accept到关闭的完整生命周期管理清楚,把EPOLLIN、EPOLLOUT、EPOLLRDHUP各个事件的切换逻辑写对,把写缓冲和读缓冲的调度理顺,一个reactor服务器的骨架就已经立住了。
后续再要往深处走,无非是几个方向:把事件循环改造为多线程或主从模式,引入协程来消除回调地狱,或者更进一步去调研一下Linux新出现的io_uring机制。但无论模式怎么演进,背后事件驱动的思想、非阻塞IO的利用、以及“不要让等待阻塞处理”这个核心诉求,从头到尾都没有变过。
最后再分享一个小技巧:调试reactor服务器时,先用单线程、纯回显、不加任何业务逻辑的最简版本跑通,然后用nc或者写个小客户端发几轮数据验证。把最小闭环打通之后,再逐步往里面加业务逻辑、加线程池。千万不要一开始就把业务、线程池、心跳、协议解析全部塞进去,那样出了问题你根本分不清是网络层的问题还是业务逻辑的问题。分步推进,每一步都能验证,这才是稳妥的做法。
