尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

epoll高并发实战:从I/O多路转接到TCP服务器实现

epoll高并发实战:从I/O多路转接到TCP服务器实现 写网络服务的人大概率都干过这么一件事用最朴素的方式写一个TCP服务器——accept一个连接开一个线程去处理再来一个再开一个。一开始觉得没什么等到连接数上来线程数跟着爆炸CPU开始打满调度开销比业务逻辑还大。这时候你就需要认真对待一件事I/O多路转接。在整个Linux的高性能网络编程里epoll几乎就是“高并发”的代名词。I/O多路转接这个词听起来很学术但说白了就是让一个线程同时盯着成千上万个连接。而epoll正是在这个场景下最常用、最成熟、面试也最爱问的方案。这篇文章不打算讲太多空洞的概念我直接把I/O多路转接的来龙去脉、epoll的内部机制、以及一个可用的epoll版本TCP服务器实现一步步拆开给你看。内容偏向实战也会穿插不少我踩过的坑适合正在学Linux网络编程的开发者也适合准备面试想把这部分彻底理清的朋友。1. 先把问题摆清楚为什么需要I/O多路转接1.1 朴素模型的困境从阻塞IO说起很多人刚写网络程序时代码大概是这样的服务端调用accept阻塞在那等客户端连上来然后read阻塞着等客户端发数据。这个过程里如果只有一个连接完全没问题代码又短又清晰。但只要是两个以上的连接同时来阻塞模型的短板立刻暴露——你在read第一个连接的socket时第二个连接虽然在排队等accept但根本没人理它因为它还卡在第一个连接的读操作里。为了让程序能“同时”处理多个连接大家自然会想到多线程每来一个连接就pthread_create开一个新线程在线程里去阻塞读写。这种做法在连接数几十个的时候挺实用代码改动也小。可一旦连接数到几千、几万问题就严重了线程本身要占栈空间默认8MB左右哪怕用线程池限制数量上下文切换和锁竞争的开销也会把性能拖垮。更麻烦的是绝大多数连接在一段时间内是空闲的——建立连接后客户端半天不发数据而线程只能阻塞在read上干等资源被白白占着。这就引出一个核心矛盾网络IO的特点是“连接数多但每个连接的数据量少、活跃时间短”。你真正需要关注的是那少数几个“准备好读或写”的连接而不是把大量资源分配给所有空闲连接。能不能让一个线程去统一监视所有socket哪个有数据来了就处理哪个没事干的连接不占用资源这就是I/O多路转接要解决的本质问题。1.2 I/O多路转接解决的本质问题I/O多路转接英文叫I/O Multiplexing它的核心是一个“监督者”的角色替你盯着所有已连接的fd告诉你哪些fd现在可读、哪些可写、哪些出了异常。你只需要在事件发生后再去调用对应的IO操作函数就不会因为某个socket没数据而卡住整个进程。这里有个非常容易误解的点多路转接本身并不是“异步IO”它依然是同步的。你在epoll_wait上阻塞等待事件等内核告诉你“某个fd可读了”你再自己去read。它跟信号驱动、AIO这类“内核帮你把数据搬好再通知你”的机制有本质区别。所以更准确的理解是多路转接把“等”这件事集中起来了把等待时间最大化复用但真正的读写还是你自己来。在Linux下实现多路转接有三个选型select、poll、epoll。select和poll是早期提出的方案机制简单但都有明显的扩展瓶颈。epoll是Linux 2.6内核专门为解决大规模fd监视而设计的它在设计思想和接口形式上跟select、poll完全不是一个量级。理解了epoll为什么快你才算真正理解I/O多路转接。2. 从select到epoll到底改进了什么2.1 select/poll的工作原理与瓶颈很多人觉得select和epoll的区别只是“效率高低”实际上它们的模型都不一样。select的工作方式是把你要监视的fd放进三个集合可读、可写、异常每次调用select时把这个集合从用户态拷贝到内核态内核轮询一遍所有fd把有事件发生的fd标记出来再拷回用户态。你拿到结果后还得再自己遍历一遍整个fd集合去一个个检查“这个fd是不是可读了”。这里有两个明显的痛点第一每次调用都要全量拷贝fd集合fd越多拷贝开销越大第二内核和用户程序都要做O(n)的遍历n是监视的fd总数而不是就绪的fd数量。select还有一个硬限制FD_SETSIZE默认是1024也就说你最多监视1024个fd想扩大还得重新编译内核或改动宏定义非常不灵活。poll针对fd数量限制做了解法不再用固定大小的位图改成pollfd数组理论上fd数量可以很大。但poll的核心问题没解决——它还是要全量拷贝、全量遍历。连接数上来后每次poll调用都要扫描那么多fd而大多数fd根本没事件这个浪费非常明显。所以select和poll的共同本质是每次调用都是“全量扫描、线性轮询”内核只负责告诉你“有哪些fd已经就绪”但这个“哪些”是通过把状态标记在你自己传入的fd数组里返回的你还是要O(n)地再去扫一遍。当连接数达到几千CPU时间几乎都消耗在“扫描无用fd”上了。2.2 epoll的两个核心突破epoll在设计上直接绕开了这两个痛点核心思路可以总结为三点第一点是“只关心活跃fd”。epoll在内核里维护了一个事件表你用epoll_ctl把需要监视的fd注册进去内核只会在这些fd上发生你关心的事件时把对应的fd放到一个就绪链表里。你调用epoll_wait时拿到的基本就是“已经就绪的fd”数量通常远小于全部fd。这样你就不用再一个接一个检查所有fd了遍历成本O(k)k是就绪数而不是总连接数。第二点是“减少数据拷贝”。select每次调用都要把fd集合从用户态拷到内核态epoll通过epoll_ctl提前注册内核和用户态共享同一份事件表后续的epoll_wait不再需要重复拷贝全部fd信息只是在就绪链表上取走结果而已。第三点是“回调机制代替轮询”。select/poll的内核实现是遍历全部fd检查状态。epoll则会给每个被监视的fd挂一个回调函数当fd上发生事件比如socket收到数据时内核自动调用回调把这个fd放进就绪队列。这等于说epoll的工作量只跟“有事件发生的fd”相关跟“监视的fd总数”基本无关。2.3 两种触发模式LT和ET理解它们是绕不开的坎epoll有两种触发模式理解它们的差异比背接口还重要。水平触发Level TriggeredLT是默认模式只要fd上有事件没处理完每次epoll_wait都会提醒你。比如你一次性收到100字节但只读了50字节剩下的50字节还在接收缓冲区里那下一次epoll_wait依然会把这个fd报告为可读。这种模式的好处是简单、不易漏事件坏处是你可能被同一个事件反复叫醒多次。边缘触发Edge TriggeredET是“只在状态变化时通知”只有当fd从“没有可读数据”变成“有可读数据”这个瞬间你才会收到一次通知。内核不管你读没读完——如果通知后你没把数据读完那在下一次新数据到来之前内核不会再提醒你。所以ET模式下你必须一次性把数据读到读不出来为止读到EAGAIN否则就会丢数据。用生活类比理解LT就像快递员反复打电话提醒你“快递到了”直到你取走为止ET就像只通知你一次“有一批快递到了”你自己必须一趟全搬完不然就丢件。实际项目中高并发服务往往用ET配合非阻塞IO因为可以减少事件被重复触发的次数、降低系统调用量。但新手我建议先从LT入手代码更稳逻辑更直观等搞清楚了再切ET。对比项select/pollepoll LTepoll ETfd数量限制select有1024限制poll无限制但有性能瓶颈无无遍历成本O(n)全量扫描O(k)返回就绪fdO(k)但需主动读完重复通知每次都扫描未处理完会一直通知状态变化只通知一次读取方式阻塞/非阻塞均可阻塞/非阻塞均可必须非阻塞并读到EAGAIN适用场景fd少、逻辑简单通用、容错性好高并发、性能敏感场景3. 三个系统调用与关键参数逐个弄明白epoll的接口只有三个刚上手时觉得少反而容易忽略细节。这里的每个参数、每个返回值都值得抠清楚因为它们直接决定了你后续的事件循环怎么写。3.1 epoll_create创建内核事件表函数原型是#include sys/epoll.h int epoll_create(int size);size参数在2.6.8内核以后其实已经不重要了内核会动态调整事件表大小当时设计这个参数的初衷是给内核一个“参考值”让你告诉他大概要监视多少fd但现在你传一个大于0的数就好。运行时一般直接写epoll_create(1)写个几十、上百也完全没问题。有个容易被忽略的点epoll_create返回的是一个fd它本身也占用一个文件描述符程序结束时也要记得close文件描述符泄漏多半就是这种不起眼的地方积累出来的。还有另一个系统调用epoll_create1可以传EPOLL_CLOEXEC标志配合多进程程序使用可以避免在exec执行其他程序时fd被意外继承项目里我建议直接用epoll_create1。3.2 epoll_ctl注册、修改、删除事件函数原型int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);op有三类操作EPOLL_CTL_ADD注册一个新的fd到事件表EPOLL_CTL_MOD修改一个已经注册的fd的监听事件EPOLL_CTL_DEL把一个fd从事件表里删除。event指向一个struct epoll_event结构体定义如下struct epoll_event { uint32_t events; /* Epoll events位图组合 */ epoll_data_t data; /* 用户数据通常放fd或指针 */ };events常用的位标志有这些EPOLLIN对应的事件是读事件fd可读时触发。EPOLLOUT写事件fd可写时触发。EPOLLERRfd发生错误比如对端异常断开。EPOLLHUPfd挂起连接被挂断时触发TCP对端发送RST或关闭连接时常见。EPOLLRDHUP对端关闭连接或半关闭这个比EPOLLHUP更精确处理TCP连接关闭时很有用。EPOLLET边缘触发模式设置了这个位就表示该fd使用ET模式。EPOLLONESHOT只触发一次触发后该fd需要重新设置才能再次被监视多线程模型里常用。特别注意EPOLLERR和EPOLLHUP不需要你显式注册只要fd上出现这两种情况内核总会把它们加到返回的就绪事件里。所以epoll_wait返回后你不光要检查EPOLLIN、EPOLLOUT还要检查这两种异常状态否则连接异常断开时你可能毫无感知资源也不会被清理。3.3 epoll_wait等待就绪事件函数原型int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);events是用户分配的一块内存内核把就绪的事件拷贝到这里maxevents必须大于0告诉你这块内存最多能装多少个事件通常和events数组的大小一致。timeout是超时时间单位毫秒为-1表示无限等待0表示立即返回大于0表示最多等待这么长时间。返回值是本次就绪的事件数量。这个返回值非常重要epoll_wait返回0表示超时没有事件发生返回-1表示出错。每次拿到就绪事件后你要通过events[i].data.fd去找到对应的socket再根据events[i].events去判断具体是什么事件。3.4 关于event.data一个容易被忽略的细节epoll_event结构体里那个data字段很多初学者只把它当“fd的存放处”其实它是一个联合体。标准定义如下typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;这里有个重要的设计思想内核只负责帮你把这个data原样保存下来当你调用epoll_wait拿到就绪事件时这个data会跟着事件一起返回给你。它的好处是你不仅可以通过data.fd拿到fd还可以用data.ptr挂一个你自己定义的结构体指针——比如一个封装了连接状态、接收缓冲区、发送缓冲区的上下文对象。当你用EPOLL_CTL_MOD修改事件时也可以用新的data把旧数据覆盖掉。我个人习惯是把连接对象指针放进data.ptr而不是只存fd因为事件循环处理时能直接拿到整个连接的上下文省去一次“fd到对象”的映射查找。不过要注意指针的生命周期管理连接释放后如果epoll事件表里还残留着指向已释放内存的指针那就是悬空指针非常危险后面我会讲怎么避免。4. 一步一步实现基于epoll的TCP服务器4.1 服务器框架设计下面进入正题我们来完整实现一个基于epoll的TCP服务器。为了让代码可复现我采用LT模式加非阻塞socket的经典组合。为什么选择LT而不是ET作为起步理由很简单LT模式下即使某个fd没读完数据内核下次还会继续通知你逻辑上不容易丢事件处理起来更宽容。等掌握了LT的完整流程再切换到ET会顺手很多。整个服务器的框架分为三层结构第一层是监听socket的创建和初始化socket、bind、listen设置非阻塞。第二层是epoll实例的创建和监听socket的注册epoll_create、epoll_ctl。第三层是事件循环epoll_wait返回后根据事件类型分发处理包括接受新连接、收发数据、处理异常断开。我用C语言来写因为Linux的epoll接口本身就是C接口用C写最直接。工程上用C封装的也不少但底层逻辑完全一样。4.2 socket、bind、listen的非阻塞改造创建socket的代码大部分人都会写但有一个关键细节必须注意监听socket最好也设置为非阻塞。为什么因为事件循环里你调用accept时返回的只是“有连接进来了”的通知但在高并发下可能有多个连接同时到达你一次accept只能取一个剩下的还得继续接收。如果监听socket是阻塞模式处理逻辑会复杂很多。我们看这段完整的初始化代码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include sys/epoll.h #include netinet/in.h #include arpa/inet.h #include fcntl.h #define PORT 8888 #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 /* 设置非阻塞 */ 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(int argc, char *argv[]) { int listen_fd, epoll_fd; struct sockaddr_in server_addr; /* 1. 创建监听socket */ listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd -1) { perror(socket); exit(EXIT_FAILURE); } /* 2. 设置地址可重用避免TIME_WAIT导致bind失败 */ int reuse 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); /* 3. 绑定地址和端口 */ memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) -1) { perror(bind); close(listen_fd); exit(EXIT_FAILURE); } /* 4. 监听 */ if (listen(listen_fd, 128) -1) { perror(listen); close(listen_fd); exit(EXIT_FAILURE); } /* 5. 设置非阻塞 */ set_nonblock(listen_fd); /* 6. 创建epoll实例 */ epoll_fd epoll_create1(0); if (epoll_fd -1) { perror(epoll_create1); close(listen_fd); exit(EXIT_FAILURE); } /* 7. 注册监听socket关注读事件 */ struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev) -1) { perror(epoll_ctl); close(listen_fd); close(epoll_fd); exit(EXIT_FAILURE); } /* 8. 进入事件循环 */ ... }这里有个经验SO_REUSEADDR一定要设置。否则服务器进程刚退出、连接还处于TIME_WAIT状态时你紧接着重启进程bind可能会报Address already in use排查时非常容易让人困惑。4.3 事件循环的核心代码事件循环是整个服务器的心脏epoll_wait拿到的每个事件都要分类处理。这段逻辑我逐步解释struct epoll_event events[MAX_EVENTS]; struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); while (1) { int n epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (n -1) { if (errno EINTR) continue; /* 被信号中断重试 */ perror(epoll_wait); break; } for (int i 0; i n; i) { /* 监听socket可读有新连接 */ if (events[i].data.fd listen_fd) { /* 这里用while循环把当前所有的pending连接全部accept掉 */ while (1) { int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, addr_len); if (conn_fd -1) { if (errno EAGAIN || errno EWOULDBLOCK) break; /* 已经没有待处理的连接了 */ else if (errno EINTR) continue; else break; } set_nonblock(conn_fd); struct epoll_event client_ev; client_ev.events EPOLLIN | EPOLLRDHUP; client_ev.data.fd conn_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, client_ev) -1) { perror(epoll_ctl(ADD)); close(conn_fd); } } continue; } /* 处理异常事件错误、挂断、对端关闭 */ if (events[i].events (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) { printf(connection %d closed or error\n, events[i].data.fd); close(events[i].data.fd); /* 实际工程里还要epoll_ctl DEL见常见问题章节 */ continue; } /* 可读事件接收数据 */ if (events[i].events EPOLLIN) { char buffer[BUFFER_SIZE]; ssize_t recv_len; /* LT模式下循环读直到读完为止 */ while (1) { recv_len recv(events[i].data.fd, buffer, sizeof(buffer), 0); if (recv_len 0) { /* 这里做业务处理示例为回显 */ send(events[i].data.fd, buffer, recv_len, 0); } else if (recv_len 0) { /* 对端关闭连接 */ printf(peer closed, fd%d\n, events[i].data.fd); close(events[i].data.fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; /* 数据读完了 */ else { /* 真正的错误 */ close(events[i].data.fd); break; } } } } } } close(listen_fd); close(epoll_fd); return 0; }epoll_wait采用timeout-1无限等待如果被信号打断返回-1且errno为EINTR一定要continue而不是直接退出这是很隐蔽的bug。监听socket的accept用while循环的目的是把内核里排队的连接全部取出来因为EAGAIN才表示队列已经空了。如果是LT模式且用多线程处理通常让一个线程专门accept其余线程负责读写。可读事件的接收LT模式下也要用while循环读一次可能没有把接收缓冲区读完这里要一致读到返回EAGAIN说明当前数据没有被读完的必要——不准确说读到EAGAIN表示内核缓冲区已经空了该fd上的数据都读完了。LT模式你不读完内核会一直通知你导致事件循环空转。这点要记住。4.4 LT与ET模式的改造差异把上面这段代码改成ET模式其实改动不大但每一处都有讲究。第一个改动是在设置客户端事件的events上加EPOLLET标志第二个改动是accept和recv的循环逻辑必须足够彻底ET模式下必须循环到EAGAIN为止——EAGAIN不再是“可选”的退出条件而是必须的退出条件。否则你只处理了一次事件剩下的数据就一直滞留在缓冲区里直到新的数据到达才会触发新一轮EPOLLIN这在业务上等于直接丢数据。修改后的注册代码client_ev.events EPOLLIN | EPOLLRDHUP | EPOLLET;接收循环和上面一样但要注意如果缓冲区分多次recv单次BUFFER_SIZE可能不够大ET模式下你可以用一个更大的应用层缓冲区比如16KB或申请大块内存来暂存或者采用“边读边处理”的策略。还有一个常见做法是在处理完EPOLLIN后额外检查一次EPOLLOUT来把发送缓冲区清空因为ET模式下写事件同样不会被重复触发。修改后的完整代码流程其实和LT模式很接近差异主要集中在“事件标志位”和“是否必须读到EAGAIN”上。这也是我希望你先在LT模式下把整个流程跑通的原因——切换ET时你只需要理解“为什么必须循环读到EAGAIN”而不是同时要应付“accept处理、连接关闭、缓冲区管理”一堆新概念。4.5 多线程下的延伸EPOLLONESHOT的用法单线程事件循环的瓶颈在于每个连接的读、写、业务处理都在一个线程里串行执行。如果某个连接的recv拿到数据后业务逻辑耗时较长比如要查数据库后面即使有几十个连接的数据就绪也只能排队等它处理完。所以高性能服务通常把事件循环和业务处理分离事件循环线程只负责收数据然后把数据丢给工作线程工作线程处理完再通过某种方式把响应交回给事件循环去send。这种情况下EPOLLONESHOT就非常有用了。它的语义是事件触发一次后立即被内核从epoll的监视中移除或者说是“禁用”直到你再次EPOLL_CTL_MOD重新注册。这样能够保证当一个工作线程正在处理某个连接的数据时事件循环不会因为该连接又变可读而再次分发事件避免了多线程同时操作同一个socket的竞态。典型流程是注册连接事件时加上EPOLLONESHOT。epoll_wait返回后把连接交给工作线程处理。工作线程处理完再调用epoll_ctl(fd, EPOLL_CTL_MOD, ...)重新注册该连接的事件。要注意的是重新注册的时机要恰当如果工作线程还没处理完就重新注册了事件新一轮的触发可能又把它丢给另一个工作线程导致同一连接被两个线程同时读。你可以在重新注册之前确认处理确实结束或者用锁保护。这一块是多个线程协作模型里的进阶话题这里先做个提示不展开写。5. 实战中的常见问题与调试心得5.1 LT和ET选错业务逻辑直接乱套很多人在自己的代码里同时混用了LT和ET导致行为不可预测。例如监听socket用LT、客户端连接用ET这时accept循环虽然一直在处理连接但ET模式下的客户端数据如果没有一次读完后续的EPOLLIN不再触发数据就一直堆积。这种问题最难排查因为它不是崩溃而是“数据有时候能收到有时候收不到”复现也很随机。我的建议是在一个服务器进程内明确每个fd使用哪种模式最好统一。如果非要用不同的触发模式至少把注册代码写清楚、注释明白别靠记忆。这个坑我踩过一次当时排查了一个下午最后发现就是一个客户端fd被意外注册成ET模式引起的。5.2 accept和recv没写循环丢连接丢数据在新手代码里最常见的错误就是accept和recv只调用一次而不考虑“有多个连接同时就绪”和“一次读不完一包数据”的情况。accept只用一次的问题如果多个连接同时到达而你的accept只调用了一次剩下的连接会一直留在内核的完成队列里。如果之后没有新连接来触发EPOLLIN这些连接就永远没人处理表现为“客户端connect成功了但服务端一点反应都没有”。recv只用一次的问题更隐蔽你调用一次recv拿到了当前缓冲区里的数据但你不知道后面还有没有更多。LT模式下内核会继续通知你你的循环至少还能处理ET模式下则直接丢失数据就停在缓冲区里了。正确做法就是我上面代码里展示的accept用while循环直到EAGAINrecv在LT模式下读到EAGAIN为止或者等到recv_len 0处理关闭。5.3 EPOLLOUT处理不当CPU飙到100%初学epoll时很多人会犯一个错为了确保数据能及时发出给所有连接都注册了EPOLLOUT。结果epoll_wait几乎每次都返回大量写事件因为socket的发送缓冲区大部分时候都是空的——也就是说fd“可写”是常态不是稀有事件。你的事件循环就开始疯狂地“可写→无所事事→再次等待→又触发可写”CPU直接打满。正确的逻辑是只有当你确实要向某个fd发送数据但发送缓冲区可能已满比如send返回EAGAIN时才临时注册EPOLLOUT等可写事件触发后发送完成立即移除EPOLLOUT。也就是说EPOLLOUT应该是“按需启停”的而不是长期监视的状态。记住一句话EPOLLIN是常态监视EPOLLOUT是应急手段。5.4 fd被关闭后的事件悬挂问题这也是一个很典型的坑你收到EPOLLHUP或EPOLLRDHUP事件关闭了fd但没有从epoll里删除它。然后在同一个epoll实例里一个新的连接恰好分配到了这个fd值。epoll里的旧事件还在它的data.fd和新的fd值相同于是新连接的读写事件一起混进了旧连接的事件处理逻辑里造成数据错乱甚至崩溃。一个有效规避手段是关闭fd之前先调用epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)把事件移除。这虽然不能百分之百防止fd重用带来的各种复杂问题因为epoll_ctl和close之间可能还有竞态但在单线程事件循环里这样做能大幅减少悬挂事件的可能性。另一条更重要的守则close(fd)后绝对不要在事件循环里再引用这个fd因为你无法准确知道它什么时候会被内核分配给别的连接。这一点在工程实践中极其重要。5.5 一段调试经验strace怎么用、日志怎么打epoll程序调试起来比多线程还要绕因为你面对的对象不是“线程栈”这种东西而是一个看不见的内核事件表。我的调试三板斧如下第一strace。strace -f -e epoll_ctl,epoll_wait,accept,recv,send ./server可以直接看到每次epoll_wait返回了什么事件、哪些fd触发了什么标识。有一次排查“某个连接为什么突然不触发任何事件”就是用strace发现recv在阻塞模式下被第N次调用后卡住了——因为那个fd的EPOLLIN事件已经触发但实际数据已经被其他处理逻辑读走程序自己把自己搞死了。第二日志里打印events[i].events的十六进制值。这是个特别实用的小技巧不要只打印EPOLLIN之类的字符串因为事件经常是组合的比如EPOLLIN|EPOLLRDHUP你还要知道具体有没有EPOLLERR。打印原始值能帮你发现“咦明明没注册EPOLLERR它怎么出现了”这类hidden信息。第三给每个连接分配一个单调递增的连接ID日志里统一用“fd连接ID”来标记。这样排查问题时你能清晰追踪“哪个连接在什么时候建立、什么时候关闭、数据从哪来”否则一堆裸fd挤在日志里根本没法看。写在最后我的一些实际体会做网络编程这些年我的一个很深的感受是epoll并不难写难的是理解它背后的“事件驱动”思维。很多人代码能跑但问他为什么这里要循环accept为什么ET模式下必须读到EAGAIN为什么EPOLLOUT不能一直监听答不上来。这些问题的答案都不在API文档里而在你对Linux IO机制的理解里。建议你把上面这份代码自己敲一遍然后故意制造一些异常情况——比如让客户端发完数据立即关闭、同时发起几百个连接、在收发之间人为加长业务处理时间——亲眼看看epoll在这些场景下的行为比读十篇博文都管用。把LT吃透之后再切换到ET你会发现自己对“事件驱动”这四个字的理解会上一个台阶。
返回列表