☰
从阻塞到epoll:高并发网络编程的I/O模式与踩坑实践
2026/10/17 14:13:59 网站建设 项目流程

1. 卡死案例复盘:不是线程不够,是阻塞语义把线程按住了

1.1 现象与第一轮误判

先说我最近改造一个内部网关接入层时遇到的真实状况。客户端通过 TCP 长连接上报数据,服务端收到后处理完往对端回写结果,日志里隔一段时间就出现一条write timeout。最诡异的是连接本身还活着,TCP 握手早就完成,对端也没有主动断开,但服务端的数据就是推不出去。一开始我理所当然地往并发模型上想:是不是线程池太小了,业务处理不过来?于是把工作线程从 4 个加到 16 个,跑了一晚上,日志里照样冒超时。

后来我又怀疑是业务代码里有慢查询或者锁竞争,把处理函数单独做了耗时统计,结果发现收到数据之后处理时间都在 1 毫秒以内。这就很尴尬了——业务侧明明是快的,为什么数据迟迟写不出去?

回过头来再看,我犯了一个很典型的错误:应用层报错的时候,下意识先去查业务逻辑,而不是先确认 I/O 方式本身是不是合理。这个项目的接入层当初图省事,所有 socket 都用默认模式打开。默认是什么?在 Linux 上,新建的 TCP socket 默认是阻塞模式。阻塞模式下,write会一直等到内核发送缓冲区有足够空间才返回,这个“等”不是空转,而是把当前线程挂起。

1.2 strace 抓到关键证据

为了不再靠猜,我用 strace 挂到服务进程上抓系统调用。结果非常直观:某个线程卡在write上长达 3 秒多才返回,期间没有任何 CPU 消耗,也没有其他系统调用发生。这说明线程不是在被业务逻辑拖着,而是被 socket 阻塞语义按住了。

当时进程里还有几十个线程在处理其他连接,为什么只有部分连接出问题?再往下看就明白了:出问题的连接都是客户端发送完数据后没有立刻关闭的连接,也就是典型的“服务端单向写”场景。这类连接的数据量不大,但发送时机集中,一旦瞬时写请求超过内核发送缓冲区的接纳能力,阻塞 socket 的write就会让线程进入不可中断睡眠,直到缓冲区腾出空间。

如果只是偶发一次,可能影响不大,但在高并发长连接场景下,一个线程卡在write上,连接池和处理能力都会被拖累。线程不是无限的,被阻塞的线程越多,真正能处理新连接的线程就越少,最终表现为整体吞吐下降、超时率上升。

1.3 从“偶发慢”到 I/O 模式选择的反思

这个案例给我的核心教训是:阻塞与非阻塞不是简单的“快”和“慢”,而是两种完全不同的资源占用方式。

阻塞模式更像是“一个人盯一个活”,数据没准备好就睡觉,等内核把数据准备好了再叫醒他。这个模型简单、直觉,适合连接数少、每个连接都有独立线程的场景。

非阻塞模式则是“一个人管一堆活”,每次操作只尝试一次,如果资源没就绪,立刻返回,让线程去做别的。配合多路复用,比如select、poll、epoll,才能把少量线程的潜力榨干。

如果只是把非阻塞 I/O 理解为“不卡线程”,却不用多路复用去等读就绪事件,那非阻塞就变成了“忙轮询”——线程不断重试read/write,CPU 空转,比阻塞还糟糕。所以这两个概念必须一起看:非阻塞是操作的行为,多路复用才是等待事件的机制。

后面我把接入层改成非阻塞模式,并且用epoll管理所有连接的事件,同时把一部分“写不出去”的场景纳入事件循环的重试机制,超时时间很快就压下来了。但改造过程远比想象的复杂,几个高频的坑几乎每个都踩了一遍。

2. 阻塞与非阻塞在四种系统调用上的行为差异

2.1 read 与 write:可用数据和缓冲区的两种等待

要理解阻塞与非阻塞,最直观的入口是read和write在“资源不可用”时的表现。

