尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从阻塞到epoll:Linux非阻塞IO多路复用与高并发实战

发布时间:2026/9/29 9:35:48

资讯中心
01
ARTICLE

从阻塞到epoll:Linux非阻塞IO多路复用与高并发实战

从阻塞到epoll:Linux非阻塞IO多路复用与高并发实战
1. 先把概念捋直阻塞与非阻塞到底在说什么做 Linux 网络编程的人几乎都踩过同一个坑服务跑着跑着连接数一上来CPU 占用不高但响应时间像坐电梯一样往上蹿日志里也没报错就是慢。我第一次遇到这种情况是在一个内部数据采集服务上单机扛了两百多个长连接就开始掉数据排查半天内存、GC、SQL 都正常最后用strace -p挂上去一看进程绝大多数时间卡在read()系统调用上纹丝不动。那一刻才真正理解阻塞与非阻塞不是书本上的两个名词而是决定你的程序能不能同时照看多个连接的分水岭。这篇内容就围绕 Linux 网络编程里的阻塞与非阻塞展开从内核怎么处理一次读写、到怎么把阻塞式代码改造成非阻塞加多路复用、再到实际排障时怎么定位问题全部讲透。适合已经会写基本 socket 代码、但一上并发就卡壳的开发者也适合需要把这些概念讲清楚的运维和面试准备者。1.1 从一次 read 调用说起内核里的等待队列很多人以为read()是从网卡拿数据其实不是。数据到达网卡后由内核的协议栈处理最终放进这个 socket 对应的接收缓冲区read()做的事情是把数据从内核缓冲区拷贝到用户缓冲区。关键在于如果缓冲区里没有数据阻塞式的read()就会把当前线程挂到这个 socket 的等待队列上然后主动让出 CPU 进入睡眠状态直到有数据到达或者被信号打断才被唤醒。这个过程对调用方是完全透明的代码看起来就像停在那里等。阻塞的本质是线程让出 CPU等条件满足后由内核唤醒它并不消耗 CPU 时间片这一点非常重要。很多人误以为阻塞就是死等浪费性能其实恰恰相反阻塞本身是高效的真正的问题是一个线程只能等一个 fd。当你有 1000 个连接时用阻塞模型就得开 1000 个线程线程的栈空间、上下文切换、内核调度开销才是压垮系统的元凶而不是阻塞这个动作本身。想清楚这一点后面所有的方案选择就顺理成章了。而把 socket 设置成非阻塞之后read()的行为完全变了缓冲区有数据就拷贝并返回拷贝的字节数没数据就立刻返回 -1同时把errno置为EAGAIN或EWOULDBLOCK在 Linux 上这两个值是同一个。这里的立刻返回意味着调用方必须自己去轮询或者借助别的机制知道什么时候可以读了这就是为什么要引入 select、poll、epoll 这些 IO 多路复用工具。1.2 阻塞非阻塞、同步异步别再把它们搅在一起面试里最喜欢问的一组概念就是阻塞非阻塞和同步异步有什么区别很多人答得含糊。用最直白的话说阻塞与非阻塞描述的是调用方在等待结果时线程的状态同步与异步描述的是结果是谁来通知、数据是谁来搬。一个非阻塞的read()依然是同步的因为你还是得自己去检查返回值、自己去处理拷贝后的数据只是你不睡觉了而已。真正的异步 IOLinux 上是io_uring、老一点的AIO是另一回事你提交一个请求然后什么都不用管内核把数据搬完直接通知你。这个概念差别很关键很多所谓异步框架底层其实是非阻塞 事件循环本质上还是同步非阻塞只是把检查哪个 fd 就绪这件事交给了 epoll 而已。理解了这个层次你看任何网络库的源码都不会被它的宣传语带偏。顺便提一句线程池里的阻塞队列ArrayBlockingQueue、LinkedBlockingQueue 这类用的是同一套思想生产者消费者之间通过条件变量做等待与唤醒消费者没活干时挂起、有活干时被唤醒。这和 socket 的等待队列在哲学上是一回事只是发生在用户态用pthread_cond_wait或者 futex 实现。理解了内核那套用户态这套也就一眼看穿了。1.3 五种 IO 模型的落地形态对照把这五种模型和 Linux 上的具体实现对应起来脑子里就有张地图了。阻塞 IO 就是默认 socket 加单线程非阻塞 IO 是加O_NONBLOCK之后自己写while轮询基本不用太烧 CPUIO 多路复用是select/poll/epoll信号驱动 IO 用SIGIO实际项目中极少见异步 IO 是io_uring近年因为高性能场景被大量讨论。模型等待数据阶段数据拷贝阶段Linux 对应工具典型场景阻塞 IO线程睡眠线程睡眠默认 socket低并发、简单工具非阻塞 IO轮询不睡线程睡眠O_NONBLOCK配合多路复用IO 多路复用阻塞在 epoll线程睡眠select/poll/epoll高并发服务器信号驱动异步通知线程睡眠SIGIO特殊场景异步 IO全异步全异步io_uring极致性能这张表我建议自己动手写一遍而不是背下来。因为当你真正去写epoll代码的时候你会发现多路复用只是把等待阶段从 N 个 fd 合并成了 1 个 epoll_wait拷贝阶段依然是同步的所以它并不神奇只是把等的效率提高了 N 倍。提示判断一个模型属于哪一类只看两个问题——等数据的时候线程睡不睡和拷数据的时候线程睡不睡。两个都睡是阻塞第一个不睡第二个睡是非阻塞两个都不睡才是异步。2. 方案选型什么时候该阻塞什么时候必须非阻塞技术选型最怕的就是听说 epoll 性能好就无脑上。我见过太多项目总共就三五个连接硬是写了一套 epoll 事件循环代码复杂度翻了三倍bug 还多了。所以这一章先讲清楚边界什么场景下阻塞式代码反而更合适什么场景下非阻塞是唯一出路以及多路复用三个工具到底怎么选。2.1 阻塞式 Socket 的适用边界和真实代价阻塞模型最简单的形态是一连接一线程主线程accept()拿到新连接的 fd扔给一个工作线程线程里recv()阻塞等待数据处理完再recv()。代码逻辑是线性的调试极其友好gdb挂上去栈清清楚楚。对于连接数在几十到一两百、每个连接交互频繁的场景比如内部管理服务、数据库中间层这种写法完全够用甚至比事件循环更稳。它的代价有三个。第一是线程数量Linux 默认线程栈 8MB虚拟内存实际按需分配1000 个线程光栈就要预留 8GB 虚拟地址空间再加上内核里每个线程的 task_struct、调度实体开销实打实。第二是上下文切换线程多了以后 CPU 大量时间花在切换上vmstat里的cs列会飙到几十万有效算力反而下降。第三是内存放大每个线程的栈、局部变量、缓冲区都会占据物理内存同样的机器能扛的连接数远低于事件驱动模型。我自己定的一条经验线是并发连接稳定在 200 以内、请求处理逻辑重有计算或磁盘 IO、团队对异步编程不熟就用阻塞加线程池。超过这个量级或者连接是长连接、大部分时间空闲比如推送、IM 场景就必须考虑事件驱动。这个数字不是死标准但能帮你快速做决定不至于在选型上纠结一天。2.2 非阻塞加多路复用选型逻辑其实只有一句话非阻塞的价值必须和多路复用绑定才体现得出来单个 fd 设成非阻塞然后循环轮询是把线程睡觉换成了CPU 空转纯亏。而 epoll 帮你解决的是怎么在 1 个线程里知道 N 个 fd 里哪些就绪事件驱动模型下的标准写法是所有 fd 都设非阻塞注册进 epollepoll_wait返回一批就绪事件逐个处理每个 fd 都读到EAGAIN为止再回到epoll_wait。为什么 fd 必须非阻塞因为epoll_wait返回的是就绪状态但这个状态可能在你这批事件处理完之前就失效了——比如你处理第 1 个 fd 花了 10ms这期间第 5 个 fd 对端把数据撤了、连接关了等你轮到时去read()一个阻塞 fd就会直接卡死整个事件循环。这是事件驱动模型最经典的死法一旦发生整个进程所有连接全部停摆。所以规矩是只要进了 epoll 的 fd必须是非阻塞的而且读事件必须循环读到 EAGAIN。选型上还有一层考虑是协议复杂度。如果你的协议是请求-响应短连接、每次交互边界清晰多路复用代码写起来很舒服如果是长连接双向流式推送状态机管理会复杂不少需要维护每个连接的读缓冲、写缓冲、半包处理这时候用现成的库libevent、libuv、muduo比自己撸要划算得多别为了自己写而重复造轮子。2.3 select、poll、epoll 的参数与性能对比这三个工具都能做多路复用但实现机制完全不同性能差距在大并发下是数量级的。select的接口是select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)fd_set是位图默认FD_SETSIZE是 1024也就是说最多只能管 1024 个 fd改这个值需要重新编译内核实际不可行。每次调用都要把整个位图从用户态拷到内核态返回后还得遍历找出哪个 fd 就绪复杂度 O(n)。另外select返回后fd_set被内核改写下次调用必须重新设置这也是常见的坑。poll用struct pollfd数组替代了位图突破了 1024 的限制接口是poll(struct pollfd *fds, nfds_t nfds, int timeout)。但它依然需要每次把整个数组拷进内核内核也要遍历所有 fd复杂度还是 O(n)。连接数上万时光是拷贝就是不小的开销。epoll用三个系统调用配合epoll_create1()创建实例epoll_ctl()增删改关注的事件epoll_wait()等待就绪。它的核心改进是把 fd 和事件的注册信息常驻在内核的红黑树里不需要每次调用重复传递就绪的 fd 由内核通过回调放进一个就绪链表epoll_wait只需要返回这个链表里的元素复杂度接近 O(1)。另外epoll支持边缘触发ET模式配合非阻塞 fd 可以减少系统调用次数。对比项selectpollepollfd 上限1024FD_SETSIZE无硬限制无硬限制每次调用数据拷贝全量 fd_set全量 pollfd 数组仅就绪列表内核查找方式线性遍历线性遍历红黑树 就绪链表时间复杂度O(n)O(n)O(1) 近似触发模式仅水平触发仅水平触发水平 / 边缘触发适用连接数百级千级以内万级以上epoll_wait的timeout参数单位是毫秒设 -1 表示永久阻塞直到有事件设 0 表示立即返回纯轮询别这么干。返回的nfds表示本次就绪的数量遍历events数组时只处理前nfds个元素即可这一点和poll必须遍历全部不同也是 epoll 高效的原因之一。3. 实操把一个阻塞服务器改造成非阻塞事件驱动光说概念没意义下面完整走一遍改造过程。我会先给一个能跑的阻塞版回声服务器用它跑出基线数据然后一步步改成非阻塞加 epoll中间把每个改动的原因和坑都说清楚。这套流程我在实际项目里用过不止一次照着做基本不会翻车。3.1 阻塞版回声服务器的实现与压测基线先看最朴素的版本一次连接一个循环逐条读、逐条回写// echo_blocking.c — 编译: gcc -O2 -o echo_blocking echo_blocking.c #include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define PORT 9001 #define BUF_SIZE 4096 int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, 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(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 128) 0) { perror(listen); exit(1); } char buf[BUF_SIZE]; for (;;) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { perror(accept); continue; } for (;;) { ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) break; // 0 表示对端关闭 ssize_t off 0; while (off n) { // 循环写防短写 ssize_t w send(conn_fd, buf off, n - off, 0); if (w 0) break; off w; } } close(conn_fd); } return 0; }这个版本有两个细节值得说。一个是SO_REUSEADDR服务器重启时如果有连接处于TIME_WAIT不设这个选项bind会直接失败报Address already in use开发阶段重启频繁这个选项基本必加。另一个是send的循环写网络库永远可能只写一部分send返回值小于请求长度就是短写必须接着写剩下的很多人写 demo 时直接忽略返回值一到生产环境就出现数据截断。压测基线怎么跑用iperf3只能测带宽测你这套应用层的并发能力还是得上并发客户端。简单做法是用wrk或者自己写个多线程 client每个线程建一条连接循环发小包。我用 8 个客户端线程、每线程一条长连接、每秒发 1000 个小包测下来阻塞版在 200 连接以内延迟很稳但把客户端加到 500 条连接时如果每条连接都保持活跃服务端线程模型这里是单线程串行处理直接崩因为它是串行 accept 串行处理第一个连接不关后面的全部饿死。这就是阻塞模型最要命的地方。3.2 设置非阻塞的三种方式与容易踩的坑把 fd 设成非阻塞有三种途径各有适用场景。第一种是socket()时直接加标志socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)优点是少一次系统调用缺点是只有新建 fd 时能用。第二种是accept4()带SOCK_NONBLOCK这样接受的连接天然是非阻塞的比accept之后再fcntl更省一次调用也规避了先阻塞后被设置的窗口期。第三种是通用的fcntl#include fcntl.h int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); // 必须先取原来的标志 if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 用或不能直接赋值 }这里有个特别容易错的地方很多人写成fcntl(fd, F_SETFL, O_NONBLOCK)把原有标志位覆盖掉了。正常情况下看不出问题但如果这个 fd 原本还有其他标志比如O_ASYNC就会被悄悄抹掉。正确做法永远是先F_GETFL再按位或。另外F_GETFL和F_SETFL是两个独立的系统调用中间存在竞态窗口多线程环境下要注意加锁或者直接用accept4。还有一个坑是fcntl设置的行为在 fork 和dup时是共享的因为非阻塞标志属于 file description 而不是 fddup出来的 fd 共享同一个 file description改一个等于改全部。这个特性有时很有用有时会带来意外写多进程服务时必须心里有数。注意errno的判断不能只判EAGAIN。可移植的写法是if (errno EAGAIN || errno EWOULDBLOCK)虽然 Linux 上两者同值但在其他系统上不同。另外EINTR必须单独处理——信号打断系统调用时应该重试而不是当成错误退出。3.3 epoll 版服务器完整实现与关键参数计算下面这个是改造后的核心用水平触发LT版本逻辑最直观也是新手最不容易写错的// echo_epoll.c — 编译: gcc -O2 -o echo_epoll echo_epoll.c #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include arpa/inet.h #include sys/socket.h #include sys/epoll.h #define PORT 9001 #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static void handle_read(int epfd, int fd) { char buf[BUF_SIZE]; for (;;) { ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { ssize_t off 0; while (off n) { ssize_t w send(fd, buf off, n - off, MSG_NOSIGNAL); if (w 0) { off w; continue; } if (w 0 (errno EAGAIN || errno EINTR)) continue; goto close_conn; } } else if (n 0) { goto close_conn; // 对端正常关闭 } else { if (errno EAGAIN || errno EWOULDBLOCK) return; // 读干净了 if (errno EINTR) continue; goto close_conn; } } close_conn: epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, 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(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 511) 0) { perror(listen); exit(1); } int epfd epoll_create1(EPOLL_CLOEXEC); if (epfd 0) { perror(epoll_create1); exit(1); } struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; // 监听可读 ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl add listen); exit(1); } for (;;) { int nready epoll_wait(epfd, 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) { for (;;) { // 一次性 accept 干净 int conn accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK); if (conn 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; break; } ev.events EPOLLIN; ev.data.fd conn; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn, ev) 0) { close(conn); } } } else { handle_read(epfd, fd); } } } close(epfd); close(listen_fd); return 0; }几个关键参数得算一下。MAX_EVENTS设为 1024 表示一次epoll_wait最多返回 1024 个就绪事件如果同时就绪的超过这个数剩下的会在下次epoll_wait返回不会丢但会增加调用次数。经验值是把MAX_EVENTS设成预期并发连接数的 1/10 到 1/4太大浪费栈上空间struct epoll_event在 64 位上是 12 字节1024 个约 12KB还好太小则调用频繁。我一般用 1024 起步压测发现epoll_wait调用次数过高再调。listen的 backlog 参数容易被忽视。这个值表示已完成三次握手但还没被accept的连接队列长度上限内核实际取的是min(backlog, /proc/sys/net/core/somaxconn)。现代内核somaxconn默认是 4096所以 backlog 写 511 或 1024 都行。但如果你的服务启动后瞬间涌入大量连接、而accept处理慢队列会溢出客户端会看到连接超时或SYN被丢弃这时候要么加大 backlog要么加快 accept 速度上面代码里的for(;;)循环 accept 就是干这个的。EPOLL_CLOEXEC和accept4的SOCK_NONBLOCK属于习惯性防御前者避免 fork 出的子进程继承 epoll fd 导致资源泄漏后者省一次系统调用。这两个都是小的优化但养成习惯后代码质量会明显不同。3.4 调试工具与现场记录改完必须验证我常用的组合是这几个命令。ss -lnt看监听状态和Send-QSend-Q在 LISTEN 状态下就是 backlog 的实际上限能直接看出有没有被内核截断。ss -s一行汇总当前连接数、TIME_WAIT数量。ss -tn state established | wc -l数活跃连接。排查 fd 泄漏用lsof -p pid | wc -l如果这个数字持续增长而连接数没涨基本就是某处close漏了。strace -f -p pid -e tracenetwork挂到进程上能实时看到所有 socket 系统调用改非阻塞之前用它确认阻塞点非常有效。测吞吐用iperf3 -s起服务端iperf3 -c ip -t 30 -P 8开 8 条并行流跑 30 秒能排除应用层逻辑先确认网络链路本身没问题再去优化代码。这是排查到底是网络慢还是代码慢的第一刀别跳过。4. 常见问题与排查技巧实录这部分是踩坑合集。下面这些问题我几乎每一个都真实遇到过有些还导致了线上事故写出来希望能帮你少走弯路。4.1 EAGAIN 处理不当的三种典型错误第一种是把EAGAIN当错误处理。某次代码 review 看到一个函数recv返回 -1 就close(fd)理由是读失败结果非阻塞模式下这个 fd 每次没数据就被关掉服务表现为连接随机断开。正确的做法是EAGAIN表示现在没数据稍后再来是正常状态直接返回就好。第二种是第一次遇到EAGAIN就退出循环但在边缘触发ET模式下这是致命错误。ET 模式下只有当 fd 状态发生变化时才通知一次如果你没有一次把缓冲区读干净剩下的数据不会再有新的事件通知数据就永久卡在内核缓冲区里表现为客户端发出的请求服务端不响应。ET 模式下必须写while循环一直读到EAGAIN才退出。这也是为什么我一直建议新手先用 LT等理解了触发语义再切 ET。第三种是写事件的EAGAIN被忽略。非阻塞send在发送缓冲区满时返回 -1 且errno为EAGAIN这时不能丢弃数据必须把剩余数据存进用户态写缓冲并注册EPOLLOUT事件等可写时再继续发。这个用户态写缓冲 动态注册 EPOLLOUT是完整事件驱动服务器的必备模块很多 demo 版 epoll 代码根本没写只能应付小数据量一到大文件传输就出问题。写缓冲建议用环形缓冲区实现避免频繁memmove。4.2 短读短写、粘包、事件丢失的处理短读是指recv一次只返回了部分数据虽然 TCP 是字节流但内核缓冲区多大就返回多少跟你的应用层消息边界无关。所以任何一次recv之后你拿到的是若干字节可能是半条消息也可能是一条半。解决办法是每个连接维护一个读缓冲先把数据追加进去然后按协议格式解析定长头、分隔符、或者长度前缀。我一般用4 字节长度前缀 变长体解析逻辑简单压缩和加密也容易加。粘包其实是短读的另一面本质是同一件事TCP 不保留消息边界。学会用读缓冲 状态机这一套粘包和半包就都不是问题了。状态机至少要有读头部和读体两个状态每次recv之后在状态机里推进直到数据不足时停在当前状态等待下次数据。事件丢失一般有三个来源。一是上面说的 ET 模式没读干净二是epoll_ctl(EPOLL_CTL_ADD)时用了错误的data比如没初始化ev的没用到字段虽然不影响但加多个 fd 时忘记重新赋值ev.data.fd会导致 fd 错乱三是epoll_wait返回EINTR时直接break退出循环导致进程意外终止。这几个都是熟能生巧的活写完代码自己strace跑一遍就都明白了。4.3 连接数与内核参数的现场记录事件驱动模型能扛多少连接除了代码内核参数的限制也很关键。ulimit -n决定单进程能打开的 fd 数默认往往只有 1024做高并发必须提到 65535 甚至更高改的时候要同时改/etc/security/limits.conf里的nofile、systemd 服务的LimitNOFILE、以及fs.file-max。这三处漏一处都可能出现文件描述符耗尽的报错而且报错信息通常很隐晦比如accept返回EMFILE此时不要关闭监听 fd正确做法是先关掉一个已建立的空闲连接或者暂停 accept 一小会儿因为EMFILE时新连接还在内核队列里不是没有。TIME_WAIT堆积也常被误认为泄漏。主动关闭的一方会进入TIME_WAIT并保持 2MSLLinux 上默认 60 秒高并发短连接场景下ss -s里能看到成千上万个这是正常状态不是 bug。可以通过net.ipv4.tcp_tw_reuse允许复用来缓解但不要迷信网传的调参先确认是不是短连接设计本身导致了大量主动关闭能用长连接就用长连接比调参有效得多。还有一个不太引人注意的参数是TCP_NODELAY。Nagle 算法会把小包攒起来一起发配合对端的延迟确认delayed ACK可能引入几十毫秒的额外延迟对于请求-响应型的低延迟服务建议在accept之后立刻设置int on 1; setsockopt(conn_fd, IPPROTO_TCP, TCP_NODELAY, on, sizeof(on));这个在交互式服务里效果立竿见影我调过一个 RPC 框架加了这个选项后 P99 延迟从 40ms 左右降到 3ms 以内改动只有一行。4.4 常见问题速查表现象可能原因排查手段处理方式进程卡死无响应fd 未设非阻塞就进了 epollstrace -p看卡在哪个调用所有 epoll fd 设O_NONBLOCK读循环到EAGAIN连接随机断开把EAGAIN当致命错误加日志打印errnoEAGAIN/EWOULDBLOCK/EINTR单独处理数据不响应ET 模式没读干净客户端发大包复现循环读到EAGAIN或先改用 LTaccept报 EMFILEfd 数超限lsof -p pid | wc -l提高nofile预留空闲 fd 应急大文件传输卡住未处理写阻塞看是否遗漏EPOLLOUT加用户态写缓冲动态注册写事件延迟偏高Nagle 与延迟确认互相等待tcpdump看包间隔设置TCP_NODELAY重启 bind 失败存在TIME_WAITss -tan state time-wait加SO_REUSEADDR监听到但没接收backlog 溢出ss -lnt看Send-Q加大 backlog循环 accept这张表我建议贴在工位上出问题的时候按行对比漫无目的地翻日志快得多。4.5 几个反直觉的实操心得第一条不要过早优化。我在一个日活几千的服务上先上了 epoll后来发现连接数峰值不到 100用阻塞加线程池代码量能少一半可维护性还更好。性能优化的前提是先有压测数据证明瓶颈在哪perf top、vmstat、pidstat三件套先跑一遍确认瓶颈真的是 IO 等待而不是锁竞争或内存分配再动手改架构。第二条epoll_wait之后的处理不要做重活。事件循环是单线程的你在这批事件里做一次耗时 100ms 的数据库查询所有其他连接就集体卡 100ms。重活要么扔给线程池要么用异步接口这是事件驱动架构的铁律。我自己踩过一次坑在事件回调里读了本地配置文件以为很快结果文件所在磁盘刚好在做快照一次读花了 2 秒整个服务两秒无响应。第三条务必做慢连接攻击的防御。非阻塞服务器虽然不会因为单个慢连接卡死但慢连接会持续占用内存和 fd配合读超时是必须的给每个连接记录最后活跃时间在事件循环里用epoll_wait的timeout参数做周期性扫描比如返回0表示超时把空闲超过阈值的连接关掉。这一步不做攻击者几条命令就能把你的连接池占满。第四条日志要能定位到 fd。事件驱动模型下调用栈是扁平的出问题时只有 fd 这个线索所以每条关键日志都带上 fd、事件类型、errno。我做项目时习惯定义一个连接上下文结构体把 fd、对端地址、状态、最后活跃时间打包在一起注册 epoll 时用ev.data.ptr指向它而不是用ev.data.fd这样回调里直接拿到完整上下文比到处查表方便得多也少一次映射查找。这个改动很小但对后期排障效率提升非常明显。最后分享一个小技巧写 epoll 代码的时候先写 LT 版本跑通全部功能再在epoll_ctl处加EPOLLET切换成 ET 做压测对比如果 ET 版本的行为和 LT 完全一致功能、错误日志都一样说明你的读循环写对了一旦出现数据丢失或不响应就回去检查读循环。这个对照测试我做过好几次比自己盯着代码找漏读要靠谱得多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。