外观
面试官问 epoll 什么时候触发可读可写,你答得上来吗
序言
对于 epoll,很多人知道水平触发,边缘触发。 然而对于什么时候会触发可读,什么时候触发可写,似乎就没什么底了。
这篇文章,将水平触发、边缘触发两种模式下,所有会触发可读、可写的场景逐一进行分析。
读完后,面试官问你 epoll 可读、可写的触发场景时,你将了然于胸。
一、可读、可写的判断条件是什么?
不论是LT水平触发,还是ET边缘触发,可读和可写的触发条件都是一样的。唯一区别是 LT 水平方式,可读/可写事件会一直触发, 而 ET 边缘触发方式下,只会触发一次。
可读事件触发条件
内核接收缓冲区中,默认情况下,只要收到1字节的数据,就说明可读。LT 水平触发方式下,将会一直触发可读, ET 边缘触发方式下,只会触发一次可读。
默认收到 1 字节就触发可读,这是可以配置的。通过 setsockopt,设置 SO_RCVLOWAT 选项进行调整。如果调整为256,则表示内核接收缓冲区,收到256字节才会触发可读。
默认收到1字节就触发可读事件,目的是为了通知应用层赶紧将数据取走。不然接收缓冲区满,会给对端发送零窗口,导致对端不再发送数据;

可写事件触发条件。
可写事件触发比较特殊,并不是内核发送缓冲区只要还有空间,就会触发写事件。
内核发送缓冲区,可用空间,大致还剩余一半以上,才认为是可写的。达不到一半以上,则是不可写的。
举个例子: 内核发送缓冲区大小为100k:
① 当前已经使用了40K, 剩余空间还有60k,大于一半,会触发写事件。
② 当前已经使用了60K, 剩余空间只有40k,不到一半, 不会触发写事件。
之所以要设置一个阈值,剩余空间达到一半才会触发可写事件,是为了攒够足够的数据,避免还剩几个字节就通知你可写,写不了多少就遇到EAGAIN写失败了,频繁的通知你,增加系统调用的开销。
一旦满足可写条件,LT 水平触发方式下,将会一直触发可写。 ET 边缘触发方式下,只会触发一次可写。

内核接收缓冲区只要收到1字节就会触发可读; 内核发送缓冲区剩余可用空间在一半以上,才会触发可写。 记住这个条件,不论是LT 水平触发,还是 ET边缘触发,判断条件都是这个。
有了这个条件,我们接下来详细分析下,LT 和 ET 两种模式下,可读和可写的所有触发场景。
二、LT 水平触发方式下,可读场景
内核接收缓冲区中,默认情况下,只要收到1字节的数据,就说明可读。 LT 水平触发方式下,会一直触发可读事件。
LT 水平方式,有 5 种场景会触发可读事件:
场景1: 只要接收缓冲区有 ≥1 字节未读,持续触发可读事件。
只要内核接收缓冲区还有剩余1字节以上的数据,你不去读取,或者没有将缓冲区中的数据全部读取完成,就会持续触发可读事件。只有全部读完后,才停止触发可读事件。
举个例子,接收缓冲区大小为:100K:
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 缓冲区无数据,epoll_wait 阻塞 | 🔇 |
| T1 | 对端发来 5K | 缓冲区 5K |
| T2 | epoll_wait 返回 EPOLLIN | ✅ 触发可读 |
| T3 | 你只 read 了 2K | 缓冲区剩 3K |
| T4 | epoll_wait 立即返回 EPOLLIN | ✅ 触发可读 |
| T5 | 你 read 了 3K | 缓冲区 0 |
| T6 | epoll_wait 阻塞 | 🔇 |

场景2: 监听套接字,全连接队列有客户端连接,立马触发可读事件
每个监听套接字,都有一个全连接队列,存放的是客户端三次握手完成后的连接。调用accept时会从全连接队列中取出一个连接。
一旦全连接队列中收到一个客户端连接,就会触发可读事件。你不去读取,或者没有将全连接队列中的数据全部读取完成,就会持续触发可读事件。只有全部读完后,才停止触发可读事件
举例:
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | accept 队列空,epoll_wait 阻塞 | 🔇 |
| T1 | 3 个客户端同时连 | 队列 3 个 |
| T2 | epoll_wait 返回 EPOLLIN | ✅ 触发可读 |
| T3 | 你只 accept 了 1 个 | 队列剩 2 |
| T4 | epoll_wait 立即返回 EPOLLIN | ✅ 触发可读 |
| T5 | accept 剩下 2 个 | 队列空 |
| T6 | epoll_wait 阻塞 | 🔇 |