阻塞 socket 上调用read,如果接收缓冲区里没有数据,线程会挂起,直到内核收到数据并通知可读才返回。非阻塞 socket 上调用read,无论有没有数据都会立刻返回:有数据就返回读取的字节数,没有数据就返回-1,并设置errno = EAGAIN(在 Linux 上通常和EWOULDBLOCK是同一个值)。

write的对称情况是发送缓冲区满。阻塞 socket 会等,非阻塞 socket 会立刻返回EAGAIN。这里有一个容易踩的细节:发送缓冲区满并不代表连接断了,只代表当前内核缓冲区暂时接纳不下你这次的数据。你需要在后续EPOLLOUT事件到来时继续把剩下的数据写完。

还有一个大家经常忽略的点:非阻塞模式下的read和write都支持“部分完成”。也就是说,你要读 2048 字节,内核缓冲区里只有 512 字节可用,read会先把这 512 字节返回给你,而不是等到凑满 2048 字节。如果业务侧默认“一次读多少就代表读到了多少”,就会有数据粘包和截断问题。

我的处理方法是:所有读写都维护一个应用层偏移量,每次返回n字节后把偏移量往前推,直到完整读完一个报文。尤其要注意write的部分写问题,不跟踪偏移量的话,第二次重试时很可能把同一段数据重复发送。

2.2 accept 与 connect:队列和握手状态的额外讲究

read和write是比较直观的,accept和connect则是很多人容易搞混的地方。

阻塞模式下,accept会一直等到有新的连接请求到来才返回。非阻塞模式下,如果当前没有挂起的连接请求,accept返回-1并且errno = EAGAIN。这里要注意,在极早期的一些系统上,没有连接时accept可能返回EINTR,所以判断时要同时处理EAGAIN和EINTR,否则一个巧合的信号就能让你的 accept 逻辑错误退出。

connect更特殊。阻塞模式调用connect时,线程会一直等到 TCP 三次握手完成或者超时才返回。非阻塞模式下调用connect,几乎是立刻返回-1,errno = EINPROGRESS,表示连接正在进行。之后你需要通过poll/epoll监听这个 socket 上的可写事件,可写事件到来后再用getsockopt查看底层连接状态,确认是不是真的连接成功了。

这个机制导致很多人第一次接触非阻塞connect时很不适应:明明是去建立连接,怎么只发出去一个“仍在进行”?它不是 bug,而是内核给你的一种异步化能力——连接过程不占用调用线程,你可以在等待握手的间隙处理其他事情。

2.3 切换非阻塞的标准写法,以及容易改错的地方

在 Linux 上把 socket 从阻塞改成非阻塞,标准做法是用fcntl:

int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);

这里有个新手很容易写错的版本:

fcntl(fd, F_SETFL, O_NONBLOCK);

看起来只差一个F_GETFL,但实际上第二种写法会把文件描述符原来的标志位全部覆盖掉。如果这个 fd 之前设置过O_APPEND、O_DIRECT之类的标志,覆盖后这些行为就消失了。轴承项目里经常会有别的模块给同一个 fd 设置标志,这种覆盖会引入难以排查的副作用。

还有一点:accept返回的新连接 fd 不会继承监听 fd 的O_NONBLOCK状态,所以如果希望所有新连接都是非阻塞的,必须在 accept 返回后逐个设置。socket也好,accept也好,新 fd 默认都是阻塞状态,这是一个非常容易在压力测试时突然暴露的坑。

3. 多路复用怎么选:select、poll、epoll 的边界

3.1 select 的 1024 魔咒与 fd_set 重置

多路复用解决的核心问题是:怎么用少量线程等待大量 socket 的状态变化?

最早的方案是select。它的模型是把一组文件描述符放进fd_set,然后阻塞在select上,等其中任何一个 fd 就绪。听起来很合理,但它的实现有一个硬上限:fd_set的大小由FD_SETSIZE决定,在很多发行版上默认是 1024。意思是你一次最多监听 1024 个 fd,超过就无能为力。

