
早几年接手一个网关项目某个 worker 进程时不时跑到 100% CPU查了半天发现有人在 epoll 的监听 socket 上挂了 EPOLLETaccept 循环又没写彻底连接 backlog 一满内核直接丢包。那是我第一次真切感受到epoll 的 LT 和 ET 不只是两个标志位而是两种完全不同的编程语义选错了轻则多几次无谓唤醒重则让整个服务在高负载下悄悄崩溃。这篇文章想把 LTLevel Triggered水平触发和 ETEdge Triggered边缘触发这件事彻底讲明白。内容包括两者的本质区别、底层触发条件、实际代码差异、选型建议以及我在生产环境里踩过和见过别人踩过的坑。不管你是刚学网络编程还是已经在写网关、代理、游戏服务器这类高并发服务只要你用 epoll这篇文章都值得读完。搞清楚这两个模式背后的“为什么”比死记 API 要重要得多。1. 先搞明白 epoll 到底解决了什么问题1.1 从 select 时代的痛点说起为什么会有 epoll 这种存在先回到没有 epoll 的日子。在 epoll 出现之前Linux 上最常用的是 select 和 poll。select 的问题很典型每次调用都要把全部关心的 fd 集合从用户态拷到内核态内核再把这些 fd 挨个扫描一遍看谁的缓冲区里有数据。连接少的时候无所谓但到了几千上万个连接每次扫描都是 O(n) 的线性开销而且 fd 数量还受 FD_SETSIZE 限制默认 1024根本不够用。poll 虽然取消了上限但每次全量扫描的毛病还在。epoll 的思路完全不同。它先通过 epoll_create 在内核里建一张事件表再通过 epoll_ctl 把需要关注的 fd 和事件注册进去之后每次只用 epoll_wait 拿结果。内核只会在 fd 真正有事件发生时把该 fd 挂到一个就绪链表上epoll_wait 直接把就绪链表里的东西返回给用户不用每次都去遍历全量 fd。用行话说select/poll 是“每次全查”epoll 是“有事才叫”。这套机制的出现直接让单机支撑十万级甚至百万级 TCP 长连接成为可能。像现在各种 API 网关、即时通讯后端、游戏服务器、消息推送系统底层基本都是 epoll 在撑着。你可能会说还有 io_uring但那是另一个故事了今天只聊 epoll更准确地说是 epoll 的 LT 和 ET。1.2 就绪状态的定义谈 LT 和 ET 之前必须先说清楚它很多文章一上来就讲“水平触发是只要缓冲区有数据就一直通知边缘触发是只有新数据到达才通知一次”这话听多了大家都会说但真问一句“什么叫有数据”“什么叫新数据到达”不少人就含糊了。这里的核心概念叫“就绪状态”。以最常见的 TCP socket 为例可读接收缓冲区里的数据字节数大于等于低水位标记默认情况下只要收到 1 字节就算可读。对监听 socket 来说已完成三次握手、等待 accept 的连接队列非空也算可读。可写发送缓冲区剩余空间大于等于低水位标记默认这一般意味着缓冲区没满可以往里写数据。异常对端关闭连接、发送 RST、带外数据等情况会触发相应的异常事件这里先不展开。epoll 做事件通知本质上就是在监测这些“就绪状态”的变化。LT 和 ET 的差别在于以什么粒度去通知LT 看的是“状态”。状态为真就提醒状态一直为真就一直提醒直到你把状态清掉比如把数据读完或者把缓冲区写满。ET 看的是“状态变化”。只有在就绪状态从假变真的那一瞬间才提醒一次之后就算状态仍然为真只要没有再次跳变内核不会再通知你。这就是最底层的东西。把这句吃透了后面所有行为差异都能推出来。2. LT 与 ET 的核心差异一次“读一半”就能看清2.1 LT条件不消失提醒就不停先看水平触发。假设服务端用 read 一次只能读 10 字节而客户端一次性发来了 100 字节。在 LT 模式下内核发现接收缓冲区有数据把这个 fd 标记为可读epoll_wait 返回你调 read 读走 10 字节缓冲区还剩 90 字节可读状态依然为真于是下一次 epoll_wait 又会立刻把这个 fd 返回给你。这就像家里来了快递门卫看到快递柜里有你的包裹就一直喊你“有包裹有包裹”直到你把包裹取走为止。处理流程简单直接代码怎么写都不容易漏事件因为你总会被反复叫醒直到把事情做完。LT 的好处是好理解、好实现、容错性强。哪怕你在一次事件里只读了一部分数据甚至读了之后忘记处理只要内核缓冲区里还有数据下次 epoll_wait 还会把 fd 捞出来。传统上很多服务端程序直接用 LT配合阻塞 IO 也能正常工作因为可读状态为真时 read 不会一直阻塞在那里。缺点也随之而来如果某个 fd 长时间处于可读或可写状态epoll_wait 就会频繁返回产生大量无意义的用户态唤醒。尤其是把 EPOLLOUT 这种写事件挂在 LT 下只要 socket 可写内核就一直通知你写很容易把 CPU 打到 100%后面第 5 章我会专门讲这个坑。2.2 ET只在状态跳变那一刻通知你边缘触发又是另一副面孔。同样客户端发来 100 字节内核发现接收缓冲区从空变成非空这一瞬间触发了一次 EPOLLIN 通知但如果你这次只读了 10 字节缓冲区里还剩 90 字节内核不会因为“还有 90 字节没读”而再次叫醒你它只会安静地等你下一次“状态变化”——也就是又有新数据从对端到达。还用快递的例子门卫只在包裹放进快递柜的那一刻喊你一声喊完就不再管了。你如果没听到包裹就一直在柜子里躺着直到下一个快递被放进来他才会顺便再喊你一次。听一次取不取是你的事取不干净责任也在你。这就是 ET 让无数新手翻车的地方。要想用 ET你必须保证每次事件触发后一次性把所有数据都读走读到系统告诉你“暂时没数据了”也就是 read 返回 -1 且 errno 为 EAGAIN。如果没读完就停手那剩下的数据很可能在后续很长时间内得不到处理。注意我这里说的是“很可能”不是“绝对”。因为如果客户端后续又发了新数据内核还是会触发一次新通知你可能侥幸把旧数据一并读走。但如果客户端是请求-响应模式发完一次就等结果那这 90 字节就会一直躺在缓冲区里服务端在 epoll 上永远等不到新事件业务直接超时。这类问题特别难查因为不是每次必现取决于后续有没有新数据来“顺带”触发通知。2.3 一个最小 C 示例同样 100 字节两种模式的不同表现文字说再多不如一段能跑的代码直观。这里我给一个 C 语言的最小示例监听 9999 端口注册 EPOLLIN用 10 字节固定缓冲区去读。你只需要改一行注释就能切换 LT 和 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 MAX_EVENTS 64 #define READ_BUF_SIZE 10 static void set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); 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 yes 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, yes, sizeof(yes)); 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(9999); if (bind(lfd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(lfd, 128) 0) { perror(listen); return 1; } set_nonblocking(lfd); int epfd epoll_create(1); if (epfd 0) { perror(epoll_create); return 1; } struct epoll_event ev; ev.events EPOLLIN; // LT 模式 // ev.events EPOLLIN | EPOLLET; // 切换成 ET 模式 ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); struct epoll_event events[MAX_EVENTS]; 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) { while (1) { struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(lfd, (struct sockaddr*)cli, len); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; continue; } set_nonblocking(cfd); ev.events EPOLLIN; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); printf(accept new fd%d\n, cfd); } } else { char buf[READ_BUF_SIZE]; ssize_t r read(fd, buf, sizeof(buf)); if (r 0) { printf(fd%d closed\n, fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else if (r 0) { printf(read %zd bytes from fd%d\n, r, fd); } else if (errno EAGAIN || errno EWOULDBLOCK) { printf(fd%d read complete for now\n, fd); } else { printf(fd%d read error: %s\n, fd, strerror(errno)); } } } } close(epfd); close(lfd); return 0; }测试方法编译后分别用 LT 和 ET 模式启动然后用任意 TCP 客户端连接 9999 端口一次性发送 100 字节观察服务端输出。LT 模式下你会看到程序多次打印 read 10 bytes from fdN直到 100 字节全部读走最后还会打印一次 read complete for now。ET 模式下因为每次 read 只读 10 字节程序大概率只会打印一次 read 10 bytes之后进程就安安静静地停在 epoll_wait 上剩下的 90 字节一直留在缓冲区里再也等不到通知。这个现象你要亲自跑一遍才能真正理解什么叫“一次没读完后续不通知”。3. 为什么 ET 这么难用还有人非它不可3.1 非阻塞 IO 是 ET 的硬性前提不是可选项很多人问ET 模式下 read 必须读到 EAGAIN那我用阻塞 read 行不行答案是绝对不行。想象一下你同时管理着几百个 fd其中一个 fd 触发了可读事件你调 read 想把它读干但读完缓冲区那一刻之后数据还没到阻塞 read 就会卡在那里不动整个事件循环彻底停摆其他所有 fd 都被耽误这就是一场灾难。所以 ET 模式下所有注册进 epoll 的 socket 几乎都必须设置 O_NONBLOCK。读取的时候要用循环一次不行两次直到 read 返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK说明当前内核缓冲区已经暂时空了这次事件的处理才算完成。这也是“读到 EAGAIN”这个说法的来源。有一点可以宽心EAGAIN 和 EWOULDBLOCK 在很多系统上是同一个值代码里两个都判断更稳妥。另外如果 read 被信号打断返回 EINTR这个不算读取结束应该立即重试而不是顺手当错误处理。很多人第一次用 ET 觉得烦是因为“事件驱动”听起来很高级结果写出来全是 while 循环加错误码判断。但这就是 ET 的本来面目它把“确认是否处理干净”的责任完全交给了用户。内核已经做到只喊你一次剩下的事你得自己做全套。3.2 触发条件的精度与底层机制LT 和 ET 在内核里怎么工作既然聊到了底层我尽量用不太枯燥的方式讲一下内核的行为差异。epoll 内部维护了一张所有注册 fd 的事件表同时还有一个就绪链表。某个 fd 的就绪状态发生变化时内核会通过回调把它放进就绪链表epoll_wait 返回时直接从这个链表里取。LT 模式的关键在于epoll_wait 返回之前内核会再做一次“此刻是否仍然就绪”的判断。如果 fd 仍然可读或可写就继续留在就绪链表里下次调用接着返回。这就导致只要条件不消失事件就不会停。ET 模式不会做这种检查。它只负责在状态发生跳变的那一刻把 fd 放进来一次返回给用户之后就直接把 fd 从就绪链表里摘除。之后除非状态再次跳变否则它不会再次进入链表。用一张表把触发条件说清楚场景LT 行为ET 行为接收缓冲区有数据每次 epoll_wait 都会返回该 fd只在缓冲区从空变为非空时返回一次读了一部分还剩数据下次 epoll_wait 继续返回该 fd不返回等待下一次新数据到来读完整包缓冲区为空不再返回可读状态为假不再返回等待下一次状态跳变发送缓冲区可写每次 epoll_wait 都会返回该 fd只在缓冲区从满变为不满时返回一次监听 socket 有连接每次 epoll_wait 都会返回该 fd只在完成队列从空变为非空时返回一次这就是“水平”和“边缘”两个词的物理含义。如果你接触过数字电路里的电平触发和边沿触发会发现这完全是同一套思想一个看持续的电平高低一个看跳变的沿。3.3 ET 的性能优势真实有多大别被“高性能”三个字忽悠既然 ET 编程难度更高为什么还有那么多项目用它核心原因是它减少了 epoll_wait 的无意义返回。在 LT 模式下如果一个 socket 经常保持可读比如大数据量传输过程中缓冲区间歇性非空内核会反复把同一个 fd 挂在就绪链表里epoll_wait 也跟着频繁返回用户态做完一轮读写又会再次被叫醒。ET 模式让每次唤醒都尽量对应一次新的状态变化可以大幅减少重复唤醒造成的上下文切换和空转。但这里我必须泼一盆冷水ET 的“性能优势”没有大众想象中那么神。绝大多数业务后端真正的时间消耗在 read/write 系统调用、数据拷贝、业务逻辑处理上epoll_wait 少唤醒几次对整个吞吐量的影响并不显著。Nginx 用 ET 是因为它把事件循环的每次唤醒都当作一次精确的调度信号尽可能让 worker 进程少做无用功但很多游戏服务器、数据库中间件照样用 LT照样扛得住几十万连接。还有一点容易被误解ET 并不是“每字节只触发一次”而是“每次状态跳变触发一次”。如果你发送一个 100MB 的大文件内核缓冲区会反复从空到满再到空ET 也会触发很多次只是触发次数大约与缓冲区翻转次数成正比而不是与“可读状态持续存在的时间”成正比。所以选 ET 的正确理由只有一个你的系统对“无谓唤醒”极其敏感并且你愿意用更高的编码成本去换这一部分收益。如果只是听人说 ET 性能好就无脑上大概率会在项目里埋下一堆难以排查的 bug。4. 实战选型到底该用 LT 还是 ET4.1 大多数业务后端LT 是省心的选择我在实际项目里默认推荐 LT理由非常朴素它不容易出错。你可以用阻塞 IO 配合多线程模型也可以用非阻塞 IO 配合事件循环你漏读了一部分数据内核下次还会提醒你不至于因为一次疏忽就造成长尾延迟。对于大部分业务服务代码的可维护性远比省那几次唤醒更重要。举个例子一个简单的聊天服务器每个客户端连接上来后epoll_wait 返回可读事件你就 read 一次把收到的消息丢给业务逻辑处理处理完继续等下一个事件。这种模型用 LT 写起来几乎不需要动脑。如果换 ET你不但要处理非阻塞 IO还得确保每次都能把数据读干读干的逻辑还要考虑缓冲区大小、粘包半包复杂度是成倍上升的。LT 还有一个隐性好处就是它可以配合阻塞 IO 做单连接单线程。比如你 accept 一个 fd 之后把这个 fd 直接丢给一个线程去处理线程里用阻塞 read 慢慢读主线程继续在 epoll_wait 上等新连接。这在高并发下不一定最优但架构简单很多内部工具就这么写而且完全能用。如果你的项目没有明确的性能瓶颈又没有专职的底层网络团队LT 就是最稳的选择。这不是妥协是务实。4.2 组件级开发才值得折腾 ET什么人适合用 ET答案是你在写一个“事件循环组件”而不是“业务服务”。Nginx、libuv、Netty 的 native 层这类东西它们把事件循环当作产品核心去抠每次唤醒都要花在刀刃上不允许因为某个 fd 持续性可读就反复把 worker 叫醒。在这种场景下ET 的语义反而更贴合设计事件就是一瞬间的边沿处理完就完不做多余动作。另外如果你的服务是“低频活跃、海量连接”的模型比如百万连接里只有少量连接在传输数据ET 也能派上用场。这种情况下 LT 模式下大量空闲连接如果有陈旧数据没读干净会被反复唤醒造成不必要的开销。ET 可以保证每次唤醒都有新状态跳变不会因为旧数据残留而反复横跳。不过还是那句老话用 ET 之前你要先回答自己三个问题。第一代码里所有 fd 是否都设置了非阻塞。第二每次可读事件处理是否都循环到 EAGAIN。第三写事件处理是否考虑只注册一次。三个问题任何一个回答不上来建议先回去用 LT。4.3 写事件 EPOLLOUT 的差别LT 容易忙循环ET 要等跳变读写是对称的却因为方向不同而出现不对称的坑。读事件是“内核有数据通知你拿走”写事件是“内核有空间通知你写入”。对于普通 socket绝大部分时间发送缓冲区都是空的、可写的如果你在 LT 模式下注册了 EPOLLOUT内核就会认为该 fd 一直处于可写状态于是 epoll_wait 每次都把它返回给你哪怕你根本没有任何数据要发送。这就是经典的“写事件忙循环”。正确做法是平时只注册 EPOLLIN当你需要发送数据且 write 返回 EAGAIN 时才把 EPOLLOUT 加上去等发送缓冲区腾出空间、内核通知你写事件后写完数据马上再把 EPOLLOUT 注销回到只关注 EPOLLIN 的状态。这套“临时注册、用完即撤”的操作在 LT 模式下是基本功。ET 模式下写事件的语义又不同。如果发送缓冲区已经是可写的你注册 EPOLLOUT 时内核并不会立刻通知你因为“从不可写到可写”的跳变已经过去了现在只是保持着可写状态而已。所以 ET 的写事件触发条件很严格要么缓冲区原本是满的后来对端读走了一部分数据从满变成不满触发通知要么缓冲区原本不满你一次性写满了之后又腾出空间再触发通知。这意味着用 ET 做发送流程时你不能指望“注册了 EPOLLOUT 就会被叫醒”必须自己先试着 writewrite 失败后再注册 EPOLLOUT等下一次跳变。一些代码写得比较随意的项目往往就是在这里踩坑数据没发出去注册了写事件又一直不来整个发送流程卡死。这时候检查的重点不是网卡而是你是不是真的在等一个可能永远不会到来的“跳变”。5. 常见问题与排查技巧实录5.1 ET 读不干净导致后续数据“失联”这是 ET 模式最有名的坑也是很多人从 LT 切换到 ET 后遇到的第一道坎。现象是客户端发一次数据服务端处理了其中一部分之后客户端再发数据服务端却没有任何反应像是连接死掉了一样但又没有断开。原因就是我前面反复强调的ET 下内核只认“状态跳变”不认“状态持续”。第一次数据到达时触发了一次可读通知你只读了一部分缓冲区里还留着旧数据这时即使你有心想读内核也不会再主动叫你因为跳变已经错过。之后客户端发了第二批数据内核缓冲区从“非空”变成“非空且有新数据”这本身没有发生从空到非空的跳变所以很多情况下连第二次通知都不会有。排查思路很简单开启服务端调试日志记录每次 read 返回的字节数一旦发现某个连接长时间不再有 read 日志同时客户端持续在发送数据基本就命中了这个问题。解决办法是规范的 while 循环读取while (1) { ssize_t r read(fd, buf, sizeof(buf)); if (r 0) { // 处理数据 } else if (r 0) { // 对端关闭清理 fd break; } else { if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) break; // 其他错误关闭 fd break; } }这套模板可以理解为 ET 读事件的“安全气囊”。任何 ET 连接的可读处理都应该长这样。5.2 LT 下 EPOLLOUT 幽灵唤醒导致 CPU 100%另一个高频事故在 LT 模式这边。现象是进程 CPU 突然飙高strace 或者 perf 一看epoll_wait 几乎瞬间就返回返回的事件里都有一个 fd 的 EPOLLOUT。原因很简单你在某个时刻因为要发大包给这个 fd 注册了 EPOLLOUT结果数据很快就写完了但你没有注销 EPOLLOUT 事件。发送缓冲区一直处于可写状态内核便一直把该 fd 当作就绪事件返回你的进程就像一个跑轮上的仓鼠转个不停。我记得有一次排查发现事故代码在一段“尝试写一下如果满了就等下次”的逻辑里把 EPOLLOUT 注册了就不再删除于是只要连接存在事件循环就在空转其他连接的读写也跟着饿死。解决手段就是前面说的EPOLLOUT 必须按需使用加进去之后一旦完成写任务立刻通过 epoll_ctl 把事件改回 EPOLLIN或者使用 EPOLLONESHOT 来避免同一个事件反复触发但这会引入每次都需要重新注册的成本适合流量小的场景。这个坑的隐蔽性在于它不像 ET 读不干净那样直接丢数据而是以一个缓慢且有迷惑性的方式让你的服务整体变慢。CPU 高但不崩连接不关闭但延迟涨看起来像是“负载真的变高了”其实是循环空转。检查命令也很直接perf top 看热点如果大量时间耗在 epoll 相关逻辑上再去查所有 fd 的事件注册状态。5.3 监听 socket 用 ET 后 accept 遗漏连接监听 socket 也照样可以设置 EPOLLET但这里有个特别容易翻车的细节。当新的连接完成三次握手完成队列从空变为非空内核触发一次可读通知如果你只 accept 了一次而后又有多个连接同时涌进来完成队列依然非空但已经没有新的“从空到非空”跳变你就错过了后面那些连接。在不太忙的服务上这个问题的表现是“偶尔有客户端连不上但服务端没有 accept 日志”。在高并发服务上表现会变成“连接数一直上不去客户端大量超时”。因为监听 fd 一旦错过跳变后续只有等到完成队列从非空变空、再从空变非空时才会再次触发。正确写法是 accept 也要循环while (1) { int cfd accept(lfd, NULL, NULL); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno ECONNABORTED) { // 对端在三次握手后、accept 前就发送 RST 断开继续处理下一个 continue; } // 其他错误视情况退出或继续 break; } // 处理新连接设置非阻塞注册进 epoll 等 }有一个原则只要用了 EPOLLET你就必须做好“能力范围内能处理多少就处理多少直到系统告诉你处理不动”的准备。accept 循环、read 循环、write 循环全都遵循这个原则否则 ET 处处是坑。5.4 其他几个容易踩的细节坑速查生产环境里我还见过不少零碎问题这里一起整理成速查表。现象原因解决方案ET 模式新注册 fd 后已有数据迟迟不被处理注册 fd 时数据已经到达但注册本身不产生状态跳变ET 不会通知accept 后先主动尝试读一次或者注册后立刻投递一次处理逻辑多个线程同时调用 epoll_wait 等同一个 epfd同一 epoll 实例被多线程并发 wait可能出现惊群和重复事件分发只允许一个线程 wait或用 SO_REUSEPORT 让每个线程独立 listen 和 epfdread 返回 -1 且 errno 为 EINTR却被当作错误关闭 fd信号打断读取并未结束遇到 EINTR 应该立即 continue 重试epoll_wait 返回后 fd 被其他线程关闭再操作 fd 报 EBADF事件分发和业务处理多线程共享 fd缺少生命周期管理建议连接 fd 增加引用计数或由单一线程负责关闭避免边用边关使用 EPOLLONESHOT 后后续不再触发事件EPOLLONESHOT 语义就是只触发一次之后需要重新注册每次处理后确认是否需要 epoll_ctl 重置事件按需设计这些坑单个看起来都不大放到高并发环境里就会被放成线上事故。我见过好几个团队在 ET 上反复栽跟头最后悄悄退回 LT不是没有道理的。还有一个经验值得单独分享无论 LT 还是 ET在 fd 关闭后一定要从 epoll 中删除并清掉引用。即使系统在 close 后会自动移除 fd 在 epoll 内部的注册如果其他线程还持有这个 fd 的副本也不要拿它继续发读写请求。做网络服务fd 生命周期管理比选 LT 还是 ET 重要得多这块出问题问题表现同样隐蔽。6. 最后分享点个人体会在我自己写网络服务的这些年里有个判断经验一直很管用如果你不确定该用 LT 还是 ET先用 LT。LT 不会因为你的疏忽而静默丢事件它像一个守规矩的闹钟哪怕叫得勤一点也不会漏叫。ET 则是那种只按一次门铃就走的快递员你必须随时守在门口否则包裹就卡在快递柜里。它的确更高效但这份高效是拿“用户必须承担全部处理责任”换来的。如果你真的准备在项目里用 ET我建议先从非阻塞 IO 和“读到 EAGAIN”这两个基本功开始把本章第 5.1 节的读取模板内化成肌肉记忆。再多做一层保护监听 socket 尽量不要用 ET让 accept 逻辑走 LT之后连接上的读写再考虑 ET这样可以先避开最容易被忽略的连接堆积问题。这些取舍都是我在真实线上一遍遍踩出来的值得你直接带走。