“网络IO模型”这几个字,很多人第一次见到是在面试题库里,第二次见到是在凌晨排查一个 CPU 跑满、流量却低得可怜的线上事故时。我属于后者。当时一个内部服务 QPS 不到两百,机器负载却一直飘红,top 一眼看过去全是 sys 态时间,最后定位到一段每 10 毫秒轮询一次 accept 的代码——连接没几个,CPU 全耗在无谓的系统调用上。那次之后我才真正把阻塞、非阻塞、多路复用这几件事从头捋了一遍,也才发现网上大量文章讲的是“定义”,而真正让人卡住的往往是定义之间的那层窗户纸。
这篇东西不讲教科书式的模型罗列,讲的是我自己理解的顺序:先搞清楚一次 read 调用在内核里到底发生了什么,再谈五种网络IO模型的差别究竟落在哪一步,最后落到 epoll 怎么用、Reactor 怎么组织、长连接服务里有哪些坑。适合写过 socket 但被各种模型绕晕的同学,也适合正在做网关、消息中间件、长连接推送这类系统选型的同学。看完至少能做到一件事:拿到一个具体业务场景,能自己推导出该用哪种模型,而不是从别人的配置文件里抄一段参数。
1. 一次read调用背后,内核到底替你做了哪些事
1.1 数据从网卡到用户缓冲区的两段路程
很多人对 IO 的直觉是“我调了 read,数据就来了”,这个直觉会直接导致后面理解模型时全线崩塌。真实情况是,一次完整的网络读操作被切成了两段,而且这两段的耗时占比可能相差几个数量级。
第一段是等待数据就绪。网卡收到电信号之后,通过 DMA 把数据帧写进内核的环形缓冲区,硬件触发中断,内核走软中断把数据交给协议栈,协议栈按 TCP 状态机做校验、排序、重组,最终把有序的字节流放进这个 socket 对应的接收缓冲区。这一整段过程,你的应用进程是完全不知情的,它甚至可能根本没被调度上 CPU。
第二段是数据拷贝。当应用进程调用 read 时,内核把已经躺在 socket 接收缓冲区里的数据,复制到你的用户态缓冲区里,这次拷贝是实打实的内存操作,绕不过去。
所以 IO 的总耗时可以写成一行:总时间 = 等待数据就绪的时间 + 拷贝数据的时间。五种网络IO模型的全部差异,几乎都集中在第一项上——谁在等、怎么等、等的时候有没有把 CPU 让出来。第二项拷贝的时间大家基本一样,除非你上零拷贝。
理解了这个切分,后面所有的模型都能串起来。阻塞 IO 是第一段和第二段全程挂着;非阻塞 IO 是第一段反复试探;多路复用是把第一段的“等”外包给内核里的一个统一调度点;异步 IO 则是连第二段都不让你自己干。
1.2 阻塞到底发生在哪一行代码
“阻塞”这个词听着抽象,落到内核里其实非常具体。当你在一个阻塞 socket 上调用 read,而接收缓冲区里没有数据时,内核会把你这个进程(准确说是当前任务)挂到这个 socket 的等待队列上,把进程状态从 RUNNING 改成 TASK_INTERRUPTIBLE,然后主动调用调度器让出 CPU。
这个动作的意义在于:你的进程不再占用 CPU,但也没有消失,它只是被挪到了一边,等待某个事件把它唤醒。当新数据到达、协议栈处理完毕之后,内核会遍历这个 socket 的等待队列,把里面挂着的进程一个个唤醒,重新放回可运行队列。进程被唤醒后从 read 内部继续往下走,发现数据有了,执行拷贝,返回字节数。
这里有个容易被忽略的细节:唤醒不等于立刻运行。被唤醒只是把进程状态改回可运行,真正拿到 CPU 还要看调度器的脸色。如果系统负载很高,从“数据到达”到“你的 read 返回”之间可能还有一段可观的延迟。这也是为什么在高并发场景下,阻塞 IO 的尾延迟会变得非常难看——不是因为 IO 慢,而是因为唤醒之后排队等 CPU 的时间被算进去了。
还有个更隐蔽的点:唤醒之后,内核会重新检查一次条件。因为存在多个进程等同一个 socket、或者信号打断的情况,所以内核里这类等待都是放在一个循环里的,条件是“数据是否真的就绪”,不满足就继续挂回去。这个设计在应用层写代码时也有对应——后面讲条件变量和 ET 模式时会反复回到这个思路上。
1.3 拷贝这一步为什么绕不过去
用户态和内核态的内存是隔离的,这是操作系统的基本安全边界,不是性能设计上的疏忽。内核不可能把 socket 缓冲区的指针直接交给你,因为那样你就能读写任意内核内存了。所以只要数据要进入用户空间,就必然存在一次拷贝(或者一次地址映射的重构)。
那常说的零拷贝是怎么回事?零拷贝减少的是拷贝次数,不是消除所有搬运。传统的“读文件发网络”路径是:磁盘 -> 内核页缓存 -> 用户缓冲区 -> socket 发送缓冲区 -> 网卡,中间两次 CPU 参与的拷贝。用sendfile之后,数据从页缓存直接进 socket 缓冲区,省掉了进出用户态的那一趟;用mmap则是把页缓存映射进用户地址空间,让你“看起来”拿到了数据,实际是共享同一份物理页。
对绝大多数业务来说,拷贝这一段的成本占比并不高,因为单次 IO 的数据量通常不大。但如果你在做文件分发、大消息中转、视频流代理这类场景,拷贝成本会迅速成为瓶颈,这时候才值得专门去做零拷贝优化。判断方法很朴素:算一下每秒搬运的字节数,如果已经到了几个 GB 的量级,那拷贝开销必然显著;如果只是几十 MB,先把等待那一侧的模型选对更划算。
2. 五种网络IO模型的真正分水岭:谁在等,等的时候在干什么
2.1 阻塞与非阻塞:差别只在那一次试探
阻塞 IO 的代码写起来最舒服,逻辑是线性的:read 一下,有数据就处理,没数据就等着。单连接、低并发的场景下这套东西没有任何问题,甚至比后面那些复杂模型更不容易出错。
问题出在连接数上来之后。假设你要同时服务一千个连接,用阻塞 IO 就得开一千个线程,每个线程大部分时间都在等待队列里躺着。线程本身是有成本的:默认栈空间通常 8MB 虚拟内存,内核里还有对应的 task_struct,上下文切换也要花钱。一千个还扛得住,一万个就开始难受,十万个基本没戏。
非阻塞 IO 是把等待的责任交回给应用:给 socket 设置O_NONBLOCK,然后 read,没数据内核直接返回EAGAIN或EWOULDBLOCK,不挂起进程。听起来很美,但如果你只是在一个 while 循环里反复 read,那就是把“内核帮你等”换成了“你自己空转”,CPU 占用直接打满。这就是我开头提到的那个事故的成因——轮询间隔设得太短,等于拿 CPU 换延迟,而实际上延迟一点没降。
所以非阻塞 IO 单独用是没有意义的,它必须和多路复用配合。非阻塞真正的价值在于:当多路复用告诉你某个 fd 就绪时,你去读它,要保证这次读不会卡住。因为“就绪”和“读得到”之间是有时间差的,如果 fd 是阻塞的,这个时间差就可能让你的整个事件循环停摆。
2.2 IO多路复用:把“等”外包给一个统一调度点
多路复用的思路转换很关键:与其让每个连接各自等,不如派一个代表去等,等到有结果了再通知对应的连接。这个代表就是select/poll/epoll这一族系统调用。
用法上,你把关心的一批 fd 交给内核,调用epoll_wait,线程在这里挂起;当这批 fd 里任意一个就绪,内核唤醒你,并告诉你是哪些 fd 就绪了;你拿着这个结果去逐个处理,处理时用非阻塞读写。
这套机制带来的核心收益是线程数与连接数解耦。一个线程可以盯住几万个连接,线程数量从“跟连接数成正比”变成“跟 CPU 核数成正比”。现代几乎所有高并发网络服务——网关、消息队列、长连接推送、反向代理——底层都是这个套路。
代价是编程模型变复杂了。你的代码不再是“读-处理-写”的线性流程,而变成了一个事件循环:注册回调、等待事件、分发处理。状态机、半包处理、写缓冲、超时管理这些原本由线程栈帮你保存的东西,现在都要自己维护。这就是为什么很多团队一开始用多路复用会把代码写成一团乱麻——不是模型的问题,是工程组织没跟上。
2.3 信号驱动与异步IO:看起来像,实际差得远
信号驱动 IO 的做法是:给 socket 注册SIGIO信号处理函数,然后就可以去干别的了,数据到达时内核发信号给你,你在信号处理函数里再去 read。理论上很优雅,实际用的人极少,原因很实在——信号处理函数的执行上下文限制太多,不能安全地做复杂操作,多个 fd 同时就绪时信号会合并(标准信号不排队),你根本不知道到底几个连接有数据。要在信号处理里做“把 fd 塞进队列、唤醒主循环”这种事,绕一圈还不如直接用 epoll。
异步 IO 常被误解。严格意义上的异步 IO 是:你发起一个读请求,然后立刻返回,当数据不仅到达、而且已经拷贝进你提供的用户缓冲区之后,内核再通知你。注意这里包含了第二段拷贝。而 epoll 那种模式,数据就绪时通知你,拷贝还得你自己调 read 去完成,所以它属于“就绪通知”,不是真异步。
Linux 上早期的 POSIX AIO 实现一直被吐槽,很多操作实际上是模拟的,性能没有优势。真正把异步做起来的是后来的io_uring,通过一对共享内存的环形队列提交和收割请求,减少了系统调用次数。但它的生态和调试工具链还在成熟过程中,除非你在做存储引擎、高性能代理这类对 IO 路径极致敏感的东西,否则不建议一上来就用。
把五个模型放在一起对照,差别一目了然:
| 模型 | 等待数据阶段 | 拷贝阶段 | 单线程可管理连接数 | 编程复杂度 |
|---|---|---|---|---|
| 阻塞 IO | 进程挂起 | 进程自己拷贝 | 1(每连接一线程) | 低 |
| 非阻塞 IO | 应用轮询 | 进程自己拷贝 | 依赖轮询策略 | 低但浪费 CPU |
| IO 多路复用 | 内核代等,事件通知 | 进程自己拷贝 | 万级以上 | 中高 |
| 信号驱动 IO | 信号通知 | 进程自己拷贝 | 受信号机制限制 | 高且坑多 |
| 异步 IO | 内核全程代办 | 内核代办 | 万级以上 | 高 |
3. select、poll、epoll:三代多路复用的能力边界
3.1 select的1024上限和每次全量拷贝
select 是最老的一版,接口长这样:传入三个 fd 集合(读、写、异常),一个超时时间,返回后集合被就地修改,你遍历一遍找出哪些位还在,就知道哪些 fd 就绪了。
它有三个硬伤。第一是FD_SETSIZE默认 1024,这是编译期常量,想改就得重新编译内核或干脆换方案。第二是每次调用都要把整个 fd 集合从用户态拷到内核态,连接数越多拷贝越大,而这个拷贝在每次调用时都要重来一遍。第三是内核里是线性扫描,检查每个 fd 是否有事件,时间复杂度 O(n),返回后应用层还要再 O(n) 遍历一次。
这三条叠加起来,决定了 select 只能在几百个连接的规模下工作。但它也不是一无是处:跨平台支持最好,几乎所有操作系统都有,写兼容层的时候还得靠它兜底。
3.2 poll用数组换掉了位图,但没解决遍历
poll 把 fd 集合从位图换成了pollfd数组,每个元素带上events(关心什么)和revents(实际发生了什么),这样就不需要每次调用前重建集合了,也没有 1024 的数量上限。
但它没解决根本问题:每次调用仍然要把整个数组拷进内核,内核仍然要线性扫描所有 fd。连接数到几万时,即使没有事件发生,每次poll调用也要扫描几万个结构体,白白烧 CPU。所以 poll 更像是一个过渡产物,接口比 select 好用,性能量级没变。
3.3 epoll的三个系统调用与红黑树加就绪链表
epoll 的思路完全变了:把“注册”和“等待”拆成两件事。
epoll_create1创建一个 epoll 实例,内核里对应一个 eventpoll 对象。epoll_ctl增删改关注的 fd,每个 fd 挂到实例内部的一棵红黑树上,同时给这个 fd 注册一个回调,在它的等待队列里挂上钩子。epoll_wait只做一件事:检查就绪链表里有没有东西,有就取出来返回,没有就睡。
关键在于那个回调。当某个 fd 上有数据到达,协议栈在处理时会触发这个 fd 的等待队列,从而调用 epoll 注册的回调,回调把这个 fd 挂到就绪链表上,同时唤醒正在epoll_wait上等待的线程。整个过程里,内核不需要扫描全部 fd,只处理真正有事件的那几个。
这就解释了为什么 epoll 在“连接多但活跃少”的场景下优势巨大。十万个连接里每秒只有一千个活跃,epoll 的开销基本正比于那一千,而 select/poll 要正比于十万。
用代码写出来大概是这样:
int epfd = epoll_create1(0); struct epoll_event ev, events[1024]; ev.events = EPOLLIN | EPOLLET; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { 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) { accept_new_conn(epfd, listen_fd); } else if (events[i].events & EPOLLIN) { handle_read(fd); } } }这段骨架很短,但每一行背后都有讲究,尤其是EPOLLET这个标志,下面单独说。
3.4 LT与ET:改不改状态这一点差别,带出两套编程模型
水平触发(LT,默认)的语义是:只要这个 fd 的接收缓冲区里还有数据没读完,epoll_wait每次都会把它返回。这跟 select/poll 的行为一致,写起来最不容易出错——你甚至可以只读一部分,剩下的下次再说。
边缘触发(ET)只在状态变化时通知一次:缓冲区从空变成非空,通知一次;之后你读不读、读多少,内核不管,除非又来了新数据让状态再次变化。
ET 用好了性能更好,因为减少了重复通知和系统调用次数。但它有个铁律:收到通知后必须循环读到EAGAIN为止。如果你只读了一次就回去等下一轮,而缓冲区里还剩数据,那这些数据就会一直躺着,直到有新数据到来才会再次触发通知——在新数据到达之前,这个连接在你看来就是“假死”的。
这个坑我在实际项目里踩过一次,表现是某些请求偶尔超时,日志里看不出任何异常,抓包发现服务端明明收到了数据却没回。排查了很久才定位到是一个 handler 里只做了一次 read。修复方式很直接:
while (1) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { append_to_buffer(buf, n); } else if (n == 0) { close_conn(fd); break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; close_conn(fd); break; } }这里还有个配套要求:ET 模式下的 fd必须是非阻塞的。因为在读到EAGAIN之前,最后一次 read 一定会因为没有数据而阻塞,如果 fd 是阻塞的,整个事件循环就停在那里了。这两个设置是绑定的,缺一不可。
我的建议是:除非你有明确的性能压力并且团队对这套机制足够熟悉,否则默认用 LT。LT 的性能损失在绝大多数业务场景下可以忽略,而它带来的容错性——比如某次处理没读干净、比如代码里有个分支提前 return 了——是实打实的。
4. Reactor:把多路复用组织成能维护的代码
4.1 单Reactor单线程能撑住的边界
直接拿epoll_wait写业务代码,很快就会写不下去:连接状态、缓冲区、超时、协议解析全糊在一起。Reactor 模式就是给这套机制套一个结构化外壳,核心角色有三个:Reactor 负责等事件、Dispatcher 负责分发、Handler 负责处理具体业务。
单 Reactor 单线程是最简单的形态:一个线程跑事件循环,accept、read、业务计算、write 全在里面做。它的优势是无锁——没有任何共享状态需要保护,逻辑清晰,调试友好。著名的内存键值存储、一些轻量级代理都用这个模型,能跑到相当可观的吞吐。
但它的天花板也很明显:任何一个 handler 里出现耗时操作,整个事件循环就卡住,所有连接一起受影响。所以用这个模型的铁律是:handler 里绝对不做阻塞调用。数据库查询、磁盘读写、外部 HTTP 请求,一律不能直接放进去。这不是性能优化建议,是这套模型的生存前提。
4.2 主从Reactor:把accept和read拆到不同线程
连接数继续涨、业务逻辑继续变重,单线程就不够了。主从 Reactor 的思路是把职责拆开:主 Reactor 只负责 accept,拿到新连接后按某种策略分配给某个从 Reactor;每个从 Reactor 跑在自己的线程里,各自维护一个 epoll 实例,负责这批连接的读写和业务处理。
这样做的收益有两层。一是 accept 和读写互不干扰,新连接洪峰不会拖慢已有连接的处理。二是可以利用多核,从 Reactor 的数量通常设成 CPU 核数或核数的两倍。
分配策略上有个细节值得注意:同一个连接的所有事件必须落在同一个从 Reactor 上。因为连接是有状态的(半包、待发送数据、协议解析进度),如果读写被分到不同线程,就得加锁保护这些状态,之前省下来的复杂度又还回去了。常见的做法是对连接 id 或 fd 做哈希取模,简单有效。
真正处理业务时,如果 handler 里有阻塞操作,还可以再往下拆一层业务线程池:从 Reactor 只负责把完整的请求包解析出来,扔进队列,业务线程池处理完再把响应交回 Reactor 发送。这时候就要注意回写的线程安全问题——通常是让 Reactor 线程统一负责写,业务线程通过任务队列把数据交回去,避免两个线程同时操作同一个 socket 的发送缓冲区。
4.3 Proactor与io_uring:异步的两种落地路径
Reactor 的本质是“就绪通知 + 应用自己搬数据”,Proactor 则是“完成通知 + 内核搬完数据”。Windows 上的 IOCP 是 Proactor 的经典实现,Linux 上长期没有对等的东西,直到io_uring出现。
io_uring的模型是提交-完成队列:应用把读请求写进提交队列,内核处理完之后把结果写进完成队列,应用从完成队列里收割。整个过程可以做到批量提交、批量收割,系统调用次数大幅下降。在高 IOPS 场景下,这个差值是能测出来的。
但要不要上,我的看法是分场景。做存储引擎、高性能网络代理、需要极致降低 syscall 开销的场合,值得投入。做普通业务服务,epoll 加线程池的组合已经完全够用,io_uring带来的复杂度——内核版本依赖、调试工具不成熟、出错时排查困难——很可能得不偿失。
4.4 一个最小可用的事件循环骨架
抛开花哨的封装,一个能跑的事件循环核心就这么点东西:
class EventLoop { public: void loop() { std::vector<epoll_event> events(1024); while (!stopped_) { int n = epoll_wait(epfd_, events.data(), events.size(), timeout_ms_); if (n < 0) { if (errno == EINTR) continue; break; } for (int i = 0; i < n; ++i) { auto* ch = static_cast<Channel*>(events[i].data.ptr); ch->set_revents(events[i].events); ch->handle_event(); } do_pending_tasks(); } } private: int epfd_; bool stopped_ = false; int timeout_ms_ = 1000; };data.ptr这个字段很关键,它让你可以挂任意指针上去,从而把 fd 和对应的连接对象直接关联起来,避免每次事件到来还要在 map 里查一遍。另外do_pending_tasks()那一步是为了处理跨线程投递的任务——别的线程想往这个 loop 里塞事情,就放到一个队列里,由 loop 线程在每轮结束时统一执行,这是避免锁竞争的常用手法。
5. 长连接服务里最容易踩的四个坑
5.1 ET模式下没读到EAGAIN,连接假死
前面提过一次,这里再展开说排查链路,因为这个坑的表现太有迷惑性。
现象是:服务端日志显示连接正常,客户端也没报错,但某些请求就是没有响应,超时后才断开。抓包能看到客户端发了数据,服务端 TCP 层也 ACK 了,但应用层没处理。
第一反应通常是查业务逻辑,怀疑某个分支漏了返回。加日志之后发现 handler 根本没被调用。这时候要往上一层想:handler 为什么没被调用?因为 epoll 没通知。epoll 为什么不通知?因为缓冲区状态没有从空变成非空——上一次数据来的时候已经通知过了,但那次没读干净。
验证方法很简单:在 read 的循环里打印每次读到的字节数,看是否在EAGAIN之前就退出了。如果看到“读了一次就 break”,基本可以确认。修复就是前面那段循环读到EAGAIN的代码。
顺带说一个相关的坑:处理 EINTR。信号打断会让 read 返回 -1 且 errno 是 EINTR,这时候不能当成错误关连接,必须 continue 重试。少写这一行,偶发的连接异常断开就会找上门。
5.2 把阻塞操作塞进事件循环
这个坑的杀伤力和团队规模成正比。单个人写的时候心里有数,人一多,总有人在 handler 里加一句同步的远程调用或者一次大的文件读取,然后整个服务的 P99 延迟就莫名其妙地上去了。
排查这类问题有个很好用的手段:在事件循环里加一个耗时监控,每轮循环开始时记时间戳,结束时算差值,超过阈值就打日志并带上当前处理的 fd。跑一段时间就能抓到元凶。这个监控本身的成本极低,但定位问题的效率很高。
预防上,我倾向于在架构层面做隔离:Reactor 线程只做协议解析和分发,任何可能阻塞的事情统统扔进独立线程池,然后通过约定和代码评审守住这条线。光靠口头提醒没用,得让它变成代码结构上的硬约束。
5.3 惊群:多个进程抢同一个listen fd
多进程模型下,如果多个 worker 进程都通过 epoll 监听同一个 listen fd,新连接到来时所有进程都会被唤醒,但只有一个能 accept 成功,其余白跑一趟。连接建立频繁时,这种无效唤醒的开销很可观。
早年的解法是加锁或者用专门的 accept 进程。后来的内核在这个路径上做了优化,把唤醒范围收窄了,但也不是完全没成本。更干净的做法是用SO_REUSEPORT,让每个进程各自创建一个 listen socket,内核在 TCP 层做负载均衡,从根上避免争抢。
这个选项不是所有场景都合适——它要求同组 socket 的参数一致,热重启时的连接保持也需要额外设计。但如果你的服务是多进程 worker 模型且建立连接频繁,值得评估一下。
5.4 fd泄漏与close时机
长连接服务跑几天之后 fd 数量只涨不跌,最后撞到ulimit -n,新连接全部失败。这类问题的根源往往不是忘记 close,而是 close 的时机不对。
常见的一类是在多线程下,读线程和写线程同时持有同一个连接对象,谁先发现错误谁去 close,结果另一个线程还在用这个 fd,或者重复 close 了别的连接刚复用的 fd。这种 bug 极难复现,因为依赖时序。
我的做法是把连接的生命周期收敛到单一线程管理:所有 close 操作都由该连接所属的 Reactor 线程执行,其他线程只能提交“关闭请求”而不能直接 close。同时在 close 之前先shutdown(SHUT_WR)发 FIN,再进入一个 TIME_WAIT 等待期,让对端有机会把剩余数据发完。这套流程写下来麻烦,但能省掉大量半夜被叫起来的次数。
6. 选型:从连接数、消息尺寸、延迟目标倒推
6.1 连接数与活跃比的估算方法
选模型的第一个输入不是技术偏好,是业务数据。具体要算三个数:峰值连接数、活跃连接比例、单连接的平均消息频率。
峰值连接数决定了你能不能承受“每连接一线程”。粗略估算是:可用内存除以单线程成本。线程栈按 1MB 算(可以调小,但太小容易栈溢出),一万个线程就是 10GB 虚拟内存,加上内核结构,物理内存压力不小。虽然虚拟内存不等于实际占用,但上下文切换的成本是实打实的,几千个线程之后调度开销就不可忽视了。
活跃连接比例决定了多路复用的收益。如果一万个连接里每时每刻都有大半在传数据,那 epoll 的优势就没那么明显,因为就绪链表本身也很长;如果一万个连接里同时活跃的只有几十个,epoll 的优势是碾压性的。绝大多数长连接场景——推送、IM、设备接入——活跃比都很低,属于 epoll 的主场。
| 场景特征 | 连接规模 | 推荐模型 | 理由 |
|---|---|---|---|
| 内部服务互调,QPS 数千 | 几十到几百 | 阻塞 IO + 线程池 | 实现简单,问题好排查 |
| 移动端长连接接入 | 万级到十万级 | epoll ET + 多 Reactor | 活跃比低,连接数大 |
| 短连接 HTTP 网关 | 万级,请求密集 | epoll LT + 线程池 | 容错优先,逻辑复杂 |
| 大文件中转代理 | 百级到千级 | epoll + 零拷贝 | 拷贝成本占比高 |
| 极致低延迟撮合类 | 千级以内 | 忙轮询 + 绑核 | 宁可烧 CPU 也要降延迟 |
6.2 消息大小决定拷贝成本占比
单条消息几百字节和几兆字节,优化重心完全不同。
小消息场景下,系统调用次数和上下文切换是主要成本。一条消息一次 read 一次 write,加上 epoll 通知,几次 syscall 就出去了。这时候要做的是批量化和减少调用:能一次读多条就一次读多条,能合并写就合并写(比如攒一批响应再统一 flush),减少通知次数比减少拷贝次数更有价值。
大消息场景下,拷贝成本迅速上升。假设每秒中转 2GB 数据,中间多做一次拷贝就多 2GB 的内存带宽消耗,这个量级足以让 CPU 的缓存效率明显下降。这时候sendfile、splice、mmap这些手段才值得上,而且要评估是否真的需要用户态参与——如果只是转发,完全可以让内核直接搬。
有个中间地带容易被忽略:消息不大但频率极高。这种情况单次拷贝不贵,但次数多,syscall 开销会成为瓶颈。可以考虑用readv/writev做分散聚集 IO,一次调用处理多个缓冲区,减少调用次数。
6.3 延迟敏感与吞吐敏感的取舍
这两个目标经常是冲突的,得先想清楚自己要哪个。
吞吐优先的场合,关注的是单位时间处理量最大。做法是批量、缓冲、合并通知,让每次系统调用干更多的活。代价是延迟会上升,因为数据要攒一攒再处理。
延迟优先的场合,关注的是单条消息从到达到被处理的时间。做法是收到就处理,不攒批,甚至不惜用忙轮询替代事件通知——用一个线程死循环调epoll_wait超时设为 0,或者干脆在专门的核上自旋检查标志位。这种做法 CPU 占用接近 100%,但在交易类、实时控制类场景里是值得的。
大多数业务落在这两端之间,我的经验是:先按吞吐优先设计,把批量参数做成可配置,上线后拿真实流量测 P99,如果延迟不达标再往延迟侧调。反过来做——一开始就极致追延迟——很容易把架构做死,后续想提吞吐得推倒重来。
6.4 一个我常用的判断顺序
落到实操,我一般按这个顺序问自己几个问题:
先问连接数和活跃比,这一步基本能定下用不用多路复用。再问单连接的状态复杂度,如果每个连接都要维护复杂状态机(比如多阶段协议、长事务),那多线程模型的加锁成本会很高,倾向于单线程 Reactor 或者按连接分片。接着问消息尺寸分布,如果长尾里有大消息,就要考虑是不是单独拆一条路径处理,别让大消息堵住小消息的通道。最后问延迟目标,P99 要求进到毫秒以内的话,批处理参数就得往小调,甚至要评估内核参数(比如 backlog、接收缓冲区大小)的影响。
这套问题问下来,方案基本就收敛了。剩下的都是实现细节——用现成的网络库还是自己写循环、缓冲区怎么管理、超时怎么处理,这些在确定了模型之后都有成熟套路可循。
我自己在做的几个项目里,最后落地的大多是 epoll LT 加线程池的组合,配合合理的批量参数。ET 只在个别对 syscall 数量特别敏感的模块里用,而且每次都会反复检查读到EAGAIN的那段循环。不是说 ET 不好,而是它要求整个团队都对这套语义有共识,一旦有人在某个分支里偷懒少写一行,排查成本远超省下来的那点开销。这个取舍,在团队规模和流动率面前,往往比纸面上的性能数字更重要。