更麻烦的是select会修改传入的fd_set。调用完之后,只有就绪的 fd 被保留,其他都被清掉了。所以下一次调用前,你必须把完整的fd_set重新填一遍。如果你在循环里漏掉了重置,就会从第二次迭代开始出现“明明连接有数据却永远等不到”。

select的性能还有一个遍历开销:每次调用都要把全部 fd 传给内核,内核再遍历一遍所有 fd 去检查状态。哪怕你同时只有 10 个就绪的 fd,只要监听的 fd 总数是 1 万,内核也得把 1 万个 fd 全过一遍。这个 O(n) 的成本在高并发场景下没法接受。

3.2 poll 的改进和它的 O(n) 阴影

poll修正了select最明显的两个问题:没有 1024 上限,也不需要重置 fd 集合。poll使用pollfd数组,每个元素里有events和revents两个字段,内核只会修改revents,所以你在下一次调用前不用重新填整个数组。

但poll依然保留了一个弱点:它还是要通过系统调用把整个 pollfd 数组从用户态拷贝到内核态,内核遍历全部 fd 检查状态,再把结果拷回用户态。连接数少的时候问题不大,一旦连接数涨到几万,每次poll的成本就会明显上升,因为它处理的是“全部 fd”,而不是“有事件的 fd”。

从用户视角看,poll比select好用很多,它适合中等规模的连接数,也就是几百到几千的量级。但如果你的目标是单机支撑几万、几十万连接,继续用poll就有些吃力了。

3.3 epoll 在内核里做了什么,让大规模接入成为可能

epoll之所以高效,核心不是返回值的形式变了,而是等待机制变了。select和poll每次都要你全量扫描,epoll则让你先把关注的 fd 和事件注册到一个内核维护的表里,然后在事件发生时只需要从内核拿“就绪列表”。

这个差别可以类比为两个找不到座位的人:poll每问一次就得把大厅里所有人挨个看一遍;epoll是你坐好之后,服务员只在有人来了的时候叫一声,你根据叫号单处理就行。连接数越大、就绪比例越低,这种事件驱动方式的优势就越明显。

使用 epoll 的基本流程是:

