1. “半”与“半”的分界线:先把这个词拆清楚
1.1 被误读最多的一个模式名
半同步/半异步模式这个名词,第一次见到的人十有八九会把它理解成“一半时间用同步IO,一半时间用异步IO”,甚至有人以为是在 select 和 epoll 之间二选一。这完全是跑偏了。这个模式描述的不是IO模型的切换,而是一个进程内部两种截然不同的处理风格共存:一部分代码活在“事件驱动、非阻塞、随时被打断”的世界里,另一部分代码活在“顺序执行、阻塞等待、一条道走到黑”的世界里。两拨人干的活性质不同,所以用的编程范式也不同,中间靠一个队列把两边接起来。
我在带新人的时候一般这么打比方:异步层像餐厅门口的迎宾和排队叫号系统,它不做饭,只负责感知“来了新客人”“某桌客人举手要结账”这类事件,感知到就把单子贴到后厨的取餐板上;同步层就是后厨的厨师,一人守一口锅,从板上撕一张单子,从头做到尾,中间不需要关心门外还有多少人在排队。厨师是阻塞式的,做菜过程中不理会新单子;迎宾是非阻塞式的,永远在门口转悠。这个分工一旦搞混,比如让厨师也跑到门口去迎宾,那这顿饭基本就吃不成了。
把这个理解套到服务器上:异步层通常是主线程跑一个epoll_wait循环,负责监听端口、感知新连接、感知哪些连接可读可写;同步层是一组工作线程,每条连接上的协议解析、业务计算、数据库往返、序列化回包,都在这里顺序做完。两层的编程难度差得很远——异步层难在状态机和非阻塞的边界处理,同步层难在并发安全和资源管理。
1.2 同步与异步在这里各自指什么
必须把术语钉死,否则后面全乱。这个模式里的“同步”,指的是工作线程的调用方式是同步阻塞的:它拿到一个连接之后,可以放心地read、可以放心地调用一个耗时几十毫秒的函数、可以放心地睡在锁上,因为它独占一个线程,不干活的时候没人催它。这里的“异步”,指的是事件分发层是异步回调的:主线程绝不会在某个连接上停留,它只负责把“这个fd有事了”这件事抛出去。
注意:这里的“异步”跟操作系统层面的异步IO(AIO、io_uring)不是一回事。半同步/半异步模式里的异步层,绝大多数实现依然是
epoll这种IO多路复用,本质上是“同步非阻塞IO + 事件循环”。模式名里的“异步”强调的是控制流的反转——不是你调用它,而是事件来调用你。
这个区分很重要,因为它决定了一个常见的架构选择:要不要在工作线程里也做非阻塞。答案是不需要。工作线程完全可以用阻塞式的读写,因为连接已经被“过户”给了它,别人不会来抢。当然,如果你把连接又还回了主线程的 epoll,那就又回到全异步了,这是另一个模式。
1.3 三层骨架与它的历史来源
完整的半同步/半异步模式其实是三层,不是两层。中间那层队列经常被忽略,但它是整个模式的枢纽:
| 层 | 职责 | 典型实现 | 编程范式 |
|---|---|---|---|
| 异步层 | 感知IO事件、接收新连接、分发 | epoll_wait / kqueue / IOCP | 非阻塞、事件回调 |
| 队列层 | 缓冲、解耦、限流、负载分配 | 互斥锁+条件变量的任务队列 | 临界区 |
| 同步层 | 业务处理、协议编解码、持久化 | 固定大小线程池 | 阻塞、顺序执行 |
这个结构最早在面向对象设计模式体系里被系统化描述,后来被大量网络中间件采用。它能活这么多年,核心原因就一个:它把最难写的并发代码压缩到了最小面积。全异步方案里,每一个业务函数都必须是可重入的、不能阻塞的、要考虑半包和状态机;而在这里,只有主线程那两百行代码需要小心,剩下的业务代码可以按最朴素的顺序思维写。
代价也很明显:线程上下文切换、队列的锁竞争、以及一层额外的内存拷贝或指针传递。这些代价在连接数几千、业务耗时毫秒级的场景里完全可以接受;在百万连接、业务是纯转发的场景里就未必划算。所以这个模式不是银弹,它有一个明确的适用区间。
2. 异步层的职责边界:主线程到底该干多少活
2.1 主线程只做三件事
我见过不少实现,主线程的epoll_wait里塞了日志落盘、配置热加载、定时任务扫描、甚至还有一段JSON解析。写着写着主线程就变成了第二个业务线程,整个架构退化成“一个全能线程 + 几个打杂线程”。这属于典型的分层失守。
守住边界的做法是给主线程列一份白名单,只允许它做三件事:监听新连接、把新连接打包成任务投递出去、响应退出信号。任何可能阻塞超过一微秒的操作,一律不许出现在这个循环里。日志可以先进一个无锁环形缓冲区,由独立线程刷盘;定时任务可以合并到一次epoll_wait的超时时间上,超时返回后做一次轻量检查;配置变更可以通过自管道(self-pipe)或eventfd唤醒主循环。
// 主线程循环的骨架,只做白名单内的事 for (;;) { int n = epoll_wait(epfd, evs, MAX_EVENTS, 1000); // 1秒超时用于定时检查 if (n < 0) { if (errno == EINTR) continue; // 被信号打断,正常现象 break; } for (int i = 0; i < n; i++) { if (evs[i].data.fd == listen_fd) { accept_new_connections(listen_fd); // 内部循环accept直到EAGAIN } else if (evs[i].data.fd == event_fd) { drain_eventfd(event_fd); // 唤醒信号,读掉计数即可 } } if (n == 0) do_lightweight_timer_work(); // 超时分支,保持极轻 }accept这里有个细节:监听socket如果设成边沿触发,必须在一个循环里accept到返回EAGAIN,否则会漏掉连接。如果是水平触发,一次accept一个也能工作,但在连接风暴下效率很低。我一般给监听socket设成边沿触发,然后把accept包一层循环。
2.2 accept 之后为什么不能就地处理
有人会问:既然连接已经到手了,主线程顺手把第一次数据读出来、把协议头解析掉,不是省了一次线程切换吗?短期看是省了,长期看是埋雷。
第一个雷是阻塞污染。第一次读往往需要读完整的握手包,如果客户端故意慢速发送(比如每100毫秒发一个字节),主线程就卡在这条连接上了,后面所有新连接、所有事件全部停摆。这就是最朴素的慢速攻击面。
第二个雷是状态耦合。一旦主线程处理了“连接建立后的第一个请求”,那第二个请求由工作线程处理时,两边就共享了这条连接的状态。上下文在两层之间来回迁移,加锁范围立刻变得难以界定,最后往往演变成“所有连接状态一把大锁”。
第三个雷是负载倾斜。主线程只做accept,它的CPU占用是可以预估的;一旦它开始处理业务,耗时就随业务而变化,容量规划彻底失去意义。
所以规则很清晰:连接的所有权在accept返回的那一刻就转移出去,主线程与这条连接再无关系。要传的东西尽量少,一般就是一个 fd,外加一个对端地址的快照用于日志。
2.3 epoll 的注册权归谁
这个问题必须提前定死,否则后面会出现“两个线程同时对同一个 fd 做epoll_ctl”的诡异现象。有两种主流分工:
第一种是主线程只监听 listen_fd,新连接的 fd 直接投递给工作线程,由工作线程注册到自己私有的 epoll 实例里。这种分工最干净,主线程的 epoll 里永远只有一个 fd,职责单一。
第二种是主线程监听所有 fd,工作线程只从队列里取“事件”而不是“连接”,处理完再把控制权交回主线程。这是半同步/半反应堆的典型形态,主线程实际上承担了分发器的角色,工作线程不碰 epoll。
我强烈建议新手从第一种开始。第二种在连接数多、事件密集时,主线程容易成为瓶颈,而且“事件回传”这个动作本身就需要线程安全的事件队列,复杂度上去了。第一种的代价是每个线程一个 epoll fd,几十个线程就是几十个 epoll 实例,但内核对这个的支持非常好,epoll_create1的开销可以忽略。
提示:一旦确定“谁注册谁负责”,就要把这条规则写进代码注释,并且在
epoll_ctl的封装函数里加断言——比如断言当前线程是 fd 的属主。这种断言在调试期能省掉大量排查时间。
3. 队列层才是胜负手:任务粒度、锁与背压
3.1 三种任务粒度及其代价
任务队列里放的到底是什么,直接决定了整个系统的并发质量。常见的有三种粒度,差异极大:
| 粒度 | 队列元素 | 优点 | 代价 |
|---|---|---|---|
| 连接级 | 一个 fd | 实现最简单,工作线程完全接管 | 长连接场景下线程被长期占用 |
| 事件级 | (fd, 事件类型) | 负载均衡好,线程复用率高 | 需要保证同 fd 事件串行 |
| 请求级 | (fd, 请求数据块) | 最细的调度,适合CPU密集 | 需要额外的会话状态管理 |
连接级最省心,工作线程拿到 fd 之后,自己epoll_wait管这条连接的生命周期,直到它关闭。缺点是如果一条连接是长连接且长期空闲,它就白白占着一个线程的 epoll 槽位——不过这不是大问题,因为 epoll 里挂空闲 fd 的成本很低,一个线程能扛几千条。真正的限制是活跃连接数:如果一个线程要同时处理几千条活跃连接,它又回到了异步编程的困境里。
事件级更灵活,但引入了“同一连接的多个事件必须串行”这个约束。如果不加约束,两个线程同时处理同一个 fd 的读事件,立刻就是数据错乱。要串行就得对 fd 加锁,锁的粒度和哈希分桶又是个新话题。
我的经验是:业务耗时在毫秒级、连接数在万级以下,用连接级足够了。为了这个场景去过早引入事件级调度,收益远小于复杂度成本。
3.2 互斥锁 + 条件变量的朴素实现,以及它的上限
任务队列最经典的写法就是一个std::mutex加一个std::condition_variable,生产者push后notify_one,消费者wait到队列非空再pop。这个写法正确性容易保证,代码量二十行以内。
class TaskQueue { public: void push(Task t) { std::lock_guard<std::mutex> lk(mtx_); q_.push(std::move(t)); cv_.notify_one(); } bool pop(Task &out) { std::unique_lock<std::mutex> lk(mtx_); cv_.wait(lk, [this] { return !q_.empty() || stop_; }); if (q_.empty()) return false; // 停止且队列已空 out = std::move(q_.front()); q_.pop(); return true; } void stop() { std::lock_guard<std::mutex> lk(mtx_); stop_ = true; cv_.notify_all(); } private: std::mutex mtx_; std::condition_variable cv_; std::queue<Task> q_; bool stop_ = false; };它的性能上限在哪里?大概在每秒几十万次进出队列的量级。对一台普通服务器来说,这对应着每秒几十万的新建连接或者任务投递,绝大多数业务达不到。真正的问题不是吞吐,而是抖动:当队列长度从0涨到几百,notify_one唤醒的线程可能正在处理长任务,队列里的任务就只能干等,尾延迟迅速恶化。
一个有效的缓解手段是多队列 + 唤醒分散。每个工作线程配一个自己的队列,主线程轮询投递(round-robin),这样某个线程的长任务不会阻塞其他队列的任务。代价是负载可能不均,需要配合“偷任务”机制——线程自己的队列空了,就去邻居队列尾部偷一个。偷任务的实现要小心,别把锁的范围搞大。
3.3 背压与队列上限
还有一个绝对不能省的东西:队列长度上限。我见过不止一次线上事故,是队列无界导致的——某个下游依赖变慢,工作线程全部堵在业务调用上,主线程还在拼命accept并投递,队列内存一路涨到把进程撑爆。
正确做法是给队列设一个水位线,超过就直接拒绝或降级:
bool try_push(Task t) { std::lock_guard<std::mutex> lk(mtx_); if (q_.size() >= kMaxQueue) return false; // 满了,返回失败 q_.push(std::move(t)); cv_.notify_one(); return true; }队列满的时候怎么办?三种处理,按业务选:直接close(fd)并回一个 503;或者暂停accept并从主线程 epoll 里摘掉 listen_fd,等队列降下来再挂回去;或者走一个独立的拒绝路径快速回包。第一种最粗暴但最安全,第三种体验最好但要多写代码。暂停 accept 是我个人最推荐的,因为它把压力挡在内核的 accept 队列里,既不占用户态内存,又能自动限速。注意摘掉 listen_fd 之后要记得在某个时机挂回去,通常是在工作线程pop之后判断水位已经低于阈值再EPOLL_CTL_ADD。
4. 同步层的线程池:工作线程如何接管一条连接
4.1 每线程一个 epoll 还是共享一个
工作线程接管连接之后,立刻面临一个问题:它怎么知道自己负责的连接有事了?
方案A是每个线程一个epoll实例,接管连接时epoll_ctl(ADD),处理完事件后如果连接还活着就留在自己的 epoll 里。方案B是全局共享一个 epoll,所有线程都在上面epoll_wait,谁抢到谁处理。
方案B的坑在于同一连接的惊群:多个线程同时被同一个 fd 的读事件唤醒,只有一个能读到数据,其余的读到EAGAIN白跑一趟。在老内核上这个开销相当可观。现在内核提供了EPOLLEXCLUSIVE标志可以缓解,但语义上仍然是“多个等待者”,只是唤醒数量受控。
方案A没有惊群问题,因为 fd 只在一个 epoll 里。它的代价是线程之间的负载可能不均——某个线程手里一堆活跃连接,另一个线程全是空闲连接。这个问题在连接级任务粒度下可以通过“连接迁移”解决:负载高的线程主动把一部分空闲连接的 fdepoll_ctl(DEL),然后作为任务投递回队列,让空闲线程接走。不过这属于优化项,先跑通再说。
4.2 EPOLLONESHOT 与重新注册
如果工作线程内部还用了边沿触发,那么EPOLLONESHOT基本是必需品。它的作用是:事件触发一次之后,该 fd 在这个 epoll 里自动失效,直到你重新EPOLL_CTL_MOD。
为什么需要它?因为边沿触发下,一次事件可能对应大量数据,工作线程要循环读到EAGAIN。在它读的过程中,如果又有新数据到达,内核会再次标记可读。如果不加EPOLLONESHOT,而工作线程又恰好把 fd 交给了别的线程处理(比如做连接迁移),就会出现两个线程同时读写同一个 fd 的灾难。
用了EPOLLONESHOT之后,流程变成:事件到达 → 线程处理 → 处理完毕后在同一线程里重新MOD加上EPOLLIN | EPOLLONESHOT。这里最容易犯的错误是忘了重新注册,表现是连接“卡死”——客户端发数据,服务端毫无反应,epoll_wait再也不返回这个 fd。我第一次踩这个坑的时候,排查了整整一天,最后是在日志里发现某个连接的事件计数停在1不动了。
void handle_read(int epfd, int fd) { char buf[8192]; for (;;) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { process(buf, n); // 业务处理 } else if (n == 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); return; // 关闭了就不用重新注册 } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 读干净了 if (errno == EINTR) continue; // 被打断,继续 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); return; } } // 关键一步:重新武装 struct epoll_event ev; ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); }这段代码里还有几个必须注意的点。read返回EINTR时要继续,不能当成错误;EAGAIN和EWOULDBLOCK在Linux上是同一个值,但写上两个更清晰;只有n > 0的时候才真正有数据,process里如果抛异常,必须保证 fd 不会泄漏。
4.3 关闭连接的归属问题
一条连接可能被多方关闭:客户端主动断开、服务端业务逻辑要求断开、超时清理线程判定空闲太久、以及进程退出。如果这四路都能close(fd),那必然出问题——fd 被关闭后可能立刻被accept复用,另一条无辜的连接就被误伤了。
解决办法是关闭权收归属主线程。任何一方想关连接,不是直接close,而是给属主线程发一个“关闭请求”(可以通过 eventfd 或者一个原子标志位),由属主线程在它自己的事件循环里执行EPOLL_CTL_DEL+close。这样 fd 的生命周期始终由一个人管,不会出现竞态。
超时清理这块,我一般不给每条连接单独起定时器(成本太高),而是维护一个按最后活跃时间排序的双向链表或者时间轮。工作线程每次处理完事件就把该连接移到链表头,清理线程定期从尾部扫,扫到超时的就投递关闭请求。这样均摊成本是 O(1)。
5. 一个能跑的最小骨架
5.1 数据结构与全局约定
下面这个骨架我去掉了所有错误处理和业务逻辑,只保留结构。它的目的是让你看清楚数据怎么流动,不是直接拿去上生产。
#define MAX_EVENTS 1024 #define WORKER_NUM 4 typedef struct { int fd; } task_t; /* 队列:一把锁一个条件变量,够用 */ static task_t g_queue[65536]; static int g_head = 0, g_tail = 0; static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; static int g_stop = 0;约定三条:主线程的产品只有一个,就是 fd;工作线程的消费单位也是一个 fd;队列满时主线程负责拒绝。
5.2 主线程:accept 与投递
static void *acceptor_thread(void *arg) { int listen_fd = *(int *)arg; int epfd = epoll_create1(0); 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 evs[MAX_EVENTS]; for (;;) { int n = epoll_wait(epfd, evs, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) continue; break; } for (int i = 0; i < n; i++) { for (;;) { struct sockaddr_in addr; socklen_t len = sizeof(addr); int conn = accept4(listen_fd, (struct sockaddr *)&addr, &len, SOCK_NONBLOCK | SOCK_CLOEXEC); if (conn < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; /* EMFILE 等,需要特殊处理,这里简化 */ } pthread_mutex_lock(&g_mtx); if ((g_tail + 1) % 65536 == g_head) { pthread_mutex_unlock(&g_mtx); close(conn); /* 队列满,直接拒绝 */ continue; } g_queue[g_tail].fd = conn; g_tail = (g_tail + 1) % 65536; pthread_cond_signal(&g_cv); pthread_mutex_unlock(&g_mtx); } } } return NULL; }accept4直接把SOCK_NONBLOCK和SOCK_CLOEXEC带上,省了两次fcntl调用。这个细节在高并发建连时能省下可观的系统调用开销,属于零成本优化。
5.3 工作线程:接管与循环
static void *worker_thread(void *arg) { int epfd = epoll_create1(0); struct epoll_event evs[MAX_EVENTS]; for (;;) { pthread_mutex_lock(&g_mtx); while (g_head == g_tail && !g_stop) pthread_cond_wait(&g_cv, &g_mtx); if (g_head == g_tail && g_stop) { pthread_mutex_unlock(&g_mtx); break; } int conn = g_queue[g_head].fd; g_head = (g_head + 1) % 65536; pthread_mutex_unlock(&g_mtx); struct epoll_event ev; ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.fd = conn; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev) < 0) { close(conn); continue; } /* 该线程后续在自己循环里处理这条连接,见下 */ serve_loop(epfd, evs); /* 简化:进入内层循环 */ } return NULL; }真实实现里serve_loop不能是一进不出的内层循环,否则这个线程就永远只服务一条连接了。正确做法是把新 fd 加入自己的 epoll 后回到统一的epoll_wait,所有自己负责的 fd 一起等。上面的写法只是为了把“注册”这一步讲清楚。实际结构应该是主循环里epoll_wait,超时时间设短一点(比如100毫秒),顺便检查一下队列里有没有新连接要接管。
5.4 编译与压测
gcc -O2 -pthread server.c -o server ./server 9527 # 另开终端 wrk -t4 -c1000 -d30s http://127.0.0.1:9527/压测的时候重点看三个指标:队列长度的最大值、每个工作线程的事件处理次数分布、以及perf top里pthread_mutex_lock的占比。前两个判断负载是否均衡,第三个判断锁是不是瓶颈。如果锁占比超过10%,就该考虑分片队列了。
6. 变体对照:半同步/半反应堆、领导者/跟随者,以及什么时候别用
6.1 主从 Reactor 是同一个思路的另一种切法
主从 Reactor 结构在其他地方也被叫作半同步/半反应堆模式,它跟本文这个模式的差别只有一处:异步层管的是所有 fd,不只是 listen_fd。主 Reactor 负责accept,然后把 connfd 交给某个从 Reactor(通常一个线程一个从 Reactor),从 Reactor 负责这条连接上所有的读写事件分发,再往下才是工作线程池做业务。
也就是说,本文的模式是“异步层 + 同步层”两层,主从 Reactor 是“异步层 + 异步层 + 同步层”三层。多出来的这一层从 Reactor,把“事件分发”和“业务处理”彻底分开了。
这两者怎么选?看业务里IO和计算的比例。如果业务以转发、代理为主,计算量小,那多一层从 Reactor 意义不大,直接让工作线程自己管 epoll 更省。如果业务里有较重的计算(比如图片处理、加解密),把事件分发单独抽出来就很有价值,因为工作线程可以专心算,不必被epoll_wait的超时管理分心。
6.2 领导者/跟随者模式是它的直接竞争者
领导者/跟随者模式解决的是同一个问题,但机制完全不同。它没有队列:所有线程轮流当“领导者”,领导者负责epoll_wait,有事件来了就自己处理,处理完之后指定下一个线程当领导者,自己退到跟随者队列尾部。
这个设计的优势是没有队列、没有锁、没有上下文传递,理论上延迟更低。缺点是实现难度陡增:领导者交接必须严格同步,任何一处竞态都会导致事件丢失或者多个线程同时认为自己是指挥官;而且一旦某个跟随者处理的事件特别慢,整个组的吞吐就被拖住,因为它占着线程却不能当领导。
我实际对比过这两个模式在同一个简单回显服务上的表现,连接数千、每连接每秒几次请求的场景下,两者吞吐差异在5%以内,延迟分位数也基本重合。也就是说,在这个量级上,选哪个都能跑,选你团队更熟悉的那个。领导者/跟随者更适合那种事件到达非常密集、每次处理极短的场景,比如高频交易的消息分发;半同步/半异步更适合业务逻辑复杂、单个请求耗时不稳定的场景。
6.3 什么情况下别用这个模式
有三类场景我会明确避开它:
第一类是连接数百万、每条连接流量极低的场景。这时候一个线程管几千条连接已经不够了,你需要的是全异步或者协程,把每条连接的成本压到几KB级别。线程模型下每条连接对应的栈和状态开销会压垮内存。
第二类是纯转发或纯代理。这种业务没有真正的“计算”,从事件到响应几乎是直通,中间加一个队列只会徒增延迟。直接单线程多路复用,或者多线程各自多路复用,都比过队列快。
第三类是请求处理时间极短且方差极小,比如纯内存KV的简单读。这时候队列的锁开销可能比业务本身还大,不如让线程自己accept(配合SO_REUSEPORT让内核做负载均衡),彻底省掉队列这一层。
7. 那些只有真跑起来才会遇到的问题
7.1 惊群的两个层面
第一个层面是accept惊群:多个线程或进程阻塞在同一个 listen_fd 的accept上,新连接到来时全部被唤醒,只有一个成功。解决办法是主线程单独accept(本文模式天然避开),或者用EPOLLEXCLUSIVE标志,或者依赖较新内核的SO_REUSEPORT让每个线程持有独立的监听socket。
第二个层面是epoll_wait内部的惊群:即使只有一个 epoll 实例被多个线程等待,每次事件也会唤醒多个线程。这个在较新的内核里已经做了优化(EPOLLEXCLUSIVE或者 wakeup 时的单线程唤醒),但如果你用的是共享 epoll 的方案,还是建议在压测里观察一下上下文切换次数。
7.2 队列抖动与负载不均
我遇到过一个很典型的抖动问题:压测开始的头两秒,P99延迟是正常值的五倍。查下来是队列从空到满的瞬间,notify_one唤醒的线程刚好在处理一个慢请求,队列里的任务只能等。后来改成了每次push之后如果队列长度超过阈值就notify_all(唤醒多个线程分摊),抖动明显好转。
另一个问题是负载不均。四个工作线程,其中一个的事件处理计数是其他三个的两倍。原因是连接级任务粒度下,如果某个客户端特别活跃,它所在的线程就一直忙。缓解办法是在队列层做一次洗牌——不是简单地轮询投递,而是记录每个线程的当前负载,投给最闲的那个。这个改动不到二十行,效果立竿见影。
7.3 优雅退出比想象中麻烦
进程收到退出信号时,正确的顺序是:主线程停止accept,设置停止标志,唤醒所有工作线程,工作线程处理完手头的连接后退出,最后统一释放资源。任何一步顺序错了都会导致连接被硬切断或者线程卡在epoll_wait里出不来。
epoll_wait本身是可以被信号打断的,但如果信号被别的线程接收了,工作线程就还在睡。所以需要一个 eventfd:信号处理器往 eventfd 写一个值,工作线程的 epoll 监听着这个 fd,收到就退出循环。信号处理器里只做这一件事,其余逻辑都放在正常流程里。
static int g_exit_fd; static void on_signal(int sig) { uint64_t one = 1; write(g_exit_fd, &one, sizeof(one)); /* 异步信号安全 */ }工作线程看到g_exit_fd可读,就把自己 epoll 里所有连接DEL掉再close,然后退出。主线程等所有工作线程join完成,再释放监听socket。这个过程里最忌讳的是用exit()直接从信号处理器里跑路,那会导致所有缓冲区的数据丢失。
7.4 缓冲区与内存生命周期
最后说一个容易被忽视的点:每条连接的读缓冲区归谁管,什么时候释放。连接级任务粒度下,缓冲区的生命周期跟着连接走,关闭的时候一起释放,比较简单。但如果你在异步层做了预读(比如accept之后顺手read一次),缓冲区就跨层了,谁释放就成了问题。
我的做法是绝不让缓冲区跨层。异步层的缓冲区用完立刻回收进对象池,投递的任务里只带 fd 和一个对端地址的拷贝。工作线程拿到 fd 后自己分配缓冲区,自己负责释放。这条规矩能省掉九成的内存泄漏和重复释放问题。
对象池本身也要注意,不要用无界池,否则高峰期涨上去就降不下来。给池设一个上限,超过上限的申请走普通malloc,释放时判断池是否已满,满了就直接free。
这套东西写完之后,最深的体会是:模式本身只有几百字就能说清,真正耗时的是那些边界情况——连接什么时候关、事件什么时候重新注册、队列满了怎么办、进程什么时候退出。这些细节在任何一张架构图里都看不到,但它们决定了你的服务能不能在半夜三点不被人叫醒。