高并发网络开发:select、poll、epoll 多路复用实战

高并发网络开发:select、poll、epoll 多路复用实战
高并发网络开发select、poll、epoll 多路复用实战一、引言上一篇文章我们写了个 TCP 服务端——单线程、一次只服务一个客户端。这种模式在嵌入式设备上勉强能用毕竟通常只有一个上位机连接但遇到需要同时服务几十上百个客户端的高并发场景就彻底歇菜了。那是不是可以每个客户端都fork一个进程或者pthread_create一个线程来处理理论可行但进程/线程的开销太大了——内存、调度、上下文切换数量一多系统直接崩溃。正确的解法是IO 多路复用Multiplexing一个线程同时监听多个 Socket哪个有数据就处理哪个。Linux 提供了三种标准 APIselect、poll、epoll。本文一次讲清三者的区别并给出 epoll 的高并发实战代码。二、三兄弟对比特性selectpollepoll监听上限FD_SETSIZE默认1024无限制无限制数据结构fd_set位图固定大小struct pollfd动态数组红黑树 就绪链表遍历方式每次 O(n) 全量扫描每次 O(n) 全量扫描O(1) 只返回就绪的数据复制每次调用都要全部重新设置每次调用传入只需添加/删除一次内核态开销大每次重新设置中小事件驱动适用规模几十个连接几百个连接几千到几十万连接一句话select 和 poll 每隔一段时间去逐一巡查所有连接轮询模式epoll 则是让内核主动通知哪个连接有事件事件驱动模式。select/poll: 用户态 ─── 遍历所有fd(1到N) ─── 内核 ↓ 用户态 ←── 返回所有fd,你判断哪些就绪 ←── 内核 epoll: 用户态 ─── epoll_ctl 注册事件 ─── 内核红黑树 用户态 ←── epoll_wait 只返回就绪的fd ←── 内核就绪链表三、select 示例仅供学习#includesys/select.h#includesys/time.hfd_set readfds;FD_ZERO(readfds);FD_SET(server_fd,readfds);while(1){fd_set tmpreadfds;// 每次都要复制因为 select 会修改它intretselect(max_fd1,tmp,NULL,NULL,NULL);if(ret0){perror(select);break;}for(intfd0;fdmax_fd;fd){if(!FD_ISSET(fd,tmp))continue;if(fdserver_fd){// 新连接intclientaccept(server_fd,...);FD_SET(client,readfds);if(clientmax_fd)max_fdclient;}else{// 客户端数据handle_client(fd);}}}select 的槽点很明显fd_set大小固定最大 1024每次调用前要重新复制fd_set每次调用后要遍历所有 fd 找出就绪的四、epoll 实战高并发服务端epoll 是 Linux 独有的高性能 IO 多路复用机制也是 Nginx、Redis 等高性能服务器的底层基石。三个核心函数#includesys/epoll.h// 创建 epoll 实例intepoll_create1(intflags);// 注册/修改/删除监听事件intepoll_ctl(intepfd,intop,intfd,structepoll_event*event);// 等待事件intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);扩展实现epoll 内部使用红黑树管理所有监听的文件描述符增删改 O(log n)当某个 fd 就绪时内核将其插入到就绪链表epoll_wait直接从链表中取——复杂度 O(1)。完整的 epoll 高并发服务器#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includeerrno.h#includefcntl.h#includearpa/inet.h#includesys/epoll.h#definePORT8888#defineMAX_EVENTS1024#defineBUF_SIZE1024/* 设置 fd 为非阻塞 */staticvoidset_nonblocking(intfd){intflagsfcntl(fd,F_GETFL,0);fcntl(fd,F_SETFL,flags|O_NONBLOCK);}/* 处理客户端数据 */staticvoidhandle_client(intclient_fd){charbuf[BUF_SIZE];ssize_tnread(client_fd,buf,sizeof(buf)-1);if(n0){// 客户端断开if(n0){printf([客户端 %d] 正常断开\n,client_fd);}elseif(errno!EAGAIN){perror(read);}close(client_fd);return;}buf[n]\0;printf([客户端 %d] 收到: %s\n,client_fd,buf);// 回显简化处理write(client_fd,buf,n);}intmain(void){intserver_fd,epoll_fd;structsockaddr_inaddr;// ---------- 1. 创建监听 Socket ----------server_fdsocket(AF_INET,SOCK_STREAM,0);set_nonblocking(server_fd);intopt1;setsockopt(server_fd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));memset(addr,0,sizeof(addr));addr.sin_familyAF_INET;addr.sin_addr.s_addrINADDR_ANY;addr.sin_porthtons(PORT);bind(server_fd,(structsockaddr*)addr,sizeof(addr));listen(server_fd,SOMAXCONN);// ---------- 2. 创建 epoll 实例 ----------epoll_fdepoll_create1(0);if(epoll_fd0){perror(epoll_create1);exit(1);}// ---------- 3. 将 server_fd 加入 epoll ----------structepoll_eventev,events[MAX_EVENTS];ev.eventsEPOLLIN;// 监听可读事件ev.data.fdserver_fd;epoll_ctl(epoll_fd,EPOLL_CTL_ADD,server_fd,ev);printf(epoll 高并发服务器启动, 端口%d\n,PORT);// ---------- 4. 事件循环 ----------while(1){intnfdsepoll_wait(epoll_fd,events,MAX_EVENTS,-1);// -1 永久等待if(nfds0){perror(epoll_wait);break;}for(inti0;infds;i){intfdevents[i].data.fd;if(fdserver_fd){// 新连接while(1){structsockaddr_inclient_addr;socklen_tlensizeof(client_addr);intclientaccept(server_fd,(structsockaddr*)client_addr,len);if(client0){if(errnoEAGAIN||errnoEWOULDBLOCK)break;// 没有更多连接了perror(accept);break;}printf([新客户端] fd%d, %s:%d\n,client,inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));set_nonblocking(client);ev.eventsEPOLLIN|EPOLLET;// 边缘触发ev.data.fdclient;epoll_ctl(epoll_fd,EPOLL_CTL_ADD,client,ev);}}else{// 客户端数据handle_client(fd);}}}close(server_fd);close(epoll_fd);return0;}边缘触发 (ET) vs 水平触发 (LT)epoll 有两种工作模式模式行为说明LTLevel Triggered默认fd 只要还有数据可读每次epoll_wait都会通知不容易丢事件代码简单ETEdge Triggered只在 fd 状态变化时通知一次比如从无数据变为有数据性能更高但必须用非阻塞 IO 循环读到 EAGAIN上面代码使用了EPOLLET边缘触发代价是代码必须循环accept和循环read直到 EAGAIN但换来了更高的吞吐量。// ET 模式下的正确处理方式循环读到无数据为止while(1){ssize_tnread(fd,buf,sizeof(buf));if(n0){if(errnoEAGAIN)break;// 读完了// 真错误close(fd);break;}if(n0){close(fd);// 对端关闭break;}// 处理这 n 字节数据...}五、三种 IO 模型的性能拓扑并发连接数 │ │ epoll ─────────→ │ poll ─────→ │ select ──→ │ fork ─→ └────────────────────────────────────→ 性能在几十个连接的量级三种方案差别不大。但到了几百甚至几千个连接epoll 的优势就碾压式地显现出来——因为它只处理有事件的 fd不浪费一个 CPU 周期在空闲连接上。六、总结select实现简单但不堪重负最大 1024每次全量复制poll取消了 fd 数量限制但仍是轮询模式epoll事件驱动红黑树管理O(1) 获取就绪事件——高性能服务器的标配LT vs ETLT 省心ET 高效但要求非阻塞 IO实际选择嵌入式设备几十个连接用 select/poll 完全够工业级服务器用 epoll把 epoll 的epoll_create1 → epoll_ctl → epoll_wait这套流程写熟练你就算真正入门了 Linux 高性能网络编程。