int epfd = epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); struct epoll_event events[128]; int n = epoll_wait(epfd, events, 128, -1); for (int i = 0; i < n; i++) { // events[i].data.fd 就是就绪的 fd }

这里的epoll_wait返回的是真正发生事件的 fd 数量,而不是你监听的 fd 总数。这就是 epoll 能支撑几十万连接的关键。要注意epoll_wait返回的n是已经就绪的事件数量,每个 fd 都可能出现多种事件,比如同时EPOLLIN和EPOLLOUT,业务代码要做遍历处理。

还有一个常见误解:epoll 不是“把 fd 变成非阻塞”的魔法。你仍然需要自己把每个 fd 设置为O_NONBLOCK,因为 event loop 里几乎不会有人用阻塞 fd 配合 epoll。阻塞 fd 在read时如果数据没到会直接卡住整个线程,那 epoll 的事件循环就名存实亡了。

4. LT 与 ET 的实战差异:为什么 ET 必须配套非阻塞

4.1 LT 的通知语义:有货就打电话

epoll 默认的工作模式是 Level-Triggered,简称 LT,也叫水平触发。它的语义是:只要 fd 上还有未处理的事件,epoll_wait就会重复通知你。举个例子,你用epoll_wait等到EPOLLIN,然后只读了 100 字节,接收缓冲区里还剩 200 字节,那么下一次epoll_wait还会再报一次EPOLLIN。

LT 的好处是编程简单,你不必担心丢事件。即使一次没有把数据读完,后续还有机会继续处理。因为事件会持续触发,你甚至可以配合阻塞 fd 使用——但这不是好习惯,最好还是统一非阻塞。

LT 的代价是对“是否处理完”没有要求,容易让人偷懒。业务方可能只读一部分就认为完成了,内核被迫反复唤醒线程。但它至少不会造成事件彻底丢失。

4.2 ET 的通知语义:状态变化才打一次

Edge-Triggered,简称 ET,是边界触发。它只在状态发生跳变的时候通知一次。比如一个 fd 从“没有数据”变成“有数据”,这是上升沿,通知一次;如果你读了 200 字节,缓冲区里还剩 100 字节,这仍然属于“有数据”状态,不再有新的上升沿,epoll_wait不会再通知你。

很多人第一次见 ET 时会有个错觉:是不是内核只通知一次就会把数据丢掉?其实不会,数据还躺在接收缓冲区里。真正的问题是:如果你没有把数据读完,也没有新的数据到达,就不再产生新的事件通知,你可能永远意识不到还有数据在等着处理。

这就解释了为什么 ET 模式必须搭配非阻塞读,而且在事件处理中要循环读到EAGAIN为止。你要是用阻塞 fd 读,可能读一次就睡过去,线程卡住不说,本应该等新数据的事件也会被阻塞调用破坏。

4.3 ET 模式下的读循环正确写法

ET 模式下的标准读循环大致是这样:

while (1) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { handle(buf, (size_t)n); continue; } if (n == 0) { // 对端关闭 close(fd); break; } if (errno == EINTR) { continue; } if (errno == EAGAIN || errno == EWOULDBLOCK) { // 数据确实读完了,退出循环 break; } // 其他错误 close(fd); break; }

continue是为了尽量在一次事件里把数据全部读走,直到返回EAGAIN。这里我建议使用一个足够大的栈上缓冲,比如 8KB 或 16KB。如果业务报文普遍偏大,可以用readv一次性填充多个缓冲区,减少系统调用次数。

一定要区分EINTR和EAGAIN。EINTR是被信号打断了,应该重试;EAGAIN是缓冲区确实被读空了,应该退出循环。很多人图省事把两种情况都当作错误处理,可能就会丢掉半截报文。

顺便说一句,ET 模式还有一种组合玩法是EPOLLONESHOT,它让 fd 在触发一次事件后自动从就绪列表里摘除,防止多个线程同时处理同一个 fd。如果你的 event loop 是多线程在竞争同一个 epoll 实例,EPOLLONESHOT能减少很多竞态,但代价是每次处理完都要重新epoll_ctl注册事件。

5. 我在项目里踩过的五类高频坑

5.1 把 EAGAIN 当错误直接关连接

这是我见过最多、也最隐蔽的问题。EAGAIN的正确语义是“现在资源不足,但不是错误”,它更像一个控制流程信号。用非阻塞 socket 读数据返回EAGAIN,意味着当前没有更多数据了,可以结束这次读取;用非阻塞 socket 写数据返回EAGAIN,意味着写缓冲满了,你需要把这段数据暂存下来,等EPOLLOUT来了再继续写。

如果你在错误处理里把EAGAIN和ECONNRESET、EPIPE混在一起处理,就等于把所有暂时性失败都当成了连接损坏。表现出来就是:服务端偶尔主动断开一个健康的连接,客户端重连后业务重新开始,浪费了大量握手开销。

针对写缓冲满的场景,我在客户端侧维护了一个待发送队列。write返回EAGAIN时把剩余数据放入队列,然后通过epoll_ctl注册EPOLLOUT事件。一旦可以继续写,就从队列里取出数据接着发;数据发完再把EPOLLOUT事件取消,避免写入事件一直触发造成频繁空转。如果长期无法写出去,再配合业务超时策略关闭连接。

5.2 非阻塞 connect 的超时判定

非阻塞connect返回EINPROGRESS之后,你要怎么知道连接到底是成功还是失败?我的做法是等这个 fd 上的可写事件,因为握手成功后 socket 就会进入可写状态。但这里有一个特别容易踩的坑:连接失败时同样会触发可写事件,所以不能只看可写就认为连接成功。

正确姿势是事件就绪后去询问 socket 的底层错误码:

int err; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); if (err == 0) { // 连接成功 } else { // 连接失败,err 是具体错误码 }

只看到EPOLLOUT就盲目把 fd 当作可用,很可能发送数据时才发现连接已经被对端拒绝。另一个注意点是超时判定不能用epoll_wait的超时参数来硬扛,而应该给每个 connect 记录一个开始时间,超时后主动把 fd 关闭清理,否则失败的连接会占着 fd 不释放,最终导致 fd 耗尽。

5.3 EPIPE/SIGPIPE 让进程悄无声息崩掉

当一个 socket 对端已经关闭,而你还继续往里写数据时,内核会向进程发送SIGPIPE信号。这个信号的默认动作是终止进程,于是你可能会看到服务进程“莫名奇妙”退出,日志里什么都没有。

有两种处理方案,可以根据项目风格二选一,也可以同时使用。第一种是在进程初始化时忽略信号:

signal(SIGPIPE, SIG_IGN);

忽略之后,写操作不会再触发进程退出,而是让write/send返回-1,errno = EPIPE,你的错误处理逻辑就有机会介入。第二种方法是使用send的MSG_NOSIGNAL标志:

send(fd, buf, len, MSG_NOSIGNAL);

MSG_NOSIGNAL的意思是:如果写到了已关闭的 socket,不要发信号,直接返回错误。这种方式的作用范围更局部,适合某些模块不想全局忽略信号的情况。

我习惯两种一起用:全局忽略SIGPIPE作为防御,再用MSG_NOSIGNAL作为显式声明。写接口返回EPIPE后,尽快关闭 fd,避免一个坏连接浪费后续事件处理资源。

5.4 惊群与 accept 队列的抖动

用多线程处理 epoll 事件时,最经典的问题是惊群:多个线程同时阻塞在同一个 epoll 实例上,当新的连接请求到来时,所有线程都被唤醒,但最终只有一个线程能成功accept到连接,其他线程空跑一圈再继续睡眠。

以前解决惊群常用“锁 + 只让一个线程 epoll_wait”的方式,现在内核已经提供了更好的手段,比如EPOLLEXCLUSIVE标志,它可以让事件只唤醒一个线程,避免惊群。不过EPOLLEXCLUSIVE有使用限制,它不能和EPOLLET同时用于某些场景,我建议先读清楚当前内核版本和 man page 再决定。

如果你想彻底绕开这个复杂度,也可以考虑用SO_REUSEPORT,让多个进程各自创建监听 socket,绑定同一个端口,内核自己负责把新连接负载均衡到不同进程。这种方式在不需要在进程之间共享事件状态时非常省心,但要注意SO_REUSEPORT的负载均衡是基于四元组哈希的,不是严格平均分配,如果你的业务特别依赖“连接数均衡”,可能还需要自己评估。

5.5 用 epoll_wait 超时做心跳的错误示范

很多人写 event loop 时习惯把心跳或者定期任务挂在epoll_wait的 timeout 参数上,比如“每 50 毫秒醒一次做一次定时扫描”。这种方法可以工作,但不够准确。

epoll_wait的超时是“最长等待时间”,它会受到事件到达时间的影响。如果第 49 毫秒有大量事件到达,等你处理完事件,可能已经是几百毫秒之后才回到epoll_wait继续计算,定期任务的执行间隔就被拉长了。在高并发条件下,这种误差会被放大。

更稳的做法是用timerfd创建一个定时器 fd,把它也丢进 epoll 实例里,和网络 fd 一起等待:

int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK); struct itimerspec spec; memset(&spec, 0, sizeof(spec)); spec.it_value.tv_sec = 5; // 首次到期时间 spec.it_interval.tv_sec = 5; // 后续周期性到期 timerfd_settime(tfd, 0, &spec, NULL);

这样定时器到期时 epoll 会像普通事件一样通知你,读timerfd拿到的 8 字节无符号整数代表触发的次数。定时逻辑和网络 I/O 真正统一到同一个事件循环里,不会再被其他事件处理时间干扰。

6. 一次完整的痛点定位链路:strace + ss 的实用组合

6.1 抓系统调用而不是猜业务逻辑

很多网络问题排查半天查不到根因,是因为一直在应用层打日志,却从来没有看系统调用到底做了什么。系统调用是用户态和内核态之间的唯一接口,任何 socket 超时、连接异常,最终都会现形在系统调用层。

把服务端进程用 strace 挂起来:

strace -f -e trace=network,descriptor -o /tmp/gateway.strace ./gateway

-f会跟随线程,-e trace=network,descriptor只保留网络和 fd 相关的系统调用,避免输出太庞杂。跑一段时间后,直接在日志里过滤关键词:

grep 'EAGAIN\|EPIPE\|ECONNRESET' /tmp/gateway.strace | head -50

看到EAGAIN出现的位置和频率,就能判断是“缓冲区不够导致的正常等待”还是“代码把暂时性失败当错误处理了”。看到EPIPE就能定位到是哪条写路径触发了对端已关闭的问题。

如果 strace 权限不够或者进程无法重启,另一个方案是用 gdb 断点到epoll_wait返回处,查看返回后的 fd 列表和事件类型。这个方式对现场排查更友好,但操作成本高一些,只建议在紧急情况下使用。

6.2 用 ss 观察连接队列和发送缓冲

ss是netstat的替代工具,用来观察 socket 当前状态。排查 TCP 连接问题时,我经常用两个参数组合。

查看进程持有的连接和状态:

ss -tnp

-t显示 TCP,-n不做域名解析,-p显示进程信息。这个输出能告诉你每个连接处在什么状态,是不是有大量的SYN_SENT,是不是有ESTABLISHED但收发缓冲一直卡在满值。

查看 TCP 连接内部的队列和发送缓冲:

ss -ti

这里会显示发送队列、接收队列、拥塞窗口等细节。如果某个连接的发送缓冲长期处于满值,配合 strace 里的write返回EAGAIN,就能确认是“对端读得太慢”导致背压,而不是随机网络故障。

还有一招是看 accept 队列。连接建立后,如果应用层没有及时调用accept,监听 socket 的 accept 队列会堆积。用ss -tln可以看到Send-Q和Recv-Q在这些监听 socket 上代表的是 accept 队列长度。数值长期接近最大值,说明 accept 逻辑太慢或者惊群消耗太大。

6.3 定位完成后,我一般怎么验证修复

工具只能暴露现象,真正的修复还是要回到代码。我的验证流程通常是三步走。

第一步,把可疑 socket 统一改成非阻塞,确认所有 fd 都执行了fcntl设置。第二步,把所有事件处理调整成“循环读/写直到EAGAIN”,并把写不出去的数据放入发送队列。第三步,用同等的压测工具重复之前的场景,同时挂上 strace 对比系统调用次数。

一个很直观的对比指标是系统调用次数。阻塞模式下如果经常卡在write,你看到的系统调用频率会比较低,因为每个线程都阻塞在睡眠上;改成 epoll + 非阻塞后,系统调用次数会上升,因为每次事件循环都有明确的事情要做。但这不代表性能下降,关键是看单位时间内完成的业务消息数和超时次数。

还有一个验证细节:压测时要模拟真实的背压场景,也就是让客户端先不读数据、把服务端的发送缓冲逼到满值,看看EPOLLOUT的处理逻辑是否能正确接管。很多代码在流量小的时候一切正常,一旦出现发送背压就爆出各种问题,就是因为开发阶段从来没有把缓冲区压满过。

我在这个项目的最后阶段就是用这种方式反复压了几轮,直到全场看不到write timeout日志,才敢把改动带上正式环境。后来再看那个最初的问题,本质上就是对阻塞 I/O 代价的低估。学会从系统调用层看待网络编程,很多“诡异现象”都会变得清晰。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询