场景3:对端close, 收到FIN包
对端调用close,正常的执行四次挥手关闭连接。接收端会收到FIN包,POLLIN可读事件会触发,执行read 调用时会返回0,说明对端关闭了。
举例:
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 对端 close(),发来 FIN | |
| T1 | epoll_wait → EPOLLIN|EPOLLRDHUP | ✅触发读事件 |
| T2 | read() → 0 |
如果应用层不去read,则一直会触发可读事件,导致cpu 空转。
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 对端 close(),发来 FIN | |
| T1 | epoll_wait → EPOLLIN|EPOLLRDHUP | ✅触发读事件 |
| T2 | epoll_wait → EPOLLIN|EPOLLRDHUP | ✅触发读事件 |
| T3 | epoll_wait → EPOLLIN|EPOLLRDHUP | ✅触发读事件 |
场景4: 对端异常关闭,收到RST包
对端异常关闭时,会发送RST包,执行的是异常关闭过程。接收端收到RST包后,直接会进入 CLOSE 状态。
收到RST后,也是会触发可读事件的,此时你 read 去读取,会返回失败,错误码为 ECONNRESET
我们来看下,不同TCP状态下,收到RST后的错误码是什么
| TCP状态 | 错误码 |
|---|---|
| ESTABLISHED(已连接) | ECONNRESET |
| SYN_SENT(正在连接) | ECONNREFUSED |
| CLOSE_WAIT(已收到对端 FIN) | EPIPE |
| TCP_CLOSE(已经死了) | 无 |
那我们如何感知是否收到 RST:
收到 RST,一定会打上EPOLLERR | EPOLLHUP这两个标记。因此我们在epoll_wait返回后,调用getsockopt获取错误码,从而知道是否收到了RST。
if (events & (EPOLLERR | EPOLLHUP))
{
int err = 0;
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (conn->IsConnecting())
{
// 还在 connect 阶段
switch (err)
{
case ECONNREFUSED:
// 对端服务没运行起来 → 快速重连是合理的
FastReconnect(conn, 1000);
break;
case ETIMEDOUT:
// 网络问题 → 过一段时间再重连
SlowReconnect(conn);
break;
}
// 通知业务层连接失败
OnConnect(conn, false, err);
}
else
{
// 已建立的连接出错
// err == ECONNRESET → 对端异常终止
// err == ETIMEDOUT → 重传超时,对端掉线
ClientDisconnect(conn, err);
}
continue;
}如果收到RST后,还执行read调用,在将内核接收缓冲区数据全部读取完成后,read会失败,返回-1,错误码为ECONNRESET;
执行 write() 也会失败,返回-1,错误码为 ECONNRESET;再继续write,之后返回 EPIPE 错误码
场景5: 本端 shutdown(SHUT_RD) 关闭读
本端 shutdown(SHUT_RD) 关闭读,则立马会触发可读事件。此时你read照样会返回0。
shutdown(SHUT_RD) 只改了一件事:把"缓冲区空"时的返回值从 EAGAIN 变成了 0;内核接收缓冲的数据一个字节都不会丢。
如果你不read,会一直触发可读事件,直到你read为止。
对端close后,会收到FIN包,read会返回0。本端关闭读,read也返回0,那如何区分是哪种场景。
这需要我们自己区分,本端 shutdown(SHUT_RD)时,我们是知道的,可以打一个标记。然后read返回0时,根据这个标记,就可以知道是本地主动关闭读,还是收到对端的FIN包。
bool m_bLocalShutdownRd = false;
// 调用处
void ShutdownRead()
{
m_bLocalShutdownRd = true;
shutdown(fd, SHUT_RD);
}
// 读事件处理函数,read返回0时
if (nRecv == 0)
{
if (m_bLocalShutdownRd)
{
//本端主动shutdown read
}
else
{
//对端断开
OnClientDisconnect();
}
}LT 水平触发方式下, 可读事件触发场景总结:
| # | 场景 | 报吗 | 持续性 |
|---|---|---|---|
| 1 | 接收缓冲区有 ≥1 字节未读 | ✓ | 持续报,直到读干净 |
| 2 | 只读了一部分,还有剩余 | ✓ | 持续报 |
| 3 | 缓冲区读空了 | ✗ | 安静 |
| 4 | 对端 close(),收到 FIN | ✓ | 持续报,除非read。read 返回 0 |
| 5 | listen socket,accept 队列非空 | ✓ | 持续报,直到 accept 干净 |
| 6 | 对端 RST | ✓ | 持续报,带 EPOLLERR|EPOLLHUP |
| 7 | 本端 shutdown(SHUT_RD) | ✓ | 持续报,除非read。read 返回 0 |
| 8 | connect() 未完成(SYN_SENT) | ✗ | 发起连接端,触发的是写而不是读事件 |
三、LT 水平触发方式下,可写场景
内核发送缓冲区,可用空间,大致还剩余一半以上,才认为是可写的。达不到一半以上,则是不可写的。
场景1: EPOLL_CTL_ADD 将 fd 注册到epoll时
连接刚建立,整个连接为空闲连接。空闲的 TCP 连接,发送缓冲区几乎总是空的,立即会触发写事件。但前提是,调用者注册了EPOLLOUT,此时就一定会触发写事件
场景2: EPOLL_CTL_MOD 修改 注册到epoll的事件
MOD 修改注册到epoll中的事件,会主动查下当前是否可写。也就是看内核发送缓冲区剩余空间,是否还有一半以上空闲空间。如果有,则可写,会持续触发可写事件。
如果不满足,则此时不会触发可写事件。

