资讯动态

epoll边沿触发(ET)只读一次?数据丢失和中文乱码的根源与正确姿势

发布时间:2026/10/9 2:48:09 来源:尧图企业网站定制
先说个真实场景前阵子一个做网关的同事来找我说线上服务在压测时偶尔出现“数据少了”“中文乱码”他把核心链路翻了个底朝天最后定位到 epoll 的边沿模式ET上——ET 模式下事件只通知一次而他的 recv 只调用了一次导致数据滞留在内核缓冲区里后面来的数据也不会再触发可读事件。更恶心的是滞留的字节如果正好切断一个 UTF-8 中文字符终端上就会出现那种让人抓狂的乱码。这个坑太典型了。很多同学对 epoll 的 LT 模式水平触发用得顺手一换 ET 就出事面试被问到“ET 和 LT 的区别”能背得很好但真到写代码时又踩回同一个坑。这篇文章就把 ET 模式掰开揉碎先讲清楚它和 LT 的内核行为差异再用一个最小可复现的 demo 演示“数据蒸发”和“中文乱码”是怎么发生的最后给出一份可直接抄作业的 C 代码骨架。搞网络编程的服务端开发者、正在准备面试的人以及所有被“et 乱码”折磨过的朋友这篇都值得看完。1. ET 到底和 LT 差在哪一次通知 vs 反复通知1.1 电平与边沿两种触发语义的底层逻辑先说名字。LT 叫 Level Triggered水平触发ET 叫 Edge Triggered边沿触发。这俩词是从电子学里借来的理解成“电平触发”和“边沿触发”就好。水平触发的意思是只要这个 fd 处于“可读/可写”的状态epoll_wait 就会一直返回它。比如客户端发来 10KB 数据你用 recv 一次只读走 4KB剩下 6KB 还在内核缓冲区那下一次 epoll_wait 还是会立刻告诉你“这个 fd 可读”直到你把缓冲区里的数据全部读干净、fd 变得不可读为止。边沿触发则完全不同它只关心“状态发生变化”的那一瞬间。从“没有数据可读”跳变到“有数据可读”时epoll_wait 会通知你一次如果你没把数据读完fd 仍然处于可读状态但内核不会因为这个状态再次通知你。只有等以后某个时刻这个 fd 又从“不可读”变成“可读”了才会产生新的一次通知。用一个生活化的比喻LT 是一个装满水的杯子水满的时候它一直“叫”你倒掉一点它还在叫直到你把水全部喝光ET 是一道门你推门进去时门铃响一次进了门你在里面待多久、拿多少东西门铃都不会再响第二次除非你出去再重新推一次门。对代码而言注册方式就一行差别ev.events EPOLLIN; // LT 模式默认 ev.events EPOLLIN | EPOLLET; // ET 模式把 EPOLLET 加进 events 字段这个 fd 就变成边沿触发了。就这么简单但后续所有行为习惯都得跟着改。1.2 ET 模式下的铁律循环 read 直到 EAGAINET 模式最核心的一条操作铁律是当 epoll_wait 告诉你这个 fd 可读时你必须在一个循环里反复调用 read/recv直到返回值是 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK才算把这次边沿带来的数据“消化干净”。为什么必须这么做因为 TCP 是一个字节流协议没有消息边界。recv 一次最多返回当前内核缓冲区里可读的数据这些数据可能只是对方发送内容的很小一部分。如果你在一个 ET 通知里只 recv 了一次剩下的数据就全部滞留在内核缓冲区里而 ET 又不会因为“仍然可读”再通知你。于是这批数据就可能永远没机会被应用层处理直到对方再发新数据、且这个 fd 经历一次“不可读 → 可读”的状态翻转。这里还有一个隐藏点ET 模式下最好配合非阻塞 socket 使用。因为循环读取的退出条件是“读不到数据了”如果是阻塞 socket最后一次 recv 会因为缓冲区为空而卡住整个进程。非阻塞 socket 在无数据时立刻返回 -1 和 EAGAIN循环才能顺畅退出。这也是很多新手在 ET 模式下手忙脚乱的原因——忘了 set nonblock程序在读完数据后直接 hang 住。1.3 高效背后的代价责任从内核转移到应用ET 模式为什么被认为更高效想象一个高并发场景某个 fd 一直有大量数据涌入。LT 模式下只要缓冲区没读完每次 epoll_wait 都会返回它这意味着应用可能反复被唤醒、反复做检查但每次只能处理一部分ET 模式下内核在状态翻转时只通知一次应用在一个循环里把能读的都读走减少了无效唤醒、减少了系统调用次数、降低了 CPU 消耗。听起来很美但代价是内核把“缓冲管理”的责任转交给了应用。内核只负责“翻转的时候告诉你一声”至于你能不能接住这一波数据、会不会把数据拼错、缓冲会不会溢出那都是应用自己的事。很多 ET 翻车现场根子就在“应用没有接住这个责任”——要么没读完要么读完了但拼错了。选择建议上我的经验是简单的聊天服务、低频小报文转发、对延迟不敏感的场景LT 完全够用没必要为了“秀技术”上 ET而网关、代理、大规模推送这类高吞吐、大消息、频繁大读的场景ET 才能真正体现价值。前提是你愿意为它写出更小心、更完整的缓冲逻辑。2. 现象演示只读一次的 ET 服务器有多离谱2.1 复现环境准备一个故意踩坑的 ET 服务器理论说得再多不如亲眼看到现象。我写了一个故意使用 ET 模式、但“只读一次”的坏例子服务器bad_et_server用来复现数据丢失和乱码。你可以直接编译跑一下。// bad_et_server.c —— 故意在 ET 模式下只 read 一次 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #define PORT 9999 #define MAX_EVENTS 64 #define BUF_SIZE 1024 static int set_nonblock(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 lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); return 1; } int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(lfd, 128) 0) { perror(listen); return 1; } set_nonblock(lfd); int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN | EPOLLET; // 注意这是 ET ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); struct epoll_event events[MAX_EVENTS]; char buf[BUF_SIZE]; printf(bad_et_server running on port %d\n, PORT); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { if (events[i].data.fd lfd) { // accept 循环也要处理到 EAGAIN while (1) { int cfd accept(lfd, NULL, NULL); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } set_nonblock(cfd); ev.events EPOLLIN | EPOLLET; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); printf(accept fd%d\n, cfd); } } else { int fd events[i].data.fd; // 这里就是经典错误只 recv 一次不循环到 EAGAIN ssize_t rn recv(fd, buf, sizeof(buf), 0); if (rn 0) { printf(recv fd%d n%zd data[%.*s]\n, fd, rn, (int)rn, buf); // 没有循环读取其余数据留在内核缓冲区ET 下不会再通知 } else if (rn 0) { printf(close fd%d\n, fd); close(fd); } else { if (errno EAGAIN || errno EWOULDBLOCK) continue; perror(recv); close(fd); } } } } }编译命令很简单gcc -Wall -O2 -o bad_et bad_et_server.c ./bad_et然后写一个 Python 模拟客户端方便精确控制“什么时候发数据、发多少”import socket import time s socket.create_connection((127.0.0.1, 9999)) s.sendall(bA * 5000) # 一次发大块数据 time.sleep(2) # 给服务端时间处理 s.sendall(bB * 100) # 隔一会再发一小段 time.sleep(2) s.close()运行起来之后你会看到 bad_et_server 只打印一次 recv而且只打印了 1024 个 A。剩下的数据去哪了没丢但也没人去读。它们在 fd 关闭前一直躺在内核缓冲区里等 Python 客户端 close 的时候整个 fd 被回收这些数据就真的“蒸发”了。2.2 演示二后续数据到了但 ET 不再通知上面那个脚本更值得玩味的是第二次发送的 100 个 B。按你的直觉服务端 epoll_wait 应该会再次触发“fd 可读”吧并不会。原因往回看第一次 recv 读了 1024 字节内核缓冲区里还剩 3976 字节所以这个 fd 始终处于“可读”状态。它从来没有回到“不可读”状态自然也就没有发生新的“不可读 → 可读”跳变。第二个 100 字节虽然到达了但它们是追加到本来就有一堆数据的内核缓冲区里的并没有制造一个干净的边沿。于是内核直接把这次数据当成“现状的延续”不通知。这个现象非常迷惑人尤其在线上一旦出现很容易被误判成“网络丢了包”或者“客户端没发出来”。实际上数据就在内核里躺着是你应用层漏读了。2.3 演示三中文瞬间变成乱码数据丢失已经够难受了乱码更让人头大。把客户端改成发一段中文import socket import time s socket.create_connection((127.0.0.1, 9999)) msg (你好\n * 300).encode(utf-8) # 一段带换行的中文文本 s.sendall(msg) time.sleep(2) s.sendall(再见\n.encode(utf-8)) # 再发一小段中文 time.sleep(2) s.close()运行 bad_et_server你会看到打印结果末尾出现一堆 replacement character通常显示为 或者稀碎的字符。原因很简单BUF_SIZE 是 1024而 “你好\n” 这个字符串在 UTF-8 下是 7 个字节1024 7 × 146 2。也就是说这次 recv 从某个完整的汉字中间切了一刀切下来了一个字的前两个字节或者后两个字节。终端接收到不完整的 UTF-8 字节序列无法解码输出就是乱码。注意这个乱码甚至比 LT 模式下更容易出现。LT 模式下你虽然也可能偷懒只 read 一次但 epoll_wait 会反复通知你总有机会把剩余的数据补读上来而 ET 模式下读不完就是读不完缓冲区里那个被切了一半的字符可能永远等不到后半截。3. 乱码的根因字节不是字符read 没有消息边界3.1 UTF-8 的字节结构为什么“切一刀”就乱要理解 ET 下的乱码得先明白 UTF-8 编码的一个基本事实一个“字符”对应的不是一个字节而是一个 1 到 4 字节的变长序列。中文汉字在基本平面内通常占 3 个字节比如“你”编码成十六进制是 E4 BD A0三个字节共同构成一个完整的码点。终端解码时必须把这三个字节凑齐才能渲染出“你”这个字。如果你把字节流从 E4 BD 后面切断第一段只有 E4 BD终端拿到的是一个残缺的序列不知道后面还有没有字节补充通常会显示为 第二段以 A0 开头也不是一个合法的 UTF-8 起始字节同样显示为乱码。所谓“乱码”很多时候就是“字节流被拦腰截断”或“多个字符的字节被错误拼接”造成的。这里容易产生一个误区很多人觉得乱码是终端编码没设置对或者 locale 有问题。这没错但只是其中一个环节。在 epoll 这个场景下乱码的根因是应用层把“字节流”硬生生按照 recv 返回的块边界切开了而没有按照“完整字符”或“完整消息”来消费。read/recv 返回的块边界是内核决定的跟字符边界没有任何关系。3.2 TCP 字节流没有消息边界recv 不是 read message进一步说TCP 是流协议不是报文协议。你 send 一条 100 字节的消息对端 recv 可能一次收到 100 字节也可能一次只收到 50 字节、下一次再收到 30 字节、再下一次 20 字节反过来如果对方连续 send 好几条消息对端 recv 一次也可能把多条消息一起打包带走。这取决于内核缓冲区空闲程度、TCP 窗口大小、Nagle 算法、网络拥塞、本机调度总之没有任何保证。所以把 recv 的一次返回值当作“一条完整消息”从协议设计上就是错的。尤其在 ET 模式下一次事件触发后你必须在一个循环里反复 recv而 recv 每次返回的长度更是千变万化。如果你拿到一块字节就直接按字符串输出、直接拼进结果那就等于把“流的拆分”完全交给了运气。专业的收包思路是在内核读出来的字节流之上再建一层应用缓冲区。先把 recv 回来的一块块原始字节全部追加到这块应用缓冲区里再根据你定义的消息边界比如换行符、长度前缀从缓冲区里“切”出完整消息去处理。没切够半条消息的残余就留到下一次事件触发时继续累积。这一步是解决 ET 乱码的关键也是后面实战代码的核心逻辑。3.3 ET 如何把“普通拆包”放大成“必现乱码”LT 模式下如果应用处理得不够干净偶尔也会出现半个字符但因为 epoll_wait 会反复通知同一个 fd你迟早会把数据补全乱码相对隐蔽和随机。ET 模式把这个缺陷放大了一次通知只给你一次“机会窗口”读不完整剩下的就等着被下一次边沿“临幸”。而当下一次边沿真的来临时滞留在内核缓冲区的旧数据和刚到达的新数据会黏在一起旧数据尾部那个残缺字符会和新数据头部拼成一个“看起来合法”但实际错误的 UTF-8 序列。还有一类更隐蔽的情况出现在多线程共享同一个 fd 读的场景。有些开发者为了提升吞吐让多个工作线程同时对一个连接 recv。在 ET 模式下一个字符的 3 个字节可能分别被两个线程读走每个线程手里的字节序列都不完整各自单独解码必然乱码。这种问题本质上不是 epoll 的错但它因 ET 而加剧因为 ET 鼓励“在一个事件里尽快读光”多线程抢读会让竞争更加混乱。其实不光是 epoll。你在各种地方看到的乱码问题本质上都是一回事printf 中文乱码、vscode 注释乱码、PowerShell 乱码、解压文件后文件名乱码、串口打印乱码……要么是字节序列在某处被错误切割要么是终端/工具用错了编码去解码。在 epoll 这里错误的切割点就是“recv 的返回边界”错误的解码行为是“把字节块直接当字符串”。理解了这一层很多乱码问题都会迎刃而解。4. 实战代码一个不乱读、不乱码的 ET 回显服务器4.1 设计思路非阻塞 循环读 应用层缓冲现在给出正确的 ET 实战姿势。核心原则就三条fd 必须非阻塞每个事件触发后循环 recv 直到 EAGAIN读出来的原始字节先进应用层缓冲再按消息边界消费。为了演示效果协议就用最简单的“换行分隔文本消息”客户端发送的每一行文本以 \n 结尾服务端按 \n 做消息切分输出并原样回显。这个协议足够清晰也方便验证乱码是否还存在。生产环境如果追求更严谨的二进制协议建议使用“固定长度头 负载”的消息格式原理完全一样。连接管理上我用一个全局结构体数组来存放每个连接的应用层缓冲区数组下标直接用 fd简化代码。真实项目里建议用 hash 或 connection 结构体指针来管理否则高并发下 fd 复用会出问题。4.2 关键步骤拆解从注册到消费第一步设置非阻塞。listen fd 和 accept 回来的连接 fd 都要设置 O_NONBLOCK。accept 循环里拿到新连接后记得立刻 set_nonblock再加进 epoll。事件注册时加上 EPOLLET 标志。第二步处理读事件时用 while 循环。每次 recv 到正数就追加到该连接的应用层缓冲区recv 返回 0 说明对方关闭返回 -1 且 errno 是 EAGAIN 时说明这个边沿可读的数据已经读完了退出读循环。这里最忌讳的错误就是“只 recv 一次”一定要把循环写到 EAGAIN 才肯停。第三步消费缓冲。在 while 循环结束后从应用缓冲区里按 \n 切分完整行处理完后用 memmove 把剩余数据搬到缓冲头部保留给下一次事件用。第四步注意缓冲区是否满了。如果应用缓冲区已满却还没有找到一条完整消息大概率是单条消息超长或对方在灌洪水此时应该主动断开连接而不是让缓冲区无限膨胀。这是 ET 下很多人忽视的兜底逻辑必须加上。4.3 完整代码可以编译运行的 decent_et_server// decent_et_server.c —— 正确姿势ET 非阻塞 循环读 应用层缓冲 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #define PORT 9999 #define MAX_EVENTS 64 #define MAX_CONN 1024 #define BUF_SIZE 4096 #define APP_BUF_SIZE 8192 typedef struct conn { char buf[APP_BUF_SIZE]; size_t len; /* buf 中有效数据的长度 */ } conn_t; static conn_t conns[MAX_CONN]; static int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static void close_conn(int fd) { conns[fd].len 0; close(fd); printf(close fd%d\n, fd); } int main(void) { int lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); return 1; } int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(lfd, 128) 0) { perror(listen); return 1; } set_nonblock(lfd); int epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return 1; } struct epoll_event ev; ev.events EPOLLIN | EPOLLET; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); struct epoll_event events[MAX_EVENTS]; char recvbuf[BUF_SIZE]; printf(decent_et_server running on port %d\n, PORT); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); 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 (fd lfd) { /* 新连接accept 循环也要读到 EAGAIN */ while (1) { int cfd accept(lfd, NULL, NULL); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } if (cfd MAX_CONN) { printf(too many conns, close fd%d\n, cfd); close(cfd); continue; } set_nonblock(cfd); conns[cfd].len 0; ev.events EPOLLIN | EPOLLET; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); printf(accept fd%d\n, cfd); } continue; } conn_t *c conns[fd]; int closed 0; /* 核心循环读直到 EAGAIN */ while (1) { ssize_t rn recv(fd, recvbuf, sizeof(recvbuf), 0); if (rn 0) { if (c-len (size_t)rn APP_BUF_SIZE) { printf(fd%d app buf overflow, close\n, fd); close_conn(fd); closed 1; break; } memcpy(c-buf c-len, recvbuf, (size_t)rn); c-len (size_t)rn; } else if (rn 0) { /* 对端关闭这时可以不消费遗留的半个消息 */ close_conn(fd); closed 1; break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; /* 本次边沿的数据已经读光 */ } if (errno EINTR) continue; perror(recv); close_conn(fd); closed 1; break; } } if (closed) continue; /* 按 \n 切分完整的行 */ size_t start 0; for (size_t i 0; i c-len; i) { if (c-buf[i] \n) { size_t line_len i - start; if (line_len 0) { printf(fd%d line: %.*s\n, fd, (int)line_len, c-buf start); /* 回显给客户端传入的字符串不包含换行这里补一个 */ send(fd, c-buf start, line_len, 0); send(fd, \n, 1, 0); } start i 1; } } /* 把未消费的余留数据移到缓冲头部 */ if (start 0) { size_t remain c-len - start; if (remain 0) { memmove(c-buf, c-buf start, remain); } c-len remain; } } } close(lfd); return 0; }我把关键点再强调一遍看那个内层 while 循环recv 的正数返回值只是“这一轮从内核拿到的字节数”不代表对方的一条消息结束了。只有读到 EAGAIN确认内核缓冲区里暂时没有更多数据这次边沿才算处理完。然后才轮到按行切分消费的环节。send 回显这里我做了简化假设回显数据量不大、不会因为发送缓冲区满而阻塞。真实项目中如果一条消息很大、或者要批量发数据不能直接在读路径里塞 send而应该把要发送的数据挂到连接的发送缓冲注册 EPOLLOUT 事件等可写时再发。这个点下面单独说。4.4 编译与验证中文多行、连续发送都不乱gcc -Wall -O2 -o decent_et decent_et_server.c ./decent_et用同样的 Python 客户端脚本验证import socket import time s socket.create_connection((127.0.0.1, 9999)) msg (你好\n * 300).encode(utf-8) s.sendall(msg) time.sleep(1) s.sendall(再见\n.encode(utf-8)) time.sleep(1) s.close()这时 decent_et_server 会连续输出 300 行“你好”和一行“再见”每一行都是完整的中文终端不会出现 乱码。因为应用层缓冲按 \n 边界切分每一条消息拿到的都是完整字符串。我实测时还特意把 300 行换成一段很长的无换行中文文本配合 EAGAIN 循环一样不会乱——长文本会在 app 缓冲里累积成完整消息后原样透传不会分裂字符。对比一下 bad_et_server 和 decent_et_server 的输出你就能直观理解数据丢失不是网络丢的乱码不是终端闹鬼纯粹是 ET 模式下“读一次没读完”和“没有应用层缓冲”导致的。4.5 EPOLLOUT 在 ET 下同样要按照边沿思维处理上面代码把读方向处理干净了但很多场景下服务器的写方向同样重要。ET 模式下 EPOLLOUT 的触发时机是fd 从“不可写”变为“可写”的时候通知一次。什么叫不可写发送缓冲区满了再 send 会阻塞或返回 EAGAIN。什么叫可写发送缓冲区有空余位置了能继续写数据了。所以 ET 模式下如果要发大量数据正确套路是先尝试直接 send如果一次性发不完、返回了 EAGAIN再把 EPOLLOUT 注册到 epoll 里等 epoll_wait 报告这个 fd 可写时再继续发剩余的发送缓冲数据发完之后立刻把 EPOLLOUT 从事件里摘掉避免每次 notify 都因为“仍然可写”而忙等。这个“用完即摘”的原则和读方向的“循环读到 EAGAIN”是对称的一个管进入一个管流出。顺手给一段最关键的逻辑骨架/* EPOLLOUT 注册/摘除骨架 */ ev.events EPOLLIN | EPOLLET | EPOLLOUT; /* 需要写时注册 */ epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev); /* 写完成之后立刻摘掉 EPOLLOUT */ ev.events EPOLLIN | EPOLLET; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev);如果漏了这步ET 的 EPOLLOUT 会像一颗定时炸弹某些情况下 epoll_wait 会疯狂返回“可写”把 CPU 打满。写方向的坑比读方向更隐蔽这篇文章主要聚焦读和乱码但这条铁律我建议你记在笔记里。5. 常见问题速查与调试三板斧5.1 症状速查表一眼定位 ET 事故症状可能根因解决方向收到的数据量明显少一截ET 下只 recv 一次剩余数据滞留循环 recv 直到 EAGAIN明明又发了新数据服务端不触发fd 从未回到“不可读”无新边沿检查是否读光缓冲配合 strace 确认中文或 UTF-8 内容出现 乱码字节流被 recv 块边界切断应用层缓冲 按消息边界消费数据延迟很久后才“突然”出来残留数据与新边沿数据被拼接读循环没到 EAGAIN遗留数据触发异常连接关闭后还有数据没处理服务端没及时读关闭时丢弃读循环逻辑修正 关闭前处理残留ET 下 EPOLLOUT 疯狂触发写后未摘除 EPOLLOUT发完数据立刻 MOD 去掉 EPOLLOUT多线程抢读一个 fd 导致乱序一个连接被多个 worker 并发 recv同一 fd 同一时刻只允许一个线程读这张表几乎覆盖了 ET 模式最常见的线上故障。排查时先对号入座再往下看调试方向。5.2 调试三板斧别靠肉眼猜乱码第一板斧strace 看系统调用。启动服务后用strace -e read,recvfrom -p pid观察每一次 read/recvfrom 的返回值能立刻看出“一次事件究竟触发了几次 recv、每次读了多少字节”。数据是否滞留、是否只读一次在这层看得清清楚楚。第二板斧在服务端打日志时记录 recv 的返回字节数 n而不是只打印内容本身。很多“数据丢了”的假象其实是你处理逻辑里某个地方把字节吞噬了n 一打出来就能定位。遇到疑似乱码不要用 printf 直接看汉字改用 hexdump 看字节流echo 你好 | xxd确认 E4 BD A0 这样的合法序列在哪一步被切断了。用肉眼判断乱码容易被终端渲染干扰十六进制的视角才能真正找到真相。第三板斧检查 errno。读循环退出的唯一合法条件是 EAGAIN/EWOULDBLOCK。很多错误代码把 EAGAIN 忽略或者把 EAGAIN 当错误处理这会导致循环逻辑混乱。我见过愣是把 EAGAIN 当成对方关闭、直接 close(fd) 的代码一个正常的连接就这么被自己掐死了。5.3 规范化收包姿势一套让你少踩十年的代码习惯结合我这些年的实战教训总结一套“永不乱码”的收包规范。这套规范不只在 epoll ET 下有用在 Java NIO、Netty、Go netpoll 里都通用。第一永远设置非阻塞。无论 LT 还是 ET网络 fd 默认就按非阻塞来规划这能避免很多边际情况。第二永远用 while 循环读。读到 EAGAIN 才停。这是 ET 的第一纪律不存在“我知道这次数据量不大所以只读一次”这种侥幸。你不可能每次都猜对内核缓冲区的行为。第三永远有上限的应用层缓冲。无论是固定数组还是动态扩容必须设置 MAX_APP_BUF_SIZE缓冲区满了要能优雅断开或丢弃超大消息。没有上限的缓冲设计在洪水攻击面前就是内存炸弹。第四消息边界只认协议字段不认 recv 次数。用 \n 分隔或者长度前缀都可以但消费逻辑必须建立在“缓冲区里有没有一条完整消息”这个判断上而不是建立在“这次 recv 返回了多少字节”上。第五涉及文本协议统一按 UTF-8 处理收包全程避免“直接把字节块转字符串”。字符串拼接这个动作只允许发生在完整的消息边界确认之后。这五条写下来很朴素但每一条背后都有一堆真实事故。我每次帮人排查 ET 问题最后基本都会引导到其中某一条上——不是代码逻辑多深奥就是基础的收包姿势没摆正。文章到这就差不多了。最后再分享一个我私藏的排查习惯别信“感觉”信字节数。无论你有多自信 read 一次能读完都别省 EAGAIN 的循环。ET 模式下的错觉往往就是线上最贵的学费。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