拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux高并发网络IO:阻塞非阻塞、epoll与线程池阻塞队列

1. 从一次线上卡死说起read 到底是停住还是返回先说个我自己踩过的坑。早年接手一个内部数据上报服务服务端用的是最朴素的一连接一线程写法本地压测跑得挺欢放到真实环境里大概二十来个客户端同时上报的时候服务开始间歇性不响应top看上去 CPU 不高日志里干干净净什么都没有。折腾了两天才定位到问题根本不在业务逻辑而是 Linux 网络编程里最基础的那道分水岭——阻塞与非阻塞。那几个卡住的线程全都停在recv()上等着一个可能永远不会再来的对端数据。这件事之后我才真正意识到阻塞与非阻塞不是一个知道概念就行的知识点它是决定你的服务能不能从能跑走到跑得好的第一个开关。这篇文章就围绕这道分水岭展开阻塞到底卡在哪里、非阻塞凭什么能解开、多路复用是怎么把非阻塞用对、线程池里的阻塞队列又该怎么选最后把我实际排查中遇到过的几个坑完整还原出来。不管你刚接触 socket 网络编程还是写过几年代码但对 IO 模型一直有点含糊这篇都能对着看。1.1 阻塞不是异常行为而是内核替你做的等待很多人第一次看到阻塞这个词下意识觉得是个贬义词好像程序卡住了就是出了 bug。其实完全相反阻塞是文件描述符的默认属性也是内核对调用方最友好的一种安排。当你调用read(fd, buf, len)而接收缓冲区里恰好没有数据时内核并不会立刻返回一个错误让你自己去处理而是做了这么几件事先把当前进程或线程的状态从TASK_RUNNING改成TASK_INTERRUPTIBLE然后把它挂到这个 socket 对应等待队列上接着触发一次调度把 CPU 让给别的任务。等到网卡收到数据、协议栈把数据放进接收缓冲区、并且唤醒等待队列上的任务你的进程才重新变回可运行状态继续执行read并返回实际读到的字节数。这个设计的好处相当直白调用方的心智负担极小。你写read就是我要数据要么给我要么我就睡着等代码逻辑是线性的、顺序的不需要自己写循环去探测数据来了没有。写业务逻辑的人最喜欢这种东西因为它符合人的直觉。但代价也很明显睡着的这段时间里这个线程什么也干不了。它是一个被绑定在单个 fd 上的执行单元。这就是为什么阻塞模型在连接数少、每连接交互频繁的场景下非常好用一到高并发长连接场景就迅速崩盘。1.2 数据没到的时候内核把你的进程放进了哪个队列这里有个细节值得展开因为它直接解释了后面 epoll 的很多设计。每个 socket 在内核里都会挂一个wait_queue_head_t结构本质是一条等待队列的头。谁在这个 fd 上阻塞了谁就被加进这条队列的一个节点。当数据到达时协议栈在把 skb 放进接收队列之后会调用sk_data_ready这个回调它的任务就是遍历等待队列、唤醒里面的进程。唤醒的时候还有个讲究是唤醒一个还是唤醒全部默认情况下wake_up_interruptible只唤醒一个避免不必要的惊群。理解这条链路之后很多现象就顺了。比如为什么阻塞在accept上的多个进程在被唤醒后只有一个能拿到新连接——因为唤醒本身不保证公平抢不到的那个可能又睡回去了。再比如为什么epoll能把复杂度降下来——它本质上就是在这条等待队列的机制之上加了一层由内核维护的谁就绪了的登记簿省掉了用户态每次都要全量询问一遍的开销。1.3 为什么同样的代码压测没事、真机就挂回到开头那个案例。本地压测为什么没事因为压测工具在本地发起连接网络延迟几乎为零数据几乎在accept返回的那一刻就已经躺在缓冲区里了。recv回回都有数据从来不真的睡着自然什么问题都暴露不出来。真机环境有三点不同网络往返带来了毫秒级延迟客户端可能只建立连接不上报数据吸血连接客户端可能异常断开而不发FIN包。这三点凑在一起就是线程全被卡在 recv 上的完美配方。提示只要你的服务里存在每连接一个线程/进程且阻塞读写的组合就必须假设最坏情况下所有工作线程都会被长期占用。这个假设不成立的时候压测结果基本没有参考价值。所以问题的根不在阻塞这个行为本身而在用阻塞的方式去服务大量不确定何时活跃的连接。这也是后面所有讨论的出发点。2. 阻塞模式的隐性账单线程、栈与上下文切换真正动手改之前得先算清楚阻塞模型到底贵在哪里。很多人只看到线程多但线程多只是表象真正的成本分布在三个层面内存占用、调度开销、以及最要命的——并发能力的上限被线程数硬性锁死。2.1 一连接一线程为什么在千级并发就开始发抖先算笔账。一个 Linux 线程的默认栈大小是 8MB即使你不去真的碰满它虚拟地址空间是要预留的。1000 个线程就是 8GB 的地址空间预留虽然实际物理页按需分配但光是把这些线程建起来、让它们各自有个栈几十兆的实际内存就跑不掉。你要是把栈调到 128KB那 1000 个线程大约 128MB勉强能看但 1 万个连接呢更关键的是线程本身的调度成本不是线性的。内核的 CFS 调度器在可运行任务数量增加时单个任务能分到的时间片变短调度决策本身消耗的 CPU 也上升。1000 到 10000 这个区间很多机器会开始出现明显的调度抖动。再看一个常被忽略的点线程创建和销毁不是免费的。pthread_create底层要走clone系统调用伴随内存分配、信号处理结构初始化等一堆动作。如果连接的生命周期很短比如 HTTP 短连接线程的创建销毁频率可能比业务处理本身还耗资源。这就是为什么成熟的实现都要用线程池但线程池解决的是创建销毁的成本解决不了线程被 IO 卡住这个根本问题。并发连接数纯阻塞一连接一线程阻塞线程池非阻塞多路复用100轻松轻松轻松1000内存吃紧、抖动受池大小限制轻松10000基本不可用队列积压严重常规配置即可100000不可行不可行需要调参但可行2.2 阻塞期间的 CPU 在做什么答案是什么都不做但也不是完全免费。线程从运行态转入睡眠态需要一次调度从睡眠态被唤醒回到运行态又需要一次调度如果这个 fd 上数据到达非常频繁比如高频小包就会形成一种睡下-唤醒-睡下-唤醒的循环。这种高频切换在某些场景下会成为性能热点。我用过一个上报频率特别高的内部服务单机每秒几万个小包改造前vmstat里的cs上下文切换次数能到每秒几十万其中相当一部分就是这种被 IO 唤醒导致的。改造后换成epoll边缘触发加非阻塞读一次性把缓冲区读空切换次数直接降了一个数量级。顺带提一句高频小包场景下还有一个坑是TCP_NODELAY。默认情况下 Nagle 算法会攒小包看起来减少了包数量但在请求-响应式的交互里会引入额外的延迟。对延迟敏感的服务通常要显式打开这个选项这跟阻塞非阻塞是两码事但经常被一起讨论。2.3 上下文切换的三种代价与观测手段上下文切换的成本可以拆成三块一是寄存器保存恢复的 CPU 周期二是缓存和 TLB 失效带来的内存访问变慢三是调度器本身的时间。第三块相对最小第二块往往最容易被忽视——切换之后原来的热数据可能全在 L2/L3 缓存里躺着新线程要重新加载这种缓存冷启动的代价在高频切换下非常可观。观测手段上我常用这几个组合vmstat 1看cs和in判断系统整体切换压力pidstat -w -p pid 1看具体进程的cswch/s自愿切换通常是被 IO 阻塞和nvcswch/s非自愿通常是时间片用完被抢占perf sched或者perf stat -e context-switches做细粒度归因cat /proc/pid/status | grep ctxt看累计值注意cswch/s高不等于有问题。判断标准是这个切换有没有换来实质进展。如果切换是因为线程在等一个迟迟不来的数据包那它纯粹是浪费如果切换是因为有另一个真正可运行的任务需要 CPU那它是合理的。把账算清楚之后改造方向就很明确了不是让 IO 不等待而是让等待这件事不再占用一个独立的执行单元。3. 把等改成试非阻塞的三条硬规矩非阻塞的核心思想一句话就能说完当操作无法立刻完成时不睡觉直接返回一个现在不行的标记。但真正写起来这条路比阻塞麻烦得多因为原来由内核替你处理的所有边界情况现在都变成你代码里的分支。3.1 O_NONBLOCK 该在哪里设、设了谁受影响设置非阻塞的常见位置有三个效果范围不同socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)在创建时直接带上标志。这是最干净的方式Linux 2.6.27 之后都支持。fcntl(fd, F_SETFL, flags | O_NONBLOCK)在已有 fd 上追加标志。accept4()的第四个参数也能直接指定SOCK_NONBLOCK比先accept再fcntl少一次系统调用还能避免中间那一小段窗口期。通过open打开的设备文件带O_NONBLOCK。关键点是非阻塞是 fd 本身的属性准确说是指向的 file 结构上f_flags的属性不是单个操作的行为。你不小心把一个 fd 设成非阻塞之后所有的读写都会受影响包括那些你原以为会阻塞的地方。反过来如果你只对写设了非阻塞读还是阻塞的那读依然会把线程卡住。还有一类容易踩的坑是dup和fork。dup出来的新 fd 和原来的共享同一个 file 结构所以O_NONBLOCK是共享的改一个另一个也变。而fork之后子进程继承的文件描述符指向的是同一个 file 结构所以标志同样共享——这点在多进程模型里很容易造成我明明在主进程里设了非阻塞子进程里还是阻塞的或者反过来的困惑。3.2 EAGAIN/EWOULDBLOCK 不是错误码这是新手最容易写错的地方。非阻塞 fd 上调用read如果当前没有数据返回 -1errno被设成EAGAIN或EWOULDBLOCK在 Linux 上这两个值相同都是 11。很多人一看返回 -1 就当成出错了直接perror打日志然后关连接结果好好的连接被自己关掉。正确的处理是把EAGAIN和EWOULDBLOCK当成一个状态而不是错误它意味着现在这个时候没有数据/没有空间你稍后再来。同样地accept在非阻塞监听 fd 上没有待处理连接时也会返回EAGAIN这不是异常。写操作在发送缓冲区满了的时候返回EAGAIN这同样不是异常。ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { /* 正常收到数据注意 n 可能小于 sizeof(buf) */ } else if (n 0) { /* 对端有序关闭这是 FIN */ } else { if (errno EAGAIN || errno EWOULDBLOCK) { /* 暂时没数据不是错误直接返回等待下次就绪 */ } else if (errno EINTR) { /* 被信号打断重试即可 */ } else { /* 这才是真错误 */ } }这个分支结构建议直接背下来这是非阻塞读写的基本骨架。3.3 忙轮询与短读短写既然返回EAGAIN就重试那是不是可以写个while循环一直重试理论上可以这就是所谓的忙轮询busy polling。但实际上一旦你这么做一个线程就会把 CPU 核心占满而且绝大多数循环都是在做无用功。有个反直觉的地方忙轮询在极低延迟场景下确实有价值比如某些高频交易系统会用SO_BUSY_POLL让内核在网卡层面做短时间的自旋等待减少一次唤醒延迟。但那是有硬件和内核参数配合的特定优化跟我们在用户态写while死循环完全是两回事。后者只会让top上的us飙到 100%然后被运维找上门。正确的做法是把重试这件事交给多路复用机制注册到select/poll/epoll上等内核告诉你 fd 就绪了再来读。这样 CPU 就在真正无事可做的时候被让出去。另一个必须建立的概念是短读短写。read返回的字节数一定小于等于你传入的长度尤其是在 TCP 这种流式协议上一次read可能只读到半条消息。write也一样它返回的是我这次写进去了多少不是我保证全部写完了。所以应用层必须有消息边界处理长度前缀、分隔符、定长并且写操作要么循环写直到写完要么把剩余数据存进发送缓冲区等下次可写事件。3.4 读写返回值必须当成最多读了多少这一条单独拎出来说是因为它造成的 bug 往往非常隐蔽。阻塞模式下你写write(fd, buf, 1000)大部分时候它会返回 1000久而久之就形成了write 一定写全的思维定式。切到非阻塞之后缓冲区满的时候write可能只返回了 200剩下 800 字节如果被丢掉对端收到的就是残缺的数据而且因为 TCP 不保留消息边界表现可能是偶尔解析失败或偶发乱码极难定位。提示判断一个网络程序写得靠不靠谱最简单的办法就是搜一下代码里所有write和send的返回值有没有被完整处理。如果一个都没处理那这个程序在高负载下必然出问题。写非阻塞程序时我习惯给每个连接维护一个发送缓冲区结构大致包含指针、剩余长度、偏移量三样东西。可写事件到来时就从偏移量开始尽量写写不完就保留状态等下次。这种写法比一次写完复杂但它是唯一正确的做法。4. 四个词摆上坐标纸阻塞/非阻塞与同步/异步的真实关系阻塞、非阻塞、同步、异步这四个词被混着用得太厉害了尤其是在各种面试题和博客里。我见过不少人把非阻塞直接等同于异步然后在讨论的时候鸡同鸭讲。这一节把它们放到一张坐标上理清楚。4.1 两组概念分别在回答什么问题先明确一点这两组词的维度不一样。阻塞/非阻塞回答的是调用方在等待结果时的状态。调用发出后如果调用方被挂起直到有结果就是阻塞如果立即返回一个还没好的标记就是非阻塞。它描述的是调用方进程的状态。同步/异步回答的是结果由谁来完成并通知。同步是指调用方主动去获取或等待结果完成异步是指操作提交之后完成和通知由另一方内核或其他线程负责调用方通过回调、信号或 completion 机制获知。它描述的是消息通知机制。把这两组两两组合得到四种模型组合调用方状态结果通知方式典型实现同步阻塞挂起调用返回即完成默认的 read/write同步非阻塞立即返回调用方轮询或配合多路复用O_NONBLOCK epoll异步阻塞挂起由第三方完成后唤醒较少见类似条件变量等待异步非阻塞立即返回回调/信号通知完成io_uring、POSIX AIO、IOCP日常说的高性能网络服务绝大多数落在第二格同步非阻塞 多路复用。程序本身是同步的代码顺序执行但 fd 是非阻塞的通过 epoll 拿到就绪通知。4.2 五种 IO 模型的定位经典的五种模型可以这样对照阻塞 IO最直观一连接一线程的标配。非阻塞 IO需要自己轮询单独用不划算但可以作为多路复用的基础。IO 多路复用select/poll/epoll本质是用一个线程同时等待多个 fd。信号驱动 IO内核在数据就绪时发SIGIO实际工程中用得很少原因是信号处理函数里能做的事极其有限。异步 IO数据从内核拷贝到用户缓冲区这一步也由内核完成后再通知。io_uring是目前 Linux 上最值得关注的实现它在高吞吐场景下能明显减少系统调用次数。需要强调的是前四种严格来说都是同步 IO因为在数据从内核缓冲区拷贝到用户缓冲区这个阶段调用方仍然是参与的。只有第五种才是真正的异步。4.3 为什么非阻塞常被误叫成异步因为在应用层的观感上非阻塞加事件循环确实表现出了异步的效果你注册一个回调数据来了回调被触发你不需要在原地等。但从内核的视角看你仍然是主动调用epoll_wait去问谁就绪了然后主动调用read把数据搬走这两个动作都是同步的。这个区分在性能分析的时候很重要。比如你发现某个服务在数据拷贝上花了大量 CPU那说明瓶颈在内核到用户态的那一次拷贝这时候讨论增加异步度是没用的得考虑mmap、sendfile或者io_uring这类能减少拷贝的手段。如果搞混了概念优化方向一开始就是错的。5. 非阻塞的正确打开方式从select/poll到epoll单靠O_NONBLOCK是没法写出高效程序的因为你总得知道什么时候可以去读。轮询烧 CPU那就得靠多路复用。这三个接口的演进史其实就是 Linux 在高并发网络场景下不断优化的过程。5.1 select 的两次遍历与 1024 天花板select的接口很直观传进去三个 fd 集合读、写、异常一个超时时间返回就绪的个数。但它有两个硬伤。第一个是FD_SETSIZE默认 1024。这个限制来自fd_set用位图实现而且这个上限在编译期就固定了改起来要重新编译内核或使用特殊手段基本不现实。第二个是性能。每次调用select内核要把整个 fd 集合从用户态拷贝到内核态然后线性遍历所有 fd 检查是否有事件返回前还要再遍历一遍把结果写回用户态。fd 数量一上来这个 O(n) 的拷贝加遍历就成了瓶颈。而且select会修改传入的集合所以每次调用前都得重新构造一遍又是一次额外开销。select至今仍有它的位置跨平台兼容性最好Windows、类 Unix 都有而且当 fd 数量很少比如少于几十个时它的开销完全可以接受。写跨平台的小工具时我仍然会用select。5.2 poll 改了什么、没改什么poll用pollfd数组替代了位图解决了 1024 的限制而且把关注的事件和发生的事件分成了两个字段不需要每次重建集合。但它的核心问题没解决每次调用仍然是 O(n) 的遍历加拷贝。内核依然要逐个检查每个 fd 的状态。所以当连接数达到几万、但同一时刻只有几十个活跃时poll绝大多数时间在做无用功。这个连接多但活跃少的特征恰恰就是长连接服务端的常态也正是 epoll 要解决的核心问题。5.3 epoll 的红黑树加就绪链表epoll把注册和等待拆成了两步。epoll_ctl负责把 fd 加进一个内核里的红黑树同时给这个 fd 挂上回调。当数据到达、fd 状态变化时回调被触发把这个 fd 放进一个就绪链表。epoll_wait要做的只是检查这个就绪链表是否为空非空就返回复杂度是 O(1) 级别的。这个设计带来的实际差异非常直观int ep epoll_create1(0); if (ep 0) { /* 处理错误 */ } struct epoll_event ev; ev.events EPOLLIN | EPOLLET; ev.data.fd listen_fd; epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[1024]; while (1) { int n epoll_wait(ep, events, 1024, -1); if (n 0) { if (errno EINTR) continue; break; } for (int i 0; i n; i) { /* 只处理真正就绪的 fd数量通常远小于总连接数 */ } }需要留意的细节有几个epoll_create1比老的epoll_create少一个无意义的size参数events数组大小决定了单次最多返回多少就绪事件设太小会导致某些就绪 fd 被延后处理设太大会占用内存超时设为 -1 表示永久等待设为 0 表示纯轮询。另外epoll的 fd 本身也可以用epoll_ctl加到另一个 epoll 实例里形成层级结构。这个特性在多线程分片每个线程一个 epoll 实例的架构里很有用但要注意EPOLL_CTL_ADD时传入的data字段是唯一的用户标识通常放 fd 或者一个指向连接结构的指针。5.4 LT 与 ET一个最容易写错的开关水平触发LT和边缘触发ET是 epoll 里最需要理解清楚的一个选项。LT 的语义是只要缓冲区里还有数据每次epoll_wait都会持续通知你。这让编程变得简单因为你可以一次只读一部分剩下的下次再说。默认就是 LT不显式设置EPOLLET就是这个模式。ET 的语义是只在状态发生变化时通知一次。如果你收到通知后只读了一半数据剩下的一半不会再触发新的通知于是那些数据就永远留在了缓冲区里直到对端又发了新数据才可能被带出来——表现就是连接随机性地变慢或者假死。所以用 ET 有一条铁律必须配合非阻塞 fd并且在收到可读事件后用循环把数据读空直到read返回EAGAIN。while (1) { ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { /* 处理数据继续循环 */ } else if (n 0) { /* 对端关闭 */ break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; /* 真错误 */ break; } }ET 的优势是减少了事件通知次数在高吞吐场景下能降低一些开销。但如果你的实现没写好它带来的 bug 会非常难查。我的建议是除非你已经把 LT 版本跑得很稳、并且确实测出来 ET 有收益否则不要轻易上 ET。6. 服务端另一块拼图线程池里的阻塞队列怎么挑聊完 IO 层往上一层就是任务队列。很多服务端架构是IO 线程负责收数据、解析请求、把任务丢进队列工作线程从队列取任务执行。这里的队列选型直接关系到服务在压力下的行为。6.1 阻塞队列在网络服务端的位置之所以叫阻塞队列是因为它提供了两个会阻塞的操作队列空时取元素的人会等待队列满时放元素的人会等待。这两个阻塞行为恰好对应了两种背压机制。我见过一些实现用无锁的环形缓冲自己撸队列性能确实好但一旦处理不好边界条件出现的问题往往比性能损失严重得多。所以先问自己一个问题你是需要极致吞吐还是需要行为可预测大部分业务服务属于后者直接用成熟的阻塞队列实现就好。6.2 有界、无界与移交队列的取舍三种典型选择无界队列。放元素永远不会阻塞看起来最省事。但它掩盖了容量问题——任务积压的时候队列会无限膨胀直到把内存吃光然后整个进程被系统干掉。这种先看起来正常然后在某个时刻突然崩掉的行为是最难运维的。我个人基本不用无界队列做生产环境的任务队列。有界队列。队列满了之后放元素的操作会阻塞或者被拒绝把压力明确地反馈给上游。这是我绝大多数场景下的默认选择。队列容量的确定需要看任务的平均处理耗时和可接受的延迟。简单估算如果任务平均耗时 5ms你希望队列延迟不超过 500ms那就是 100 个槽位左右再留一点余量。同步移交队列。它不存储元素放和取必须同时发生否则一方阻塞。这种队列适合生产者速度和处理速度差不多不需要缓冲的场景能提供最直接的背压。队列类型积压行为适用场景主要风险无界无限增长任务量极小、可控内存耗尽有界满时阻塞或拒绝通用服务端容量设置不当同步移交不缓冲生产消费速率接近抖动直接传导6.3 拒绝策略就是背压策略队列满了之后怎么办这个问题比选哪个队列更重要。常见几种处理方式直接丢弃并记录、返回失败让上游重试、由提交任务的线程自己执行、或者干脆阻塞等待。由提交线程自己执行这种策略有个反直觉的好处它让提交方亲身感受到压力自然降低了提交速度形成一种温和的自我调节。但它的风险是提交线程可能正是 IO 线程一旦它开始执行耗时任务整个 IO 循环就被拖住了。所以这个策略只在提交方本身就有余力的时候才好用。真正重要的是把拒绝这个动作显式地设计进去而不是让它以内存溢出或者长时间无响应的形式隐式发生。我一般在队列满的时候会做三件事打点记录速率、返回一个明确的繁忙响应、并且触发一次流量控制。这比事后翻日志猜测要有效得多。注意队列长度这个参数没有放之四海皆准的值。它的正确取值取决于你对延迟的容忍度而不是某个最佳实践。压测时把队列长度设成不同值观察 P99 延迟的变化曲线比抄一个数字靠谱。7. 排错链路实录五个让程序看起来正常的写法这部分是全文我最想写的。前面讲的都是应该怎么做这一节讲的是实际写代码时最容易出错、而且出错后最难查的地方。每一条我都真实踩过。7.1 EINTR 被当成真错误read、write、accept、epoll_wait这些系统调用都可能被信号打断返回 -1 并设置errno为EINTR。如果代码里只判断了返回值小于 0 就是错误那么当进程收到一个信号比如定时器信号、或者你用了SIGALRM时一个本来正常的连接就会被误关。这类问题的症状是程序运行时间越长越容易出现异常断开而且和业务量没关系。排查方法是strace -f -e tracenetwork -p pid抓一下如果看到大量 ? ERESTARTSYS或者EINTR基本就确认了。修法很简单遇到EINTR就重试或者用SA_RESTART标志让系统调用自动重启。7.2 accept 返回的新连接忘了设非阻塞这是我最常见到的疏漏。监听 fd 设了非阻塞accept出来的连接 fd 却是阻塞的之后所有针对这个连接的读写都会阻塞把整个 IO 线程卡住。而且因为只有一部分连接是这样的问题表现非常随机可能压测几十分钟才出现一次。规避方法是用accept4直接带上SOCK_NONBLOCK从源头保证新 fd 的属性正确。用accept的话紧接着就要fcntl设置中间别夹其他逻辑。int conn accept4(listen_fd, (struct sockaddr *)addr, len, SOCK_NONBLOCK | SOCK_CLOEXEC);顺便说下SOCK_CLOEXEC它确保exec系列调用时这个 fd 会被自动关闭。在没有这个标志的年代多线程程序里fork加exec的组合会导致 fd 泄漏到子进程里然后子进程持有连接不放出现连接已关闭但资源没释放的怪现象。7.3 ET 模式下漏读导致的连接假死前面提过 ET 的读空要求这里说一下它的实际表现。症状是某个连接在收到几次数据之后就再也没有响应但对端说它一直在发。用ss -tnp看这个连接的接收队列会看到Recv-Q一直是个非零值不下降。这种假死的成因往往是代码里对消息不完整的处理有问题。比如读到一半发现消息头不完整就return了等着下次事件再来读剩下的但 ET 不会再有下次通知。所以正确做法是把数据先全部读进自己的缓冲区再在缓冲区上做消息解析。IO 层的读和业务层的解析必须解耦这是 ET 模式下的基本纪律。7.4 关闭时序close、shutdown 与半关闭关闭连接看起来简单实际上有几个容易搞错的地方。close会把 fd 的引用计数减一只有减到零才会真正关闭连接并且在关闭时如果发送缓冲区还有数据内核会尝试把数据送出去再发 FIN但这个过程是异步的。如果你close之后立刻退出进程那些数据可能就丢了。shutdown(fd, SHUT_WR)是发送 FIN 但不影响接收这允许实现半关闭——告诉对端我不再发数据了但我还在听。这种模式在某些协议交互里是必须的比如对端需要读完所有数据才能开始处理。还有一个常见问题是close之后的 TIME_WAIT 状态。主动关闭的一方会进入这个状态并保持 2MSL占用一个四元组。如果服务端频繁主动关闭短连接端口可能被快速耗尽。规避手段包括让客户端主动关闭、调整tcp_tw_reuse参数需要谨慎评估、或者用连接池复用连接。另一个坑是close一个已经触发EPOLLERR或者EPOLLHUP事件的 fd。这些事件在 epoll 里是被无条件上报的你不需要注册也会收到。如果你没处理epoll_wait会一直返回这个 fd形成活锁CPU 直接跑满。7.5 我的实际排查顺序遇到陌生的网络问题我基本按这个顺序走ss -tnlp看连接状态分布。如果出现大量CLOSE_WAIT说明是应用层没有正确关闭被动关闭的连接大量SYN_RECV则可能是握手阶段被攻击或者accept队列满了。ss -tni看具体连接的重传、窗口、拥塞参数。Retrans不为零说明网络层有问题。strace -f -p pid -e tracenetwork抓几分钟看系统调用的返回值和 errno 分布。如果怀疑是 IO 模型问题perf top -p pid看火焰图上时间花在哪里是卡在epoll_wait还是某个处理函数。最后才去读代码。前面四步能把范围缩小到某个具体的连接或者某类系统调用。提示CLOSE_WAIT堆积几乎百分百是应用层的问题不是内核的问题。看到这个状态先检查自己代码里有没有在处理完请求后关闭连接。8. 一份可直接改的骨架代码与非阻塞改造清单理论和排错都讲完了最后落到可以动手的东西上。8.1 epoll 加非阻塞的 C 骨架下面这段是我常用的最小可用骨架去掉了业务逻辑保留了所有 IO 相关的关键处理。socket 是监听 fd已经设了O_NONBLOCK。/* 设置监听 fd 非阻塞 */ int flags fcntl(listen_fd, F_GETFL, 0); fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK); int ep epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev {0}; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[512]; for (;;) { int n epoll_wait(ep, events, 512, -1); if (n 0) { if (errno EINTR) continue; break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { /* 一次性 accept 完所有待处理连接 */ for (;;) { struct sockaddr_in cli; socklen_t len sizeof(cli); int c accept4(listen_fd, (struct sockaddr *)cli, len, SOCK_NONBLOCK | SOCK_CLOEXEC); if (c 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; break; } struct epoll_event cev {0}; cev.events EPOLLIN; cev.data.fd c; epoll_ctl(ep, EPOLL_CTL_ADD, c, cev); } } else { /* 读空这个连接 */ char buf[4096]; for (;;) { ssize_t r recv(fd, buf, sizeof(buf), 0); if (r 0) { /* 交给业务层处理 */ } else if (r 0) { epoll_ctl(ep, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; epoll_ctl(ep, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } } } } }几个关键点再强调一下accept要循环调用直到EAGAIN因为一次epoll_wait通知可能对应多个待处理连接recv要循环读到EAGAIN或者对端关闭EPOLL_CTL_DEL在close之前调用不是必需的close会自动清理但显式删除能让逻辑更清楚。8.2 从阻塞改到非阻塞的检查清单把现有代码改造过来的时候我一般按这个清单过一遍所有 socket 创建点是否都带了非阻塞标志包括accept出来的所有read/write的返回值是否都处理了EAGAIN、EINTR、以及部分读写是否存在写一次就假定写完的地方需不需要引入发送缓冲区消息解析是否与 IO 读操作解耦避免 ET 模式下的漏读是否处理了EPOLLERR和EPOLLHUP连接关闭路径是否统一CLOSE_WAIT有没有堆积风险epoll_wait的事件数组长度是否与预期并发量匹配是否有信号处理导致系统调用被打断的场景这份清单看着啰嗦但每一条背后都是一次真实的故障。我自己的习惯是把这份清单贴在改造任务的第一行改完一项划掉一项。8.3 几个我实测下来值得记住的参数最后分享几个调优时经常用到的值都是实测对比出来的经验不是从文档抄的listen的 backlog 参数在 Linux 上实际生效的是min(backlog, somaxconn)somaxconn默认 4096 左右高并发场景建议显式调大。epoll_wait的事件数组长度我一般设成预估活跃连接数的 1/10 到 1/5。设太小会频繁返回设太大浪费内存扫描。单连接的读缓冲区4KB 到 64KB 之间。太小导致系统调用次数多太大浪费内存。用SO_RCVBUF调整时注意内核会有上下限约束最终值可能和你设的不一样调整后用getsockopt读回确认。线程池的队列长度用压测的 P99 曲线来定不要拍脑袋。这几个参数没有普适的最优值因为最优值取决于你的流量特征和硬件。但它们给了我一个很好的起点剩下的就是根据实际测量微调。改造完那个上报服务之后最直观的变化是单机能承载的连接数从几百涨到了几万cswch/s掉了一个数量级而且再也没出现过线程全卡住的情况。回头看整个改造其实就围绕一句话把等待从占用执行单元变成占用一个状态标记。理解这一点剩下的 epoll、非阻塞、发送缓冲区、任务队列都只是这句话的工程化落地而已。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门