资讯动态

一次read到epoll:网络IO模型与Reactor选型实践

发布时间:2026/10/26 2:52:21 来源:尧图企业网站定制
“网络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应用轮询进程自己拷贝依赖轮询策略低但浪费 CPUIO 多路复用内核代等事件通知进程自己拷贝万级以上中高信号驱动 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::vectorepoll_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_castChannel*(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 不好而是它要求整个团队都对这套语义有共识一旦有人在某个分支里偷懒少写一行排查成本远超省下来的那点开销。这个取舍在团队规模和流动率面前往往比纸面上的性能数字更重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