例如: 发送缓冲区大小为100k, mod 不触发写事件。
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区已有数据60k | 发现只有40k剩余 |
| T1 | EPOLL_CTL_MOD 修改epoll事件 | ✗ 不触发写事件 |
发送缓冲区大小为100k, mod 触发写事件场景。
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区已有数据40k | |
| T1 | EPOLL_CTL_MOD 修改epoll事件 | 发现剩余60k |
| T2 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
| T3 | 你 write 了 60K | 缓冲区剩 0K |
可以看出,对于写事件,并不是发送缓冲区有空间,就一定会触发写事件。
场景3: 内核发送缓冲区还剩余一半以上的空间
只要内核的发送缓冲,剩余空间还有一半以上的空间,就会触发写事件,且一直会触发。达不到一半以上就不会触发写事件

例如: 发送缓冲区大小为100k, 只要内核发送缓冲区剩余一半以上空间,持续触发
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入10k数据 | 剩余90k,剩余一半以上 |
| T1 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
| T2 | write 在写入10k数据 | 剩余80k,剩余一半以上 |
| T3 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
例如: 发送缓冲区大小为100k, 内核发送缓冲区剩余空间不足一半空间,不触发
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入40k数据 | 剩余60k,剩余一半以上 |
| T1 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
| T2 | 继续写入20k数据, | 剩余40k,不足一半 |
| T3 | epoll_wait 不会返回 EPOLLOUT | ✗ 不触发写事件 |
例如: 发送缓冲区大小为100k, 内核发送缓冲区满后,剩余空间降到一半以上
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入100k数据,剩余0k | ✗ 不触发写事件 |
| T1 | 继续 write 返回 EAGAIN | |
| T2 | 收到 ack,腾出40k, 剩余40k,不足一半 | ✗ 不触发写事件 |
| T3 | 收到 ack,腾出20k, 剩余60k,达到一半 | |
| T3 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
缓冲区满后,不会触发写事件。只有等对端确认后,收到对端的ack,腾出一半以上的空闲空间,才会再次触发可写事件。
场景4: 非阻塞 connect() 完成
客户端非阻塞方式调用connect,连接服务器时。如何知道是否连接成功呢?
答案是监听可写事件,在可写事件发生时判断是否连接成功。
int retval = getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &optval, &optlen)
if( retval < 0)
{
//建立连接失败
}
else
{
//建立连接成功
}可写事件中,调用getsockopt判断返回值,小于0说明建连失败。为0说明建立连接成功
不管连接成功还是失败,都是会触发可写事件。

