【C/C】select、poll、epoll 实战对比从 fd_set 到就绪事件列表1. 为什么需要 IO 多路复用上一篇多线程 TCP 服务端的模型是每来一个连接就创建一个线程阻塞在recv()上。这个模型好理解但连接数一多线程数、内存、上下文切换都会成为瓶颈。IO 多路复用解决的是另一个问题让一个线程同时管理多个 fd。线程不再盯着某一个连接阻塞而是把一批 fd 交给内核等内核告诉我们“哪些 fd 有事件”。本项目分别实现了三个版本tcp_server_select.c用fd_set管理连接。tcp_server_poll.c用struct pollfd数组管理连接。tcp_server_epoll.c用epoll_ctl注册事件用epoll_wait获取就绪事件。2. select每一轮都要准备 fd_setselect的核心 API 是intactivityselect(maxfd1,tempfds,NULL,NULL,NULL);项目中的关键写法是先保存一份总集合readfds每次调用前复制到临时集合fd_set readfds;FD_ZERO(readfds);FD_SET(serverfd,readfds);intmaxfdserverfd;while(1){fd_set tempfdsreadfds;intactivityselect(maxfd1,tempfds,NULL,NULL,NULL);if(activity0){perror(select);continue;}为什么要复制因为select()返回后会修改传进去的 fd 集合只保留就绪 fd。如果下一轮继续拿同一个集合调用就会丢失没有就绪的连接。监听 socket 就绪表示有新连接if(FD_ISSET(serverfd,tempfds)){structsockaddr_inclient_addr;socklen_tclient_lensizeof(client_addr);intclientfdaccept(serverfd,(structsockaddr*)client_addr,client_len);FD_SET(clientfd,readfds);if(clientfdmaxfd)maxfdclientfd;}普通客户端 fd 就绪表示可读for(intiserverfd1;imaxfd;i){if(FD_ISSET(i,tempfds)){charbuffer[1024];ssize_tnrecv(i,buffer,sizeof(buffer)-1,0);if(n0){close(i);FD_CLR(i,readfds);}else{buffer[n]\0;send(i,buffer,n,0);}}}select的缺点也在这段代码里体现出来了应用层需要从小到大扫描 fdfd 很多时会浪费大量遍历成本。3. poll用数组管理 fd事件更清楚poll把 fd、关注事件、返回事件放在一个结构体里structpollfdfds[1024];fds[0].fdsockfd;fds[0].eventsPOLLIN;intnfds1;调用方式比select更直接intretpoll(fds,nfds,-1);if(ret0){perror(poll);continue;}新连接到来时把客户端 fd 追加到数组if(fds[0].reventsPOLLIN){intclientfdaccept(sockfd,(structsockaddr*)client_addr,client_len);fds[nfds].fdclientfd;fds[nfds].eventsPOLLIN;nfds;}客户端断开时项目里用了一个很实用的技巧用最后一个元素覆盖当前元素然后nfds--。if(n0){close(fds[i].fd);fds[i]fds[nfds-1];nfds--;i--;}这样删除数组中间元素时不需要整体搬移。i--是为了重新检查被覆盖过来的新元素。poll相比select的进步不依赖FD_SETSIZE默认大小。events和revents分开关注事件和实际事件更清晰。不需要每轮重新构造fd_set。但它仍然需要把整个数组传给内核也仍然要扫描数组。4. epoll注册一次等待就绪事件epoll的使用流程更像“先注册再等待”intepollfdepoll_create1(0);structepoll_eventev,events[1024];ev.eventsEPOLLIN;ev.data.fdsockfd;epoll_ctl(epollfd,EPOLL_CTL_ADD,sockfd,ev);事件循环里直接等待就绪事件intnfdsepoll_wait(epollfd,events,1024,-1);if(nfds0){perror(epoll_wait);continue;}for(inti0;infds;i){if(events[i].data.fdsockfd){intclientfdaccept(sockfd,(structsockaddr*)client_addr,client_len);ev.eventsEPOLLIN;ev.data.fdclientfd;epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,ev);}else{ssize_tnrecv(events[i].data.fd,buffer,sizeof(buffer)-1,0);if(n0){close(events[i].data.fd);epoll_ctl(epollfd,EPOLL_CTL_DEL,events[i].data.fd,NULL);}}}epoll_wait()返回的是就绪事件数组不需要像select/poll那样扫描完整连接表。这也是它适合大量连接的关键原因。5. 三者对比表模型fd 管理方式每轮是否重建集合是否扫描全部 fd适合场景selectfd_set位图需要需要入门理解、小规模 fdpollpollfd数组不需要重建但数组要传入内核需要中小规模连接epoll内核维护兴趣集合不需要不需要扫描全部只返回就绪项大量长连接6. 编译运行gcc tcp_server_select.c-oselectgcc tcp_server_poll.c-opoll gcc tcp_server_epoll.c-oepoll分别启动./select ./poll ./epoll用nc测试nc127.0.0.18080hello需要注意的是本项目的 select 和 poll 版本会 echo 回客户端epoll 基础版本主要演示读事件注册和删除收到数据后打印到服务端终端。如果希望它也 echo在recv()成功分支中补一行send(events[i].data.fd, buffer, n, 0);即可。7. 小结select - poll - epoll的演进本质是 fd 管理方式的演进select应用每轮准备集合内核返回后还要应用扫描。poll数组结构更清楚但仍要传递和扫描整张表。epoll内核保存兴趣集合应用只处理就绪事件。理解这三者之后再看 Reactor 模式就不会突兀。Reactor 其实就是在epoll事件循环上继续抽象把不同 fd、不同事件分发给对应的回调函数。学习链接: https://github.com/0voice