
1. 把一次网络读写拆到内核里看线上有个服务QPS 压到三千左右就上不去了机器 CPU 看着不高但top里sy系统态占比一直压在 40% 以上load也不高连接数上不去也不掉。一开始怀疑是数据库换了几轮慢查询优化没效果后来又怀疑带宽不对抓包一看每个请求的往返都很正常。最后把线程栈 dump 下来发现几百个工作线程全卡在同一个recv调用上一半在等数据、一半在等锁。问题不在业务代码在于整个服务的网络 IO 模型选错了——线程池 阻塞 IO 硬扛长连接连接一多线程全变摆设。这就是我想聊的话题。网络 IO 模型说白了就一件事当程序要读网络上的数据而数据还没到的时候你的程序该干什么。是原地卡住等还是先回去干别的、过会儿再来问一遍还是干脆交给一个“哨兵”统一盯着谁的数据到了再通知谁。这五种做法就是常说的阻塞 IO、非阻塞 IO、IO 多路复用、信号驱动 IO、异步 IO。它们不是教科书里的名词游戏每一种背后都对应着一笔真实的成本账线程数、内存占用、上下文切换次数、以及你愿不愿意接受一定的编程复杂度。这篇文章写给三类人。第一类是写业务代码的平时用框架用得很顺但遇到连接数暴涨、超时莫名其妙、CPU 空转这些问题时不知道该往哪个方向查第二类是准备自己写网络服务的选型的时候需要知道每种模型的天花板在哪第三类是要应对面试和架构评审的光背“五种模型”的定义没用得能说清楚为什么主流选择是 epoll 那一套、为什么异步 IO 喊了这么多年落地却不算多。下面的内容我会从内核里的一次读操作开始拆一路讲到代码怎么写、线上怎么排查。所有参数和步骤我都会给出为什么这么定而不是直接甩结论。1.1 从网卡到用户缓冲区一次读事件的完整路径很多人对网络 IO 的理解停在“read 一个 socket 拿到数据”中间发生了什么是一片模糊。这片模糊恰恰是后面所有模型差异的来源所以必须先讲清楚。假设客户端发来一个数据包。网卡收到电信号通过 DMA 把数据写进内核里预先分配好的环形缓冲区ring buffer硬件随即发一个中断。内核的中断处理程序不会当场把数据一路解析上去那样太慢它只做最轻量的事把数据包挂到软中断待处理队列然后尽快返回。真正的协议栈处理在软中断里完成逐层剥掉以太网头、IP 头、TCP 头根据四元组找到对应的 socket把载荷数据追加到该 socket 的接收缓冲区里。如果这个 socket 上挂着一个正在睡眠等待的进程内核唤醒它把它从等待队列里挪回可运行队列。到这一步为止数据已经在内核缓冲区里躺着了但你的程序还没看见它。程序要真正拿到这段字节还需要一次数据拷贝从内核空间的 socket 接收缓冲区复制到用户空间你声明的那块内存里。这次拷贝是 CPU 参与的绕不开除非用零拷贝那一套但那是另一个话题普通 socket 读写还是要拷。所以一次完整的网络读可以切成两个阶段阶段一等待数据就绪数据从网卡到内核缓冲区期间你的程序可能什么都做不了。阶段二数据拷贝从内核缓冲区到用户缓冲区期间 CPU 在干活但你的程序依然处于系统调用之中。记住这两个阶段后面五种模型的区别全都在于“这两个阶段里进程到底在不在等、等在哪”。我在实际调试中经常用一个很土的办法验证这个路径在服务端recv之前加日志然后用ss -m看对应连接的接收队列。如果 Recv-Q 有值而你的程序还没读到说明数据已经在内核里了程序卡在阶段二的入口如果 Recv-Q 是 0 而程序还在阻塞那就是阶段一没等到东西。这个判断方法很粗糙但定位方向极其有效比盲目加日志快得多。顺带说一个容易忽略的点TCP 的接收窗口和内核接收缓冲区是绑定的。如果你的程序读得慢内核缓冲区堆满接收窗口就会缩小到零发送方被流控卡住。这时候表现出的“网络慢”其实是应用层读得太慢不是带宽问题。我在一个数据同步的场景里就遇到过对方一直抱怨传输慢最后发现是消费端每次只读 1KB内核缓冲区堆了 200KB 读不出去。1.2 两个阶段的划分决定了所有模型的差异把两个阶段摊开五种模型的差别就一目了然了我先用一段话把它们串起来后面再逐个拆。阻塞 IO两个阶段都在等。发起调用后进程被挂起数据到了、拷贝完了才返回。等待期间这个线程什么都干不了。非阻塞 IO第一个阶段不等。调用立刻返回没数据就告诉你没数据errno 为 EAGAIN程序自己决定过多久再来问一次。第二个阶段还是同步的数据拷贝时进程仍然在系统调用里。IO 多路复用第一个阶段交给一个专门的系统调用去等。你一次性把一批 fd 注册进去这个调用会阻塞在那里直到其中任意一个可读可写。也就是说一个线程能盯住成千上万个连接。但注意它只是帮你“等到”拷贝还是你自己调recv来做所以本质上仍然是同步 IO。信号驱动 IO第一个阶段连等都不用等。注册一个信号处理函数数据就绪时内核发 SIGIO 给你你在信号处理里再调recv。理论上很优雅实际用得极少后面会说原因。异步 IO两个阶段都不需要你管。你发起一个读请求附带一块缓冲区和回调或者一个完成事件然后立刻返回。内核把数据等到、并且拷贝进你给的缓冲区之后才通知你“完事了”。这是真正的异步和前面四种有本质区别。这里有个特别常见的误解就是把 IO 多路复用当成异步 IO。很多文章写着“NIO 就是非阻塞异步”这个说法很不严谨。Java 的 NIO 名字叫 Non-blocking I/O但它用的是多路复用底层是 epollSelector.select()返回可读之后你还是得自己调channel.read()把数据从内核拷到 ByteBuffer 里这一步是同步的。真正对标异步 IO 的是 JDK 7 引入的 NIO.2AIO或者 Linux 上的 io_uring。判断一个 IO 是同步还是异步标准只有一条数据从内核缓冲区到用户缓冲区这一步是谁在做进程有没有被阻塞。只要这一步是进程自己调用的那就是同步不管前面等得多花哨。这条标准我在评审方案时反复用能挡掉一大堆名词包装。理解了这层再看一个具体的现象为什么有些服务线程数不多但能扛上万连接因为它把阶段一交给了 epoll一个线程盯几千个 fd只有真正有数据的连接才会被分配线程去处理阶段二。这就是事件驱动的基本盘。反过来如果服务是一连接一线程的阻塞模型一万连接就是一万个线程光线程栈默认 8MB实际虚拟内存物理页按需分配和内核调度开销就能把机器拖垮。2. 五种网络 IO 模型的逐个拆解与适用边界这一段是全文的主体我尽量把每种模型“为什么是这样设计的”“在什么情况下会翻车”讲透。不讲空话每个结论都配一个能验证它的现象。2.1 阻塞 IO最简单也最容易被误用的模型阻塞 IO 的调用链非常直白accept没有新连接就卡住recv没有数据就卡住send发送缓冲区满了也卡住。进程被放进 socket 的等待队列状态变成可中断睡眠CPU 完全不占用。数据到了内核唤醒它它继续往下走。它的优点就一个字简单。代码是线性的读完了就处理处理完了就写回去心智负担为零。所以它在两类场景里活得很好。一类是连接数很少的内部服务比如一个管理后台几十个并发用阻塞 IO 加线程池完全够用没必要上事件驱动。另一类是配合“一连接一线程”的模型做短连接服务请求处理很快连接生命周期很短线程复用池子能兜住。但它有两个硬伤第一个是线程和连接绑定。假设每个连接平均每秒只有 10 个请求单次处理耗时 5ms那么这个连接 99.5% 的时间是空闲的可它依然占着一个线程。线程本身的开销分几块内核里的 task_struct、用户态的栈、调度器维护的运行队列节点还有上下文切换的成本。线程数上千之后调度器的红黑树操作和缓存失效会明显拖慢整体sy占比会明显上升。第二个硬伤是阻塞调用会传染。你在一个请求里串行调了三个下游服务每个都可能卡住最坏情况下这个线程被占用的时间等于三个超时之和。如果下游抖动线程池很快被耗干新请求进来排队超时扩散最终整个服务雪崩。我做压测时见过一个典型现象并发从 500 加到 800TP99 从 80ms 跳到 3 秒但 CPU 使用率反而下降——这就是线程池打满、大量线程在等下游的典型特征。要缓解就得给每个阻塞调用设超时并且用SO_RCVTIMEO或应用层超时把等待时间卡住。但超时只是止血不解决根本问题你依然为每个连接付出了线程成本。一个经验判断如果你的服务连接数长期在几百以内、单次处理耗时可控、没有大量长连接比如推送、IM、订阅阻塞 IO 加线程池是最省心的选择。别为了“先进”去上事件驱动事件驱动的代码复杂度和调试难度是另一个量级。还有一个坑要说清楚阻塞 IO 在accept上也有讲究。监听 socket 默认是阻塞的如果accept返回后你处理连接花了很久期间新连接会堆在 backlog 队列里。backlog 大小由listen(fd, backlog)指定实际生效值还受net.core.somaxconn限制。这两个值不一致的时候以小的为准。很多服务连接建立慢、握手超时就是 backlog 给太小了。生产环境我会把somaxconn调到 4096 以上listen传 1024 起步具体看连接建立速率。2.2 非阻塞 IO轮询的代价与适用场景非阻塞 IO 就是把 fd 设成O_NONBLOCK然后调用立刻返回。没数据时返回 -1 并置 errno 为 EAGAIN 或 EWOULDBLOCK在 Linux 上这两个值相同。程序可以立刻去干别的事过会儿再来问。听起来很美好但如果你真的写一个while(true) { n recv(fd, buf, len, 0); if (n 0) break; }的循环你会发现 CPU 直接 100%而且大部分时间在做无意义的系统调用。这就是忙轮询是最糟糕的用法。系统调用本身有成本要从用户态切到内核态保存寄存器检查权限处理完再切回来。一次可能几百纳秒但一秒做几十万次就是实打实的开销且这些开销全部记账在sy上。所以非阻塞 IO 单独用几乎没有意义它必须和别的东西配合。最常见的配合方式有三种第一种是和多路复用配合这也是绝大多数场景的做法。先用 epoll 等到某个 fd 就绪再对这个 fd 用非阻塞方式读写。为什么就绪了还要非阻塞因为 epoll 的通知是“曾经可读”等你真正去读的时候可能已经被别的线程读走了或者数据只到了一部分。非阻塞在这里是兜底保证你永远不会因为一次读写把线程卡死。第二种是带退避的轮询。每次 EAGAIN 之后 sleep 一小段时间再来。这种方式在极低延迟要求的场景下偶尔用比如某些交易系统的行情拉取但绝大多数业务没必要。退避时间的选择很讲究太短没意义太长丢延迟。第三种是非阻塞加自旋加协程调度。协程库在遇到 EAGAIN 时会把当前协程挂起把 CPU 让给别的协程等 fd 就绪了再唤醒。对上层来说代码是同步写法实际执行是事件驱动的。这也是现在很多语言 runtime 的做法。这里必须点出一个常见误区把 socket 设成非阻塞并不等于性能变好。非阻塞只是改变了“等待”的方式并没有减少数据拷贝也没有减少系统调用次数。如果你的程序逻辑还是“读一次处理一次”非阻塞带来的只是更复杂的错误处理代码。真正带来吞吐提升的是多路复用带来的“一个线程管多连接”。我在实际项目里给 fd 设非阻塞的时机很固定accept返回的新连接立刻设connect发起前设非阻塞 connect 可以避免三次握手期间卡住然后用 epoll 监听可写事件判断连接是否建立成功监听 fd 本身视模型而定。这个顺序不能乱尤其是connect如果先连再设非阻塞那在连接超时的情况下你还是会被卡住。2.3 IO 多路复用select、poll、epoll 的三代演进这一节是重点因为它是目前几乎所有高性能网络服务的底座。select是最早的方案。它用一个位图fd_set表示要监听哪些 fd调用时把这个位图从用户态拷到内核态内核遍历一遍看哪些 fd 有事件把有事件的 fd 在位图里标记出来再拷回用户态。程序拿到返回值后还得自己再遍历一遍位图找出哪些位被置位了。它有三个致命问题。第一fd 数量上限是FD_SETSIZE通常是 1024改这个值要重新编译内核不现实。第二每次调用都要把整个位图拷进拷出fd 越多拷贝越大这是 O(n) 的内存拷贝。第三内核要线性扫描所有 fd用户态也要线性扫描两次 O(n)。所以 select 在 fd 上千之后性能急剧下滑而且每次调用完都得重置 fd_set很容易写错。poll换了个数据结构用pollfd数组每个元素包含 fd、关心的事件、返回的事件。它解决了 1024 上限的问题上限变成内存和ulimit -n决定的也解决了每次要重置的问题events 和 revents 分离。但本质没变还是每次全量拷贝还是内核线性扫描还是用户态线性扫描。fd 到一万这个量级poll 的拷贝和扫描开销就很可观了。epoll是 Linux 2.6 引入的思路完全不一样把“注册”和“等待”拆成两个操作。epoll_create在内核里创建一个 eventpoll 对象里面维护两块核心结构一棵红黑树用来存所有注册进来的 fd 及其关注的事件一个就绪链表用来存已经有事件发生的 fd。红黑树保证增删改是 O(log n)就绪链表保证取事件是 O(1)。epoll_ctl负责往红黑树里加、删、改。关键是注册的时候内核会给这个 fd 挂一个回调当该 fd 对应的数据到达、触发软中断处理时内核直接通过这个回调把 fd 塞进就绪链表。也就是说事件是被主动推送过来的不需要扫描。epoll_wait只做一件事看就绪链表空不空。空就睡非空就把链表里的节点拷到一个用户传入的数组里返回同时把这个节点从链表摘掉水平触发模式下会重新挂回去后面细说。返回值是就绪的 fd 个数用户只需要处理这 n 个不需要遍历全部。这三个改进叠加起来效果是注册一次长期有效事件驱动无需扫描只返回就绪的处理量与总连接数无关。这就是所谓的 O(1) 事件通知严格说是 O(就绪数)不是 O(总连接数)。但 epoll 不是没有成本有几个点必须知道第一epoll_ctl的调用是有开销的比 select 的一次性注册摊薄下来要贵。所以如果你的场景是“一批 fd 用一次就扔”比如处理一批短连接select 或 poll 反而可能更划算。epoll 的优势在长连接、高频复用。第二epoll 的红黑树节点和就绪链表节点都是内核内存连接数巨大时这部分内存不可忽视。粗略估算每个被监听的 fd 在内核里额外占几百字节不同内核版本有差异量级在数百字节十万连接就是几十 MB 的额外开销。做容量规划时要把这块算进去。第三epoll_wait返回的数组大小要合理。传 1 意味着每次只能取一个就绪事件虽然不会丢但会多调用很多次传太大又浪费栈或堆空间。我一般按min(预期并发就绪数, 1024)来定实测下来 512 到 1024 是个舒适区。第四跨进程或者多线程同时epoll_wait同一个 epoll 实例会涉及唤醒竞争也就是常说的惊群。Linux 4.5 引入的EPOLLEXCLUSIVE标志可以缓解同一时刻只唤醒一个等待者另一个思路是用SO_REUSEPORT让每个线程各自持有独立的监听 fd 和独立的 epoll 实例。这两套方案我在不同项目里都用过后者在多核下扩展性更好但连接分配的均衡性依赖内核哈希不能保证完全均匀。2.4 信号驱动与异步IO一条容易被混淆的边界信号驱动 IO的流程是给 socket 设置属主并注册 SIGIO 处理函数开启信号驱动然后进程就可以去干别的。数据到达时内核发 SIGIO进程在信号处理函数里调recv读数据。它的问题很实际。首先信号处理函数里能做的事情非常受限很多库函数不是异步信号安全的一不小心就死锁。其次信号是进程级的多个 fd 共用一个信号处理函数里很难判断到底是哪个 fd 就绪还得额外维护映射。第三信号可能丢失或合并高频事件下不可靠。所以这套东西除了少数嵌入式场景和历史遗留代码基本没人用了。你在网上看到它的介绍知道原理就够了不用花时间实践。异步 IO才是真正意义上的“不等”。你提交一个读请求附带缓冲区和完成通知方式立刻返回。内核负责等数据、负责拷贝全干完了再告诉你。整个过程中你的进程一次都没有被阻塞在 IO 上。Linux 上的实现经历了两个阶段。早期是 POSIX AIOglibc 的用户态模拟和内核 AIOio_submit/io_getevents前者本质是用线程池模拟后者限制很多只支持 O_DIRECT、对普通文件的支持不好、网络 IO 支持有限一直没能普及。近些年 io_uring 出来了用一对共享内存的环形队列提交队列和完成队列来做用户态和内核态的通信既支持文件 IO 也支持网络 IO还能批量提交通过IORING_SETUP_SQPOLL让内核线程轮询提交队列进一步减少系统调用。它的性能数据很漂亮但生态还在成熟过程中各种语言的绑定质量参差不齐线上大规模用的案例还不算多。至于 Java 的 AIONIO.2在 Linux 上底层用的是 epoll 模拟JDK 内部有专门的实现并不是真正的内核异步 IO所以它的“异步”更多是 API 层面的封装。这也是为什么很多 Java 服务依然选 Netty 而不是 AIO 的原因之一。选型上的建议除非你有非常明确的理由比如已经在用 io_uring 做存储栈、或者对系统调用次数极度敏感网络服务的默认答案就是 epoll 这一套多路复用加事件循环。异步 IO 的收益在当前阶段还不足以抵消它的复杂度和生态风险。下面这张表是我自己在做方案对比时用的把关键维度摆在一起看比读十篇介绍文章都直观。模型阶段一等数据阶段二拷贝单线程可管理连接数编程复杂度典型适用场景阻塞 IO进程阻塞进程阻塞1与线程绑定低连接少、内部服务、短连接非阻塞 IO轮询不阻塞进程阻塞取决于轮询策略中一般需配合其他模型使用IO 多路复用由内核代为等待进程阻塞数千至数十万中高长连接网关、推送、代理信号驱动 IO信号通知不阻塞进程阻塞理论上多实际受限高极少使用异步 IO内核完成内核完成高高高吞吐存储、特定场景3. 把 epoll 真正跑起来从调用到事件循环理论讲完落到代码。这一段我会把 epoll 的关键调用拆开说明每个参数为什么这么填然后给出一个能直接编译运行的最小事件循环再对照 Java NIO 的写法方便不同技术栈的读者对照理解。3.1 epoll 三个调用背后的数据结构与参数含义epoll_create1(EPOLL_CLOEXEC)创建一个 epoll 实例返回一个 fd。参数用EPOLL_CLOEXEC而不是老的epoll_create(size)原因是前者会在exec新程序时自动关闭这个 fd避免子进程继承一堆用不上的句柄。老接口的size参数在内核 2.6.8 之后已经没意义了只是兼容保留不要以为它真的能限制什么。epoll_ctl(epfd, op, fd, event)是增删改。op有 ADD、DEL、MOD 三种。event里两个字段最要紧events是关注的事件掩码data是一个联合体可以存 fd 也可以存指针。我强烈建议存指针而不是 fd因为在高并发场景下你需要通过这个字段快速定位到连接对象包含缓冲区、状态机、超时时间等存 fd 的话还要再查一次哈希表白白多一次间接寻址。存指针的做法是把连接对象的地址塞进data.ptr事件回来时直接强转。events里常填的EPOLLIN可读、EPOLLOUT可写、EPOLLERR错误、EPOLLHUP挂断、EPOLLRDHUP对端关闭写端这个很有用能提前感知半关闭。注意EPOLLERR和EPOLLHUP是永远会被上报的不需要你显式注册但你应该在事件处理里优先检查它们。epoll_wait(epfd, events, maxevents, timeout)是等待。timeout单位毫秒-1 表示永久阻塞0 表示立即返回纯轮询正数表示最多等这么久。这里有个实操细节很多事件循环会把 timeout 设成一个较小的值比如 100ms 或者按最近的定时器到期时间算目的是定期醒来处理定时任务连接超时、心跳检测。如果设 -1那定时任务就永远没机会执行。我一般的做法是算出“距离下一个定时器还有多少毫秒”作为 timeout 传进去这样既不空转也不误事。调用返回值 n 表示就绪的事件数接下来遍历events[0..n-1]逐个处理。处理时有个原则单个事件的处理时间要尽可能短。如果某个连接的业务处理很重不要在这个循环里做完应该丢给业务线程池。否则一个慢请求会拖住整个事件循环所有连接都受影响。这是事件驱动模型最容易被违反的原则也是很多自研框架的性能瓶颈所在。3.2 水平触发与边缘触发丢数据的坑都在这儿这是 epoll 里最容易出问题的地方我必须单独讲。水平触发LT默认的语义是只要 fd 的接收缓冲区里还有数据没读完每次epoll_wait都会告诉你这个 fd 可读。也就是说你读了一半就走了下次还会被通知不会丢。这个模式写起来安全因为你哪怕只读了一个字节剩下的下次还能拿到。代价是如果一直不读完就会一直被通知形成忙循环所以还是要读完。边缘触发ET的语义是只在状态发生变化时通知一次。从“无数据”变成“有数据”时通知之后无论缓冲区里还剩多少都不再通知直到下一次新的数据到达。这就要求你收到通知后必须循环读取直到返回 EAGAIN把缓冲区彻底读空。同时 fd 必须是非阻塞的否则最后一次读会因为没数据而阻塞住把整个事件循环卡死。我在一个项目里就踩过这个坑代码改成了 ET 模式但读取逻辑还是只读一次。压测时表现为极低概率的数据丢失大概万分之一的请求少字段。排查了两天才定位到原因是某个请求的数据包被拆成了两段第一段到达触发了一次通知程序读完第一段就去处理了第二段到达时因为缓冲区非空状态没变化不再触发通知于是第二段永远留在内核缓冲区里直到下一个包到达才被顺带读出来——所以表现为“偶发丢数据”而不是“必丢”。这种 bug 极难复现因为依赖网络分包。所以 ET 模式的正确写法固定是三段式for (;;) { n read(fd, buf, len); if (n 0) { 处理 } else if (n 0) { 对端关闭清理 } else { if (errno EAGAIN || errno EWOULDBLOCK) break; else 出错处理 } }。这个循环不能省。选用建议没有明确性能诉求就用 LT。ET 的优势在于减少epoll_wait的返回次数在高并发下能省一些系统调用但差距没有想象中大通常 10% 以内而它带来的正确性风险是实打实的。只有在连接数极高、且你对自己的读取逻辑非常有信心时才上 ET。另外要注意监听 fdlisten返回的那个在 ET 模式下也要循环accept直到 EAGAIN否则并发连接建立时会漏掉一部分连接。3.3 一份可复用的最小事件循环先看 C 版本这段代码我用了很多次去掉业务逻辑就是一个干净的骨架。#include sys/epoll.h #include sys/socket.h #include fcntl.h #include unistd.h #include errno.h #include stdio.h #define MAX_EVENTS 1024 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main(void) { int listenfd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); /* bind listen 略backlog 建议 1024 起 */ set_nonblocking(listenfd); int epfd epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; /* 监听 fd 用水平触发稳 */ ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); for (;;) { /* timeout 按最近定时器到期时间算这里固定 100ms 演示 */ int n epoll_wait(epfd, events, MAX_EVENTS, 100); if (n 0) { if (errno EINTR) continue; /* 被信号打断重来 */ perror(epoll_wait); break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (events[i].events (EPOLLERR | EPOLLHUP)) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); continue; } if (fd listenfd) { for (;;) { /* 循环 accept 直到 EAGAIN */ int conn accept(listenfd, NULL, NULL); if (conn 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; break; } set_nonblocking(conn); ev.events EPOLLIN | EPOLLRDHUP; ev.data.fd conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, ev); } } else { /* 业务读取这里若用 ET 必须循环读到 EAGAIN */ char buf[4096]; ssize_t r read(fd, buf, sizeof(buf)); if (r 0) { /* 处理数据 */ } else if (r 0) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } } } } } return 0; }这段代码里有几个地方值得单独说。EINTR的处理不能省因为进程可能被信号打断epoll_wait会返回 -1如果不判断就直接退出服务会因为一个无关信号挂掉。EPOLLRDHUP加进去是为了尽早感知对端关闭否则你可能要等一次写失败才知道对面走了。epoll_ctl DEL在 close 之前还是之后其实都可以因为 close 会自动把 fd 从 epoll 里移除但显式 DEL 更清晰也避免 fd 被复用后出现意外。再看 Java NIO 的等价写法思路完全一样只是把系统调用包装成了对象。Selector selector Selector.open(); ServerSocketChannel server ServerSocketChannel.open(); server.bind(new InetSocketAddress(8080), 1024); server.configureBlocking(false); server.register(selector, SelectionKey.OP_ACCEPT); while (true) { // 1000ms 是为了兜底定时任务 int n selector.select(1000); if (n 0) continue; IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); // 必须移除否则下次重复处理 if (!key.isValid()) continue; if (key.isAcceptable()) { SocketChannel ch server.accept(); ch.configureBlocking(false); ch.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel ch (SocketChannel) key.channel(); ByteBuffer buf ByteBuffer.allocate(4096); int r ch.read(buf); if (r -1) { key.cancel(); ch.close(); } else if (r 0) { buf.flip(); /* 处理 */ } } } }it.remove()这一行是新手最容易漏的。selectedKeys()返回的集合是复用的你不移除下一轮还会处理同一个 key业务逻辑会重复执行。这个 bug 的表现是“请求被处理了两次”在幂等接口上不明显在扣款类操作上就是事故。Java 侧还有两个细节configureBlocking(false)必须在register之前调用否则会抛异常allocate每个连接都新建一个 ByteBuffer 会造成大量小对象生产环境要用池化或者直接缓冲区。这些在 Netty 里都被封装好了但知道底层在干什么出问题时才能定位。4. 从模型到架构Reactor 与线程模型的取舍搞清楚了单个连接怎么处理下一个问题是怎么把成千上万个连接和有限的 CPU 核数组织起来。这就是 Reactor 模式要回答的事。4.1 三种 Reactor 结构与它们的成本账单 Reactor 单线程是最朴素的形态一个线程跑事件循环accept、读、业务处理、写全在里面。它的好处是无锁、无竞争、逻辑简单坏处是任何一步慢都会拖住全部。适合业务极轻的场景比如某些协议转换、简单的回显服务。用它跑有数据库访问的业务基本上是自找麻烦。单 Reactor 多线程把业务处理挪出去事件循环只负责 IO 读写和编解码读完的完整报文丢给业务线程池。这样 IO 和计算解耦一个慢查询不会阻塞其他连接的数据接收。代价是线程间传递数据要做队列队列满了要有背压策略而且写操作要从业务线程回到 IO 线程涉及跨线程提交任务。这里的坑是写并发多个业务线程同时想往同一个连接写数据直接写会导致字节流交错必须加锁或者串行化到 IO 线程。主从 Reactor更进一步把 accept 也分离出来主 Reactor 只负责监听和建立连接建立后把新连接分给某个从 Reactor从 Reactor 各自跑自己的事件循环处理读写。这样 accept 的压力不会影响已有连接而且从 Reactor 的数量可以按 CPU 核数扩展通常设成核数或核数的 1 到 2 倍。这是目前主流框架的默认模型。分连接的方式通常有两种轮询和按连接 id 取模。轮询实现简单但需要原子计数高并发下有竞争取模无竞争但依赖连接 id 分布均匀。我在实际项目里更倾向轮询因为连接 id 不一定连续取模可能倾斜。这里有个成本账要算清楚从 Reactor 线程数不是越多越好。每个线程有自己的 epoll 实例、自己的事件循环、自己的队列。线程多了锁竞争虽然少了但上下文切换和缓存局部性会变差。我实测下来从 Reactor 线程数设成 CPU 核数时吞吐最好超过 1.5 倍核数基本没有收益反而 P99 会抖动。这个结论在不同业务下会有差异但量级上差不多。4.2 连接数、线程数与内存容量估算的实际算法做架构评审时经常被问“这套模型能扛多少连接”很多人答不上来。其实可以算我心里有个固定的算法。先算文件描述符。每个连接占一个 fd服务端自己还要占一些监听、日志、管道、epoll 实例、定时器留 200 个余量比较稳。所以ulimit -n至少要等于目标连接数加上 200。这里要注意ulimit -n是每进程的限制还有fs.file-max是系统级限制两个都要调。改ulimit可以在启动脚本里ulimit -n 1048576也可以通过 systemd 的LimitNOFILE配。改完记得验证用cat /proc/pid/limits看实际生效值。再算内核内存。每个 TCP 连接在内核里有 socket 结构、发送缓冲区、接收缓冲区。缓冲区大小受net.ipv4.tcp_rmem和tcp_wmem控制是三元组最小、默认、最大。默认值通常接收 128KB 左右、发送 16KB 左右但这是上限实际按需增长。保守估算每连接 200KB 内核内存十万连接就是 20GB。这个数字经常被忽略结果连接一多就 OOM。生产上我会把默认值调小一点比如接收 64KB让缓冲区按需增长避免空闲连接占着大块内存。再算应用层内存。每个连接对象、读缓冲区、写缓冲区、状态机、定时器节点加起来通常几 KB 到几十 KB。这里的关键是读写缓冲区别开太大。有的框架默认给每个连接分配 64KB 读缓冲十万连接就是 6.4GB。实际做法是用可变缓冲区初始 1KB 到 4KB不够再扩并且设上限。最后算线程栈。Java 默认每线程 1MB可用-Xss调小到 256KBC 的 pthread 默认 8MB虚拟内存实际按需分配。线程数按 Reactor 核数加业务线程池来估通常几十到几百这块开销不大。真正占内存的是连接对象不是线程。把这些加起来就是一台机器的内存需求。我给个参考十万长连接4KB 应用层缓冲加 64KB 内核缓冲加对象开销大约 8 到 12GB需要配 16GB 内存的机器并且把 fd 限制调到 12 万以上。这个估算在几个项目里验证过误差在 20% 以内。4.3 背压与应用层协议的配合模型选对了还有一个隐形的坑上游读得飞快下游写得慢。事件循环不停地把数据读进来放进队列业务线程处理不过来队列无限增长最后内存爆掉。这就是没有背压。背压的手段分几层。最直接的是停止读当某个连接的待处理队列超过阈值就从 epoll 里把这个 fd 的 EPOLLIN 事件摘掉用epoll_ctl MOD改成只监听 EPOLLOUT或者干脆 DEL 掉这样内核缓冲区堆满后TCP 接收窗口缩小发送方自然减速。这一招最干净因为它把压力传导回了源头符合 TCP 的设计初衷。第二层是限制并发处理数。业务线程池用有界队列队列满了就拒绝或者阻塞提交。这里的取舍是阻塞提交会让 IO 线程被卡住不可取拒绝则要有一套降级逻辑比如返回特定错误码让客户端重试。我一般用有界队列加拒绝队列容量按“能承受的最大积压时间”来定比如目标 P99 是 100ms处理速度是每秒 1 万那队列 1000 左右比较合适。第三层是协议层限流。比如在报文头里带一个序号或者窗口大小让客户端自己控制发送速率。这在自研协议里很常见用标准协议的话就得靠 TCP 自身的流控。我见过最典型的背压缺失案例一个推送服务客户端网络慢服务端不停往里写写缓冲区从几 KB 涨到几 MB几千个慢客户端直接把内存吃光。修复方案是给每个连接设写缓冲上限超过就断开或者丢弃旧消息。断开这个动作看着粗暴但对推送场景是合理的因为积压的消息本来就已经过期了。另外应用层协议的选择也会反向影响模型。比如 HTTP/1.1 的 keep-alive 让连接变长就必须用多路复用模型HTTP/2 的多路复用是在一条连接上跑多个流需要应用层自己做流控WebSocket 是长连接双向通信对事件循环的写并发要求更高。这些在选择协议的时候就得想清楚别等上线了才发现模型不匹配。5. 线上排查实录那些模型理论解释不了的现象理论和代码讲完最后落到排查。这一节全是我在实际环境里遇到过的现象和对应的定位路径很多问题光看模型定义是看不出来的。5.1 高频问题速查表先给一张表按现象查起。现象优先怀疑快速验证方法常见处理CPU 的 sy 占比高但吞吐上不去事件循环空转、系统调用过多strace -c -p pid看调用次数检查 timeout 是否设成 0 或过小检查是否有忙轮询连接数上不去accept报错fd 限制、backlog 太小cat /proc/pid/limits、ss -lnt看 Send-Q调ulimit -n、调大somaxconn和 listen backlog大量 CLOSE_WAIT 堆积应用层没正确关闭连接ss -tan state close-wait | wc -l检查读到 EOF 后是否 close检查异常路径是否漏关偶发丢数据低频难复现ET 模式没循环读完抓包对比应用层收到的字节数改为循环读到 EAGAIN或退回 LT某几个连接延迟极高单个事件处理太慢拖住循环在事件循环里埋点统计单次处理耗时把重业务移出 IO 线程内存持续增长不释放写缓冲无上限、连接对象泄漏看堆内连接对象数量和缓冲大小分布设缓冲上限加空闲连接回收多线程 accept 时部分连接不处理epoll 惊群观察是否所有线程都被唤醒用 EPOLLEXCLUSIVE 或 SO_REUSEPORT这张表里的每一条我都在真实环境验证过。尤其是 ET 那条抓包对比是最直接的在服务端用 tcpdump 抓一段数一下某个请求的 payload 总字节数再和应用层日志里收到的字节数对比。如果抓包比日志多基本可以确定是读循环没读干净。5.2 几个必须提前规避的细节第一个epoll_wait返回后别在遍历中直接 DEL 当前 fd 之外的 fd。这个坑很隐蔽。你手里拿着一个 events 数组遍历到第 3 个元素时业务逻辑顺手关掉了第 5 个元素对应的连接并 DEL 了。等遍历到第 5 个时那个 fd 可能已经被新连接复用了你去处理它操作的是完全不相干的连接。正确做法是先把所有事件收集起来处理完再统一做增删或者用引用计数延迟释放。我在一个网关项目里因为这个 bug 排查了整整一周现象是“极低概率的串包”A 用户收到了 B 用户的响应。第二个信号要处理好。事件循环里被 SIGPIPE 打断会导致进程直接退出默认行为。如果客户端在你写数据时突然断开你调用 write 就会收到 SIGPIPE。要么全局忽略它signal(SIGPIPE, SIG_IGN)要么用send的MSG_NOSIGNAL标志。这个必须加不加就是一颗定时炸弹平时不出问题用户批量断线重启时直接崩。第三个定时器精度别依赖epoll_wait的 timeout。那个 timeout 只是“最多等这么久”实际可能提前返回有事件也可能稍微延后调度延迟。如果你靠它来做精确的超时控制误差会累积。正确做法是自己维护一个时间轮或者最小堆每次算好最近到期时间作为 timeout同时每次醒来都检查一遍当前时间把到期的连接处理掉。第四个TIME_WAIT 是正常的别急着调参数。服务端主动关闭连接会进入 TIME_WAIT占着四元组 60 秒2 倍 MSL。高并发短连接场景下 TIME_WAIT 数量会很高很多人第一反应是开tcp_tw_reuse或者调小fin_timeout。但在 NAT 环境下乱开 reuse 有风险而且 TIME_WAIT 的存在是为了保证最后一个 ACK 能重传强行消灭会引入数据错乱。我的做法是优先用长连接和连接池把短连接降下来实在降不下来再看有没有足够的端口范围最后才考虑内核参数。第五个不要在事件循环里做 DNS 解析、文件读写、日志刷盘这些阻塞操作。任何一个都可能耗时几十毫秒而这几十毫秒里所有连接都停摆。DNS 要单独起解析线程或用异步解析库日志用异步 appender文件操作丢线程池。这些看起来是常识但我在不止一个服务里见过事件循环里直接logger.info同步刷盘的。第六个连接关闭的顺序。正确的关闭是先shutdown(SHUT_WR)发 FIN 告诉对端我不发了然后继续读直到读到 EOF对端也关了再 close。直接 close 可能会导致发送缓冲区里没发完的数据被丢弃除非设了 SO_LINGER。在推送类服务里这个细节很关键直接 close 会导致最后一条消息丢失。写到这里我个人的体会其实很简单网络 IO 模型的选择本质上是在“线程成本”和“编程复杂度”之间做交换。阻塞 IO 把复杂度留给了运行时线程多、切换多事件驱动把复杂度留给了开发者状态机、回调、缓冲管理。没有哪一种绝对更好只有适不适合你当下的连接规模、团队能力和业务特征。很多团队上事件驱动上早了代码写成一团乱麻问题排查成本远超省下来的那点资源。反过来连接数已经过万了还在用一连接一线程那就是在跟物理规律较劲。真要给一个判断标准我会看两条连接数长期稳定在 500 以下用阻塞 IO 加线程池超过这个量级或者有大量空闲长连接就老老实实上 epoll 那一套。中间地带可以先用“阻塞 IO 合理超时 连接池”过渡等压测数据真的支撑不住了再重构别一上来就追求终极方案。