场景5: 本端 shutdown(SHUT_WR) 关闭写
本端 shutdown(SHUT_WR) 关闭写,会给对端发送FIN包,关闭写方向。此时立马会触发可写事件,且持续触发写事件。
关闭写,内核无条件报 EPOLLOUT,完全不看缓冲区水位。这是为了让后续的 write() 能立刻返回 EPIPE 失败,而不是傻等
T0 shutdown(fd, SHUT_WR)
T1 epoll_wait 返回 EPOLLOUT
T2 write发数据, 立即失败,触发EPIPE错误
T3 epoll_wait 继续返回 EPOLLOUT, 持续可写因此,shutdown(SHUT_WR) 关闭写后,优雅关闭时必须同时 EPOLL_CTL_MOD 摘掉 EPOLLOUT。不然会持续报可写
shutdown(SHUT_WR)
EPOLL_CTL_MOD 移除EPOLLOUT场景6: 对端异常关闭,收到RST包
对端异常关闭时,会发送RST包,执行的是异常关闭过程。接收端收到RST包后,直接会进入CLOSE状态。
收到RST后,之前介绍会触发可读事件,其实也还会触发可写事件。会返回失败,错误码为ECONNRESET
对LT水平触发方式下,可写事件触发场景做个总结:
| # | 场景 | 剩余空间 | 报吗 | 特点 |
|---|---|---|---|---|
| 1 | 连接刚建立、刚 EPOLL_CTL_ADD 进 epoll | 0/100K | ✓ | 持续报,每轮都报 |
| 2 | 数据全发完,缓冲区排空 | 0/100K | ✓ | 持续报 |
| 3 | 已用不到一半 | 30/100K | ✓ | 持续报 |
| 4 | 非阻塞 connect() 完成 | 0/100K | ✓ | 持续报 |
| 5 | connect() 进行中(SYN_SENT) | — | ✗ | 握手完成前不报 |
| 6 | 已用超过一半 | 70/100K | ✗ | |
| 7 | 缓冲区写满 | 100/100K | ✗ | 安静,不占 CPU |
| 8 | 满了之后 ACK 降到一半以下 | 100K→40K | ✓ | 恢复持续报 |
| 9 | EPOLL_CTL_MOD 时当前可写 | > 50K | ✓ | 持续报 |
| 10 | 对端 RST / 出错 | 任意 | ✓ | 带 EPOLLERR |
| 11 | 本端 shutdown(SHUT_WR) | 任意 | ✓ | ⚠️ 无条件报 |
四、ET 边缘触发方式下,可读场景
内核接收缓冲区中,默认情况下,只要收到1字节的数据,就说明可读, ET 边缘触发方式下,只会触发一次可读事件。
因此默认情况下,每一个到达的数据包都产生一次边沿,触发一次可读事件
ET 边缘方式,有 7 种场景会触发可读事件:
场景1:每收到一个数据包都产生一次边沿,触发一次可读事件
只要接收到数据,就会触发一次可读,且只会触发一次。
read时需要一次性全部读取完。
如果一次性不读取完全部数据,剩余部分不会再触发可读事件。这和LT是不一样的,LT是会一直触发。
剩余部分需要等下一次收到数据时,再产生一个边缘,才会触发可读事件

| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 接收缓冲区 0 | |
| T1 | 对端发来 5K | 缓冲区 5K |
| T2 | epoll_wait → EPOLLIN | ✅ 触发读事件 |
| T3 | 你只 read 了 2K 就返回 | 缓冲区剩 3K |
| T4 | epoll_wait 阻塞 | 🔇 那 3K 还在缓冲区 |
| T5 | 对端又发来 4K | 缓冲区 3K + 4K = 7K |
| T6 | epoll_wait → EPOLLIN | ✅ 触发读事件 |
| T7 | 这次循环读到 EAGAIN,读完 7k |
T4 到 T6 之间那段时间,3K 数据还在内核的接收缓冲区里。需要等对端再次发新数据,产生新边缘,才会触发可读。
所以 ET 漏读的表现往往不是"彻底不工作",而是延迟诡异地抖动。低负载时卡,高负载时反而正常(因为新包不断制造边沿)。
场景2:每收到一个客户端连接,触发一次可读事件
每个监听套接字,都有一个全连接队列,存放的是客户端三次握手后的连接。调用accept时会从全连接队列中取出一个连接。
一旦全连接队列中收到一个客户端连接,就会触发可读事件。收到一个连接就触发一次读事件。
因此,需要你循环 accept 将连接全部取出。
如果没有将全连接队列中的数据全部读取完成,就不会再触发可读事件。
剩余部分需要等下一次收到新客户端连接时,再产生一个边缘,才会触发可读事件
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | accept 全连接队列空 | |
| T1 | 3 个客户端几乎同时连上 | 队列 3 个 |
| T2 | epoll_wait 返回一次 EPOLLIN | ✅ 触发读事件 |
| T3 | 你只 accept(fd) 一次 | 队列剩 2 |
| T4 | epoll_wait 阻塞 | 🔇 2 个客户端被阻塞 |
| T5 | 第 4 个客户端连上 | 新边沿 |
| T6 | epoll_wait → EPOLLIN | ✅ 触发读事件 |
| T7 | accept 3 个客户端 |
表现:客户端 A 连上后一直没反应,直到客户端 B 也来连,A 才突然活了。

场景3:对端close, 收到FIN包
对端调用close,正常的执行四次挥手关闭连接,接收端会收到FIN包。此时POLLIN可读事件会触发。
执行read调用时会收到0,说明对端关闭了。
如果应用层不去read,则不会再触发可读事件。读事件只会触发一次
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 对端 close(),发来 FIN | |
| T1 | epoll_wait → EPOLLIN|EPOLLRDHUP | ✅触发读事件 |
| T2 | read() → 0 |
读事件触发,不去read,则不会再触发读事件了。而LT 是每轮都触发读事件,每轮 read 返回 0 → CPU 100%
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 对端 close(),发来 FIN | |
| T1 | epoll_wait → EPOLLIN|EPOLLRDHUP | ✅触发读事件 |
| T2 | 不去read,不会再触发,epoll_wait 也照常阻塞 | 🔇 |
场景4:对端异常关闭,收到RST包。
对端异常关闭时,会发送RST包,执行的是异常关闭过程。接收端收到RST包后,直接会进入CLOSE状态。
收到RST后,也是会触发可读事件的,会返回EPOLLIN|EPOLLERR|EPOLLHUP 几种 epoll mask。
此时你read去读取,会返回失败,错误码为ECONNRESET
场景5: EPOLL_CTL_MOD 修改 注册到epoll的事件
MOD 修改注册到epoll中的事件,会主动检查当前是否可读。也就是看内核接收缓冲区是否有数据,只要还有一字节以上的数据,就是可读,触发读事件。相当于人为制造了一次边缘
如果不满足,则此时不会触发可读事件。
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 收到了40k数据 | |
| T1 | epoll_wait → EPOLLIN | ✅ 触发读事件 |
| T2 | 你只 read 了 20K 就返回 | 缓冲区剩 20K |
| T3 | 剩余20k,不再触发读事件 | |
| T4 | epoll_ctl(MOD) | 未读 20K ≥ 1 → 可读 |
| T5 | epoll_wait 立即返回 EPOLLIN | ✅ 触发读事件 |
| T6 | read 读取剩余20K |
因为接收侧的门槛低到 1 字节,只要还有未读数据,EPOLL_CTL_MOD 就必然触发可读事件。

