linux系统编程(九):select-poll-epoll多路复用

linux系统编程(九):select-poll-epoll多路复用 select / poll / epoll —— I/O 多路复用三剑客一个线程同时盯着 1 万个连接谁来数据就处理谁 —— 这就是 I/O 多路复用要解决的问题。三个工具select1983 年、poll1987、epollLinux 2.6, 2002。这篇把它们的接口、性能、适用场景、LT/ET 模式讲清楚。0. 引言C10K 问题90 年代末Web 服务开始撞上C10K 问题单机如何同时服务 1 万个并发连接关键矛盾不是“能不能创建连接”而是连接数量不能线性放大线程数量。方案线程 / 进程模型内存与调度成本适用结论每连接一个线程 / 进程1 万连接约等于 1 万执行单元默认 8MB 栈时光线程栈就约 80GB上下文切换也会爆炸只适合小并发或简单同步模型单线程 非阻塞 多路复用少量线程托管大量 fd线程只处理“已就绪”的 fd未就绪连接继续等待高并发网络服务的基础模型角色负责什么一句话理解应用线程调用select/poll/epoll_wait等待事件不再挨个阻塞读连接内核跟踪 fd 可读 / 可写 / 出错状态帮应用筛出“现在能处理”的连接多路复用 API在应用和内核之间传递关注集合与就绪集合问题变成“如何高效问内核谁就绪”引言结论select/poll/epoll的本质都是让一个线程同时管理很多 fd区别在于关注集合放哪里、返回后要不要全量扫描、复杂度能不能随就绪数收敛。1. select —— 老祖宗1983#includesys/select.hintselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*timeout);用 bitmap 表示我关心哪些 fdfd_set rfds;FD_ZERO(rfds);FD_SET(fd1,rfds);FD_SET(fd2,rfds);FD_SET(fd3,rfds);structtimevaltv{.tv_sec5};intnselect(maxfd1,rfds,NULL,NULL,tv);if(n0){if(FD_ISSET(fd1,rfds)){read(fd1,...);}if(FD_ISSET(fd2,rfds)){read(fd2,...);}if(FD_ISSET(fd3,rfds)){read(fd3,...);}}1.1 select 的硬伤#问题说明1fd 数量上限 1024fd_set是固定大小位图FD_SETSIZE1024重新编译 glibc 改不了硬限制2每次调用都要传所有 fd内核每次都把 bitmap 从用户态拷进内核态fd 多时 syscall 开销大3每次返回都要遍历所有 fdselect 返回后只告诉你有 N 个就绪要用FD_ISSET一个个查4bitmap 被改了返回时readfds已被改成就绪集合下次调用前要重新FD_SET全部性能O(n)n 是 fd 总数。fd 一万的话每次都扫一万CPU 占用爆炸。但 select 的优点是POSIX 标准、跨平台都有。简单跨平台工具仍在用。2. poll —— 1987 年的小升级#includepoll.hstructpollfd{intfd;shortevents;/* 你关心的事件 */shortrevents;/* 内核返回的就绪事件 */};intpoll(structpollfd*fds,nfds_tnfds,inttimeout_ms);跟 select 思路一样但用数组替代 bitmapstructpollfdpfd[3];pfd[0].fdfd1;pfd[0].eventsPOLLIN;pfd[1].fdfd2;pfd[1].eventsPOLLIN;pfd[2].fdfd3;pfd[2].eventsPOLLIN|POLLOUT;intnpoll(pfd,3,5000);if(n0){for(inti0;i3;i){if(pfd[i].reventsPOLLIN)read(pfd[i].fd,...);if(pfd[i].reventsPOLLOUT)write(pfd[i].fd,...);}}2.1 poll 解决了什么 没解决什么方面✅ 解决了❌ 没解决fd 数量没有 1024 限制数组想多大都行—events / revents分离设计原数组不被改下次调用不用重置—传 fd—每次调用还是要传所有 fdsyscall 拷贝 O(n)扫 fd—每次返回还是要遍历所有 fd 扫 reventsO(n)整体性能—fd 多时性能仍差2.2 events 常用值标志含义POLLIN可读POLLOUT可写POLLPRI紧急数据可读OOBPOLLERR错误revents 自动置位不用 events 加POLLHUP对端关闭同上POLLNVALfd 无效POLLRDHUP对端半关闭Linux 扩展需要 events 显式加3. epoll —— Linux 杀手级特性2002select / poll 的根本问题每次调用都要传/扫全部 fd。epoll 的设计设计解决的痛点fd 集合常驻内核不用每次传消除 syscall 拷贝开销内核直接告诉你哪些就绪不用扫消除 O(n) 遍历性能从O(n)降到O(就绪数)。3.1 三个核心 API#includesys/epoll.hintepoll_create1(intflags);intepoll_ctl(intepfd,intop,intfd,structepoll_event*event);intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);structepoll_event{uint32_tevents;epoll_data_tdata;/* union: ptr / fd / u32 / u64 */};3.2 用法骨架/* ① 创建 epoll 实例 */intepfdepoll_create1(EPOLL_CLOEXEC);/* ② 注册 fd */structepoll_eventev;ev.eventsEPOLLIN;ev.data.fdfd;epoll_ctl(epfd,EPOLL_CTL_ADD,fd,ev);/* ③ 等待事件 */structepoll_eventevs[64];while(1){intnepoll_wait(epfd,evs,64,-1);for(inti0;in;i){intfdevs[i].data.fd;if(evs[i].eventsEPOLLIN)handle_read(fd);if(evs[i].eventsEPOLLOUT)handle_write(fd);}}/* ④ 注销也可以 close fd 自动注销 */epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);3.3 epoll_ctl 操作操作含义EPOLL_CTL_ADD注册新 fdEPOLL_CTL_MOD修改已注册 fd 的关注事件EPOLL_CTL_DEL注销3.4 epoll 性能操作复杂度实现添加 fdO(log n)红黑树等待事件O(就绪数)跟总 fd 数无关10 万个 fd只有 100 个就绪 →epoll_wait只返回 100 个O(100)。这就是 epoll 牛在哪。4. LT vs ET —— epoll 两种触发模式4.1 LTLevel-Triggered默认只要 fd 处于就绪状态每次 epoll_wait 都会返回它。ev.eventsEPOLLIN;/* 默认 LT */例子fd 来了 100 字节你只 read 了 50。下次 epoll_wait还会通知你这个 fd 可读。像 select / poll行为直观不容易漏。4.2 ETEdge-Triggered只在 fd 状态变化时通知一次。ev.eventsEPOLLIN|EPOLLET;例子fd 从无数据变成有数据时通知一次。如果你只 read 了 50 字节剩下 50 字节不会再触发通知要等下次有新数据来才再触发。ET 模式下必须循环 read 到 EAGAIN否则数据卡住while(1){ssize_tnread(fd,buf,sizeofbuf);if(n0)handle(buf,n);elseif(n0){close(fd);break;}elseif(errnoEAGAIN)break;/* ⭐ 关键读完了 */else{close(fd);break;}}ET 必须配合非阻塞 fd否则 read 读完会阻塞造成 starvation。4.3 LT vs ET 对比维度LT默认ET通知方式持续通知状态变化时通知一次编程难度简单复杂必须 read 到 EAGAIN性能略低多次返回同一 fd高每个事件只一次必须非阻塞不强制强制推荐场景业务代码高性能服务器nginx 用 ET新手用 LT熟练后再 ET。5. 三者性能对比10 万 fd1 千个活跃的场景实测典型方法单次开销CPU 用量select~3 ms80%poll~3 ms80%epoll~0.05 ms2%epoll 比 select / poll 快 50-100 倍并发越大差距越大。6. epoll 进阶用法6.1 EPOLLONESHOT —— 多线程友好ev.eventsEPOLLIN|EPOLLONESHOT;每个事件触发后自动从关注集合摘除直到 EPOLL_CTL_MOD 重新加入。多线程 worker 模型下避免同一个 fd 被多个线程同时处理。6.2 EPOLLEXCLUSIVE —— 防惊群ev.eventsEPOLLIN|EPOLLEXCLUSIVE;多个线程的 epoll fd 都监听同一个 listen socket新连接来时只唤醒一个而不是全部。Linux 4.5 支持。nginx worker 用这个避免惊群。6.3 epoll_pwait —— 信号安全的 epoll_waitintepoll_pwait(intepfd,structepoll_event*events,intmaxevents,inttimeout,constsigset_t*sigmask);类似 pselect / ppoll原子地屏蔽信号 epoll_wait 恢复。6.4 epoll_create1(0) vs epoll_create(N)intepepoll_create(1);/* 老接口N 是 hint已废弃 */intepepoll_create1(0);/* 现代接口 */intepepoll_create1(EPOLL_CLOEXEC);/* 推荐自动 close-on-exec */7. 多路复用配合定时器epoll_wait 的最后参数timeout毫秒timeout值行为 0最多等这么久0立即返回轮询-1永久阻塞但只有一个超时不够用——如果同时有 N 个不同截止时间的任务需要专门的定时器做法实现代表timerfd把定时器变成 fdepoll 一起管Linux 原生下篇细讲小顶堆 / 红黑树自己维护任务队列按最早截止时间设epoll_wait超时libevent / libuv8. 一个完整的 epoll TCP echo server#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includefcntl.h#includeerrno.h#includesys/socket.h#includesys/epoll.h#includenetinet/in.h#defineMAX_EV64staticvoidset_nonblock(intfd){fcntl(fd,F_SETFL,fcntl(fd,F_GETFL)|O_NONBLOCK);}intmain(void){intsrvsocket(AF_INET,SOCK_STREAM|SOCK_NONBLOCK|SOCK_CLOEXEC,0);intyes1;setsockopt(srv,SOL_SOCKET,SO_REUSEADDR,yes,sizeofyes);structsockaddr_inaddr{.sin_familyAF_INET,.sin_porthtons(8080),.sin_addr.s_addrINADDR_ANY};bind(srv,(structsockaddr*)addr,sizeofaddr);listen(srv,128);intepepoll_create1(EPOLL_CLOEXEC);structepoll_eventev{.eventsEPOLLIN,.data.fdsrv};epoll_ctl(ep,EPOLL_CTL_ADD,srv,ev);structepoll_eventevs[MAX_EV];while(1){intnepoll_wait(ep,evs,MAX_EV,-1);for(inti0;in;i){intfdevs[i].data.fd;if(fdsrv){intconn;while((connaccept4(srv,NULL,NULL,SOCK_NONBLOCK|SOCK_CLOEXEC))0){ev.eventsEPOLLIN|EPOLLET;ev.data.fdconn;epoll_ctl(ep,EPOLL_CTL_ADD,conn,ev);}/* accept 到 EAGAIN 才停 */}else{/* ET 必须循环 read 到 EAGAIN */charbuf[4096];while(1){ssize_trread(fd,buf,sizeofbuf);if(r0){write(fd,buf,r);/* 简化忽略写不完的情况 */}elseif(r0){close(fd);break;}elseif(errnoEAGAIN){break;}else{close(fd);break;}}}}}return0;}100 行实现单线程能扛 10 万连接的 echo server。9. 几个常见坑9.1 ET 模式不读到 EAGAIN/* ⛔ ET 下读一次就 break剩余数据卡住 */ssize_tnread(fd,buf,100);handle(buf,n);ET 必须 while EAGAINwhile(1){ssize_tnread(fd,buf,100);if(n0)break;handle(buf,n);}9.2 LT 模式 EPOLLOUT 不取消注册LT 下 EPOLLOUT 在 fd 可写时一直触发。如果你没数据要写应当 EPOLL_CTL_MOD 把 EPOLLOUT 关掉否则 CPU 100%每次 epoll_wait 都返回。正确做法有数据要写时才注册 EPOLLOUT写完了取消。9.3 fd 关闭后没从 epoll 删close(fd)会自动从 epoll 注销但前提是这是最后一个引用 file 的 fd。如果有 dup 出来的副本close 一个不算。建议显式 EPOLL_CTL_DEL。9.4 epoll fd 泄漏epoll_create1 返回的也是 fd要 close。多 reactor 模型创建一堆 epfd记得清理。9.5 多线程并发 epoll_wait 同一个 epfd可以Linux 内核支持但要用 EPOLLEXCLUSIVE 防惊群否则一个事件唤醒所有等待线程。10. 总结表API出现年代fd 上限复杂度推荐度select19831024O(n)简单跨平台工具poll1987无O(n)比 select 略好epoll2002无O(就绪数)⭐⭐⭐ Linux 服务器必选跨平台等价物LinuxBSD/macOSWindowsepollkqueueIOCPLinux 上服务器代码 100% 选 epoll。跨平台框架libevent / libuv / Boost.Asio会自动选用。11. 收尾I/O 多路复用的演进史就是如何高效问内核谁就绪的演进今天 99% 的 Linux 高并发服务底层是 epollnginx、Redis、Node.js、Go runtime 网络层、Java NIO Linux backend ……