做Linux服务端开发的兄弟,肯定都见过这一行英文:Resource temporarily unavailable。第一次遇到它的时候,我一度怀疑是自己代码写崩了。后来才搞明白,这其实是一个errno,数值是11,它还有个更出名的名字叫EAGAIN。在EPOLLET(边缘触发)+ 非阻塞读的组合里,EAGAIN不是来砸场子的,它是来报喜的——读到它,说明当前内核缓冲区里没有新数据了,你的读取循环可以收工了。
这篇文章会告诉你三件事:第一,这个错到底是哪来的,为什么ET模式几乎必然触发它;第二,正确的EPOLLET + 非阻塞读写法长什么样,为什么必须这么写;第三,我会给你一份可以直接编译运行的服务端+客户端完整代码,并附上实测输出,你照着敲一遍就能彻底跑通这套逻辑。适合正在学epoll、或者把LT模式换成ET模式后遇到一堆诡异问题的服务端开发同学。
1. 先搞懂:EAGAIN不是你的程序出BUG了
1.1 初见"Resource temporarily unavailable"
很多人在自己机器上第一次跑出这行报错的时候,反应基本都是:完了,代码写错了。我当年也是这么想的,那时候刚把服务端从LT模式切成ET模式,然后终端里疯狂刷Resource temporarily unavailable,整个人都麻了。
先把这个错误的名分搞清楚。在Linux的errno体系里,EAGAIN的值就是11,它的字符串表述就是Resource temporarily unavailable。更坑的一点是,EAGAIN和EWOULDBLOCK在Linux上完全等价,都是一个值,你说它是"暂时没有资源"也好,说它是"当前会阻塞"也行,本质上表达的是同一件事:现在执行这个操作的话,缓冲区里没货,或者缓冲区满了,系统没法立刻给你结果。
这个错误常出现在非阻塞socket上。当你把一个socket设置成O_NONBLOCK之后,所有的read、write、accept、connect都变成了一种"试一下"的语义:能读就读,能写就写,干不了就直接返回-1并设置EAGAIN,绝对不给你卡住。
所以EAGAIN的出现,恰恰说明你的socket非阻塞配置生效了,没有真的在那儿死等。从这个角度看,EAGAIN其实是"非阻塞机制正常工作"的信号。
1.2 从这里看懂ET模式和非阻塞读的关系
那为什么偏偏是EPOLLET模式下,EAGAIN的出现频率极高?这得从epoll的两种触发模式说起。
水平触发(Level Trigger,LT)是epoll的默认模式。它的核心逻辑是:只要fd上有数据没读完,epoll_wait就反复通知你。比如内核缓冲区里来了100字节,你只读了10字节,下一次epoll_wait照样告诉你"这个fd可读"。这种模式对阻塞读和非阻塞读都宽容,因为你不读,系统就持续提醒你,漏掉数据的概率很低。
边缘触发(Edge Trigger,ET)恰恰相反。它只在fd状态发生变化的那一刻通知一次。什么叫状态变化?就是从"没有数据"变成"有数据"的瞬间。如果这个瞬间你没有把数据全部读走,那么剩下的数据就再也不会触发新的通知了,直到下一次新的数据到来,状态再次发生一次从无到有的跳变。
这就是ET模式的天然要求:既然只通知一次,那你必须在这次通知里尽可能把数据全部读完。而"全部读完"的判断标准,就是读到返回EAGAIN。只有在ET模式下把读取循环读到EAGAIN,你才能确认内核缓冲区已经被掏空了,不会丢掉任何残留在缓冲区的数据。
所以ET和非阻塞读是强绑定的,不是锦上添花,而是必须这么做。如果你在ET模式下面用了阻塞读,事情会变得非常糟糕:第一次recv读到了部分数据,你想再读一次把剩下的读干净,结果缓冲区暂时空了,阻塞读就开始死等,而下一批数据真正到达时,因为状态已经跳变过、epoll不会再触发这个fd的事件,于是你的程序就永远停在那次阻塞recv上了。数据就算来了也只会躺在缓冲区里,没人读、没人唤醒,整个连接直接废掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 边缘触发的实现机制与读写模式
2.1 LT与ET的本质区别
我用一个生活类比来帮你看透LT和ET的区别。LT就像小区门口的快递柜,只要柜子里有包裹,它就会一直给你发短信通知,直到你把包裹全部取出。ET就像快递员的敲门声,他敲一次门,把包裹放到你门口就走了,之后不管包裹在门口放了多久,他都不会再敲第二次,只有当你下一次家里又产生了新的快递配送,他才会再敲一次门。
用同一个fd来对比,假设这个fd的内核接收缓冲区一次性收到了100字节数据。LT模式下,epoll_wait会持续返回这个fd的可读事件,直到你把这100字节全部消耗完。ET模式下,epoll_wait只会返回一次,如果这次你at只读了40字节,剩下的60字节就"失联"了,系统不会因为你没读完再通知你。下一次通知是什么时候?是再有新数据到达、可读状态重新经历一次"从无到有"的转变时。
这个机制带来的结果就是,ET模式下你必须控制好每次事件触发的处理节奏,尽量用最小次数的系统调用把一次事件能够带来的资源全部消耗完。放在读路径上,就是循环recv直到EAGAIN;放在accept路径上,就是循环accept直到EAGAIN;放在写路径上,就是不断send直到返回EAGAIN或者数据全部写完。
2.2 为什么ET模式必须配非阻塞
直接原因前面已经提到:ET模式要依靠"读到EAGAIN"来判断数据读尽了,这是唯一的哨兵信号。如果你用阻塞socket,这个哨兵就不存在了,因为阻塞socket在缓冲区为空时不会返回EAGAIN,而是直接睡在那里等数据。这一睡就出事了,因为epoll不会再触发事件,数据到达时没人来通知你,你永远不会被叫醒。
间接原因更隐蔽:ET模式本身就是为了追求高吞吐而设计的。它最大的价值是减少epoll_wait的重复通知,让你在一次事件里高效处理所有就绪的数据和连接。如果你的fd是阻塞模式,你根本没办法放心大胆地循环读,因为循环到缓冲区空的那一下会直接卡死整个进程。于是你不得不在每次recv前猜测大概该读多少,或者读一次就收手,这恰恰把ET模式"事件驱动+批处理"的优势全部丢了,用起来还提心吊胆,不如直接用LT。
另外还有一层,就是事件循环的公平性。如果阻塞fd霸占在事件处理函数里睡大觉,同一个进程里的其他连接也就跟着饿死了。非阻塞模式下,每次操作都有明确返回值,读完了就跳出循环回到epoll_wait,整个进程才能保持高响应。
所以正确姿势只有一个:监听fd也好、已连接的fd也好,全部设置O_NONBLOCK,然后在事件回调里循环读写,以EAGAIN作为一次处理的边界。
3. 正确写法的完整实现与代码解析
3.1 关键前置:socket、非阻塞与epoll初始化
先把全局设计说清楚。服务端要做的步骤是:创建监听socket、设置SO_REUSEADDR、绑定地址、监听、把监听fd设为非阻塞、创建epoll实例、把监听fd注册进epoll并且加上EPOLLET标志,最后进入事件循环。
这里有一个新手很容易漏的细节:监听fd自己也要设置为非阻塞。因为ET模式下,新连接请求可能瞬间涌进来多个,事件触发只发生一次,你必须在一次触发里循环调用accept,把所有积压的连接全收下来。如果监听fd是阻塞的,accept调用时机稍微晚一点,连接队列空了,那次accept就会卡在系统调用里,整个epoll_wait循环就停摆了。
另外,注册事件时的flag要注意加上EPOLLIN,这是最基本的读事件。如果你还要关注对端断开,可以加上EPOLLRDHUP,这样对端关闭时能立刻知道。EPOLLET是边缘触发标志,不加就默认是LT模式,代码再好看也没意义。
3.2 数据读取的正确姿势
进入正题,读数据的循环长这样:
c复制while (1) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
// 处理这 n 字节数据
continue;
} else if (n == 0) {
// 对端关闭连接
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 缓冲区数据读完了,退出循环
break;
} else if (errno == EINTR) {
// 被信号打断,继续读
continue;
} else {
// 真正的错误
close(fd);
break;
}
}
}
这段代码的逻辑千万不能乱。n大于0,说明读到了数据,处理完后继续读,因为ET模式下一次事件可能对应着多次数据到达。n等于0,说明对端已关闭,回收fd直接退出。n小于0,这时候要看errno:EAGAIN或EWOULDBLOCK,说明缓冲区的活干完了,正常退出循环;EINTR说明被信号中断了,这不是真正的错误,应该重新调一次recv;其他errno才算是真正的异常,需要主动关闭连接。
这里我不建议把EAGAIN和其他错误统一处理成break就完事。因为EAGAIN和EINTR的处理语义完全不一样,混在一起写只会给你后面排查问题埋坑。如果你在一条热路径上还关心日志开销,可以对EAGAIN不做任何记录,但EINTR我一定要建议你打一条计数或者trace,这样线上如果频繁被信号打断,你才能发现。
3.3 完整可运行代码(服务端+客户端)
我写了一个最小可运行的回显服务端和配套客户端。服务端用EPOLLET + 非阻塞读处理所有客户端连接,客户端就是简单地同步发送一条消息并等待回显,方便观测完整的收发过程。
服务端源码(server.c):
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096
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 listen_fd, epoll_fd;
struct sockaddr_in addr;
struct epoll_event ev, events[MAX_EVENTS];
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9000);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 128) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
if (set_nonblocking(listen_fd) < 0) {
perror("fcntl");
exit(EXIT_FAILURE);
}
epoll_fd = epoll_create1(0);
if (epoll_fd < 0) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) {
perror("epoll_ctl");
exit(EXIT_FAILURE);
}
printf("server listening on port 9000, epoll ET mode\n");
for (;;) {
int nready = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nready < 0) {
if (errno == EINTR) {
continue;
}
perror("epoll_wait");
break;
}
for (int i = 0; i < nready; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// 循环 accept,直到 EAGAIN,一次收完所有新连接
for (;;) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(listen_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (client_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
} else if (errno == EINTR) {
continue;
} else {
perror("accept");
break;
}
}
if (set_nonblocking(client_fd) < 0) {
perror("fcntl client");
close(client_fd);
continue;
}
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = client_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) < 0) {
perror("epoll_ctl client");
close(client_fd);
continue;
}
printf("accept new client, fd=%d\n", client_fd);
}
} else {
// 数据读取:循环 recv,直到 EAGAIN
char buf[BUFFER_SIZE];
for (;;) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
printf("recv %zd bytes from fd=%d: %.*s",
n, fd, (int)n, buf);
// 回显给客户端
send(fd, buf, n, 0);
continue;
} else if (n == 0) {
printf("client fd=%d closed\n", fd);
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("read loop end for fd=%d: EAGAIN (%s)\n",
fd, strerror(errno));
break;
} else if (errno == EINTR) {
continue;
} else {
perror("recv");
close(fd);
break;
}
}
}
}
}
}
close(epoll_fd);
close(listen_fd);
return 0;
}
客户端源码(client.c):
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
exit(EXIT_FAILURE);
}
const char *msg = "hello, ET world\n";
ssize_t n = send(fd, msg, strlen(msg), 0);
printf("client send %zd bytes\n", n);
char buf[1024];
n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
printf("client recv %zd bytes: %.*s", n, (int)n, buf);
}
close(fd);
return 0;
}
编译方式很简单,两个文件分别编译:
bash复制gcc -Wall -O2 -o server server.c
gcc -Wall -O2 -o client client.c
因为客户端消息很短,服务端一次recv就能完整读走,那么第二次recv就会返回EAGAIN,read loop结束。这正是我们在日志里想看到的信号。
4. 实测记录与运行结果分析
4.1 运行环境
我实测用的是一台Linux 5.10内核的云主机,双核四线程,系统是Ubuntu 20.04。读者自己机器上跑,只要内核版本2.6以上、支持epoll,结果基本一致。网络回环接口(loopback)上的行为和数据包在真实网卡上略有差异,但EPOLLET的语义在内核协议栈层面是统一的,不影响验证效果。
启动服务端之前,我先把终端开好,一个跑服务端,一个跑客户端。服务端进程默认在前台跑,方便我直接看日志输出。
4.2 完整输出与结果解读
启动服务端,终端输出:
code复制server listening on port 9000, epoll ET mode
然后跑客户端:
code复制client send 19 bytes
client recv 19 bytes: hello, ET world
服务端这边紧接着就打印了:
code复制accept new client, fd=8
recv 19 bytes from fd=8: hello, ET world
read loop end for fd=8: EAGAIN (Resource temporarily unavailable)
注意最后一行,read loop结束的标记就是EAGAIN。这个日志一定要出现,如果一直不出现,反而要怀疑你的读取逻辑有问题。服务端在recv成功19字节后,继续发起了第二次recv,内核缓冲区此时已经空了,由于socket是非阻塞的,recv立即返回-1、设置errno为EAGAIN,于是循环安全退出。这一整套流程验证了下面这个核心机制:
- ET事件触发一次,服务端会收到"fd可读"的通知。
- 循环recv把当前缓冲区里的19字节全部读完。
- 下一次recv遇到EAGAIN,确认缓冲区已干净,正常结束读处理。
- 数据完整读取,没有丢失,没有卡死。
我特意又做了一组大消息的测试,把客户端的发送消息改成几万字节,这样服务端一次recv肯定读不完,循环读取会多次触发recv。日志里能看到多次recv输出,然后照例以EAGAIN收尾。这组测试证明了ET模式在面对大数据包时也能依靠循环读完,不会因为"只触发一次"而丢数据。
还有一点值得提一下,我在一部分机器上跑的时候,进程偶尔会被信号打断,recv返回EINTR。程序里我写了continue来处理,所以数据不会错乱。这个问题在线上是有真实案例的,尤其是服务端接了监控系统、定时器信号特别多的时候,EINTR出现的频率会高不少。
5. 常见问题与排查技巧实录
5.1 线上最常踩的坑速查表
做一遍完整复盘,我把ET模式下最容易踩的坑整理成一张表,你写代码的时候可以逐条对照。
| 问题 | 典型现象 | 根因 | 解决办法 |
|---|---|---|---|
| 没有设置非阻塞就注册EPOLLET | 进程卡死、处理完一批数据后不再响应 | 阻塞recv卡在空缓冲区,连接无法推进 | 所有fd统一set_nonblocking |
| 读循环遇到EAGAIN就退出 | 数据读了一半、缓冲区残留数据丢失 | 没有正确区分EAGAIN和真实错误 | 只处理EAGAIN为正常退出,其他错误打日志 |
| accept只调一次 | 并发连接有一部分长时间无法建立 | ET只触发一次accept,积压连接无人处理 | accept循环直到EAGAIN |
| 监听fd没有设置非阻塞 | 高并发时accept卡死 | accept遇到连接队列空时阻塞 | 监听fd也加O_NONBLOCK |
| recv里不校验n==0 | fd泄漏、连接关闭无法感知 | 没处理对端close事件 | 检查n==0并close(fd) |
| 把EINTR当错误处理 | 偶发性丢数据或异常断开 | 被信号打断的syscall不是真正失败 | EINTR时continue重试 |
这个表里的每一条,我都真实踩过或者帮别人远程排查过。印象最深的是第二个坑,有一个项目把EAGAIN统一当作错误处理,重启之后整个服务端的吞吐量直接掉了一半,查了半天才发现是对端客户端大量短连接,服务端在读取剩余数据前就关闭了连接,导致数据丢失和重传。
5.2 我的实战心得
最后聊几句我自己的经验。
第一,写ET模式代码的时候,一定要把"边界条件"当作正常的业务逻辑来写,不要觉得处理EAGAIN是在处理异常。整条读路径上,EAGAIN的频率远高于成功读到数据的频率,因为一次epoll事件触发后,绝大多数情况下第二次recv就空了。所以你可以把EAGAIN理解成"这次处理的自然结束点",它在逻辑上和读到EOF一样正常。
第二,日志里一定要保留一条EAGAIN结束的标记。我见过不少人在线上把这条日志删掉,理由是太吵、太频繁,结果后来出问题的时候,日志里根本看不出读循环走完没有,排查效率极低。正确的做法是把这个日志降级成DEBUG级别,线下调试时候打开,线上闭环后关掉,但代码逻辑不能删。
第三,ET模式下建议把接收缓冲区设置得稍大一些。虽说循环读能保证数据安全,但缓冲区越大,一次recv能带走的数据就越多,系统调用次数越少,CPU开销越低。我一般在一个回显服务里用8KB到16KB的栈缓冲区,实测比4KB减少了不少recv调用。
第四,也是最想说的一点,当你想把LT模式改成ET模式提升性能时,不要只改一个EPOLLET标志就完事。你必须把accept和recv的读取循环全部改成"读到EAGAIN为止"的写法,否则改完不但性能不升,还会引入一堆顽固的bug。性能优化的核心不只在于"少触发几次事件",更在于"每次触发都能处理干净"。这两件事缺一不可。