但别把 MOD 当常规手段。它能救场,不代表该这么用:
① 白白多一次系统调用
epoll_ctl 不是免费的。每处理一次事件多一次 MOD,高并发下是实打实的开销。
② 治标不治本
你 MOD 一次读 20K,剩 20K;再 MOD 一次读 20K,等于把 ET 退化成了一个每轮还要额外调一次 epoll_ctl 的、比 LT 更慢的 LT。 ET 的全部性能优势就是这么被抵消掉的。
③ 掩盖真问题
需要靠 MOD 才能把数据读完,说明读循环本身写错了。
场景6: EPOLL_CTL_ADD 将 fd 注册到epoll时
连接刚建立,使用 EPOLL_CTL_ADD 将 fd 注册到 epoll。如果此时内核接收缓冲区提前收到了客户端的数据,此时也是会立即触发可读事件。且只会触发一次。相当于人为制造了一次边缘
场景7: 本端 shutdown(SHUT_RD) 关闭读
本端 shutdown(SHUT_RD) 关闭读,则立马会触发可读事件。此时你read照样会返回0。
如果你不read,就不会再触发可读事件
对端close后,会收到FIN包,read会返回0。本端关闭读,read也返回0,那如何区分是哪种场景。
这需要我们自己区分,本端 shutdown(SHUT_RD)时,我们是知道的,可以打一个标记。然后read返回0时,根据这个标记,就可以知道是本地主动关闭读还是收到对端的FIN包。
ET 边缘触发方式下,可读事件触发场景总结:
| # | 边沿 | 频率 |
|---|---|---|
| 1 | EPOLL_CTL_ADD 时缓冲区已有数据 | 一次性 |
| 2 | EPOLL_CTL_MOD 且当前可读 | 每次 MOD |
| 3 | 新数据到达 | 收到一次触发一次 |
| 4 | 新连接进入 accept 队列 | 收到一次触发一次 |
| 5 | 收到 FIN | 一次 |
| 6 | RST / 出错 | 一次 |
| 7 | 本端 shutdown(SHUT_RD) | 一次 |
五、ET 边缘触发方式下,可写场景
ET边缘触发方式下,可写必须满足这两个条件:
内核发送缓冲区,可用空间,大致还剩余一半以上,才认为是初步可写的。达不到一半以上,则是不可写的。
必须先写满过一次(
write返回EAGAIN),后续 ACK 释放空间到水位以上,才会给你一个 EPOLLOUT 边沿。
场景1: EPOLL_CTL_ADD 将 fd 注册到epoll时
连接刚建立,整个连接为空闲连接。空闲的 TCP 连接,发送缓冲区几乎总是空的,立即会触发写事件。但前提是,调用者注册了EPOLLOUT,此时才一定会触发写事件。相当于制造了一次边缘
场景2: EPOLL_CTL_MOD 修改 注册到epoll的事件
MOD 修改注册到epoll中的事件,会主动查下当前是否可写。也就是看内核发送缓冲区剩余空间,是否还有一半以上空闲空间。如果是,则可写,会触发一次可写事件,也只会触发一次。相当于制造了一次边缘
如果不满足,则此时不会触发可写事件。

例如: 发送缓冲区大小为100k, mod 不触发写事件
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区已有数据60k | |
| T1 | EPOLL_CTL_MOD 修改epoll事件, 发现只有40k剩余 | ❌ 不触发写事件 |
例如: 发送缓冲区大小为100k, mod 触发写事件
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区已有数据40k | |
| T1 | EPOLL_CTL_MOD 修改epoll事件 | 发现剩余60k |
| T2 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
| T3 | 你 write 了 60K | 缓冲区剩 0K |
可以看出,对于写事件,并不是发送缓冲区有空间,就一定会触发写事件。
场景3: 内核发送缓冲区还剩余一半以上的空间
只要内核的发送缓冲,剩余空间还有一半以上的空间,就会触发写事件,且只触发一次。
达不到一半以上就不会触发写事件

例如: 发送缓冲区大小为100k, 只要内核发送缓冲区剩余一半以上空间,只触发一次
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入10k数据 | 剩余90k,,剩余一半以上 |
| T1 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
| T2 | write 再写入10k数据,剩余80k,剩余一半以上 | ❌ 不触发写事件 |
| T3 | write 再写入10k数据,剩余70k,剩余一半以上 | ❌ 不触发写事件 |
例如: 发送缓冲区大小为100k, 内核发送缓冲区剩余空间不足一半空间,不触发
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入40k数据 | 剩余60k,剩余一半以上 |
| T1 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
| T2 | 继续写入20k数据, 剩余40k,不足一半 | ❌ 不触发写事件 |
例如: 发送缓冲区大小为100k, 内核发送缓冲区满后,剩余空间降到一半以上
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入100k数据, 剩余0k | ❌ 不触发写事件 |
| T1 | 继续 write 返回 EAGAIN | |
| T2 | 收到 ack,腾出40k, 剩余40k,不足一半 | ❌ 不触发写事件 |
| T3 | 收到 ack,腾出20k, 剩余60k,达到一半 | |
| T4 | epoll_wait 返回 EPOLLOUT | ✅ 触发写事件 |
还有一种场景,也是很多人有疑问的。如果发送缓冲区剩余空间从不到一半,到超过一半。如果没有产生过边缘,还是不会触发可写事件。

例如: 发送缓冲区大小为100k,
| 时序 | 步骤 | 结果 |
|---|---|---|
| T0 | 发送缓冲区写入10k数据, 剩余90k | ❌ 不触发写事件 |
| T1 | write 再写入50k数据,剩余40k,不足一半 | ❌ 不触发写事件 |
| T2 | 收到 ack,腾出20k, 剩余60k,达到一半 | ❌ 不触发写事件 |
T2 时刻,对端确认了,腾出了空间,剩余空间都在一半以上,还是不会触发可写事件。
这是因为,没满足这个条件:
必须先写满过一次(write 返回 EAGAIN),后续 ACK 释放空间到水位以上,才会给你一个 EPOLLOUT 边沿。
而这个场景下,自始至终内核发送缓冲区都是空闲的,没写满过,也就不会有边缘。
场景4: 非阻塞 connect() 完成
客户端非阻塞方式调用connect,连接服务器时。如何知道是否连接成功呢? 答案是监听可写事件,在可写事件发生时判断是否连接成功。
int retval = getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &optval, &optlen)
if( retval < 0)
{
//建立连接失败
}
else
{
//建立连接成功
}可写事件中,调用getsockopt判断返回值,小于0说明建连失败。为0说明建立连接成功
因此,不管连接成功还是失败,也是会触发可写事件。

场景5: 本端 shutdown(SHUT_WR) 关闭写
本端 shutdown(SHUT_WR) 关闭写,会给对端发送FIN包,关闭写方向。此时立马会触发可写事件,且只触发一次写事件。
关闭写,内核无条件报 EPOLLOUT,完全不看缓冲区水位。这是为了让后续的 write() 能立刻返回 EPIPE 失败,而不是傻等
T0 shutdown(fd, SHUT_WR)
T1 epoll_wait 返回 EPOLLOUT
T2 write发数据, 立即失败,触发EPIPE错误边缘触发方式下,调用shutdown(SHUT_WR) 只会触发一次可写事件。
场景6: 对端异常关闭,收到RST包
对端异常关闭时,会发送RST包,执行的是异常关闭过程。接收端收到RST包后,直接会进入CLOSE状态。
收到RST后,之前介绍会触发可读事件,其实也还会触发可写事件。会返回失败,错误码为ECONNRESET
对ET边缘触发方式下,可写事件触发场景做个总结:
| # | 边沿 | 说明 |
|---|---|---|
| 1 | EPOLL_CTL_ADD 时当前就可写 | 触发一次写事件 |
| 2 | EPOLL_CTL_MOD | 剩余一半空间,触发可写,人造边沿 |
| 3 | 非阻塞 connect() 完成 | 从"未连接不可写"翻转到"已连接可写" |
| 4 | 已用不到一半 | 触发一次写 |
| 5 | 写满后 ACK 回来腾出一半空间 | 触发一次写 |
| 6 | 对端 RST / 出错 | 带 EPOLLERR 触发一次写事件 |
| 7 | 对端close 收到FIN | 触发一次写 |
| 8 | 本端 shutdown(SHUT_WR) | 无条件触发写事件 |
六、写在最后
读完文章,我想你对 epoll LT 水平触发、ET 边缘触发种方式下,所有触发可读、可写的场景已经有所了解了。当你在看网络相关的源码时,也就能理解人家为什么那么写。
我尽量站在读者的角度把来龙去脉讲清楚了,但每个人理解不一样。有任何疑问,可以在评论区里讨论,也可以联系作者。
如何联系作者
yudongtc 这个博客每周更新一篇高并发干货,图解方式讲解,需要可以浏览器访问。
关注高并发成长记,加入linux网络编程交流群,一起讨论。