ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux网络编程:彻底讲透select多路IO复用原理与实战

Linux网络编程:彻底讲透select多路IO复用原理与实战 如果你写过Linux下的网络服务程序一定绕不开一个问题一个进程同时盯着几十上百个socket怎么盯大多数新人第一反应是开线程来一个连接开一个线程。我当年也这么干过实测连接数一上来线程上下文切换就能把CPU拖到接近100%而且线程安全问题接踵而至。后来接触了select函数才第一次感受到什么叫多路IO转接——把原本需要自己反复检查的IO事件统一交给内核去盯。这篇文章就从原理、API使用、内核机制到实战排坑把select彻底讲透。适合正在学Linux网络编程的同学、做嵌入式Linux应用开发的工程师以及准备系统编程面试的人。1. 为什么需要select阻塞IO的天花板与多路转接的解题思路想理解select得先理解它出现之前的世界是什么样。不是所有人都经历过纯阻塞式网络编程的时代但这段背景决定了你后续对IO模型的理解深度。1.1 一个线程守一个socket为什么扛不住最早写TCP服务端最直观的做法就是主进程accept到一个连接然后fork一个子进程或者创建一个新线程让这个子进程/线程阻塞在recv上专门伺候这一个客户端。一连接一线程逻辑无比简单——每个线程只需要处理自己的fd不用管别人。但代价随连接数增长迅速失控。第一是内存开销。每次pthread_create默认会给线程分配独立的栈通常是8MB虚拟内存虽然物理内存按需分配但地址空间和内核栈仍然有真实成本。1000个连接就是1000个线程页面表、调度实体、信号处理这些元数据都成倍增长。第二是上下文切换。Linux内核的调度器是按照时间片轮转的线程数越多切换越频繁。我当年在一台4核机器上跑一连接一线程模型连接数到六百多的时候CPU idle直接跌到个位数而业务其实根本没多少数据在跑——CPU时间全耗在保存/恢复寄存器、切换页表、维护各种缓存上了。这就是典型的空转式并发。第三是同步复杂度。多个线程共享连接表、日志、统计计数器全部得加锁某个客户端异常断开时线程要安全地清理资源还容易踩到use-after-free。小项目勉强能跑一旦并发上来bug就开始四面开花。一个连接一个线程的方案本质上是拿线程数换并发数这条路走不远。1.2 非阻塞轮询看似省了线程实则是CPU空转既然阻塞在recv上不行那不加线程、直接在单线程里把所有fd设为非阻塞然后写一个死循环挨个read行不行技术上确实可以每个fd都用O_NONBLOCK打开没有数据时read返回EAGAIN继续看下一个。但这里有个很要命的点你无法预知数据什么时候来。这个循环在没有任何网络活动时依然会以极快的速度把所有fd轮一圈除了得到一堆EAGAIN之外什么也没干。fd数量越多一次完整轮询的成本越高而CPU的占用却是100%。就算sleep一两个毫秒再轮询延迟和空转也依然存在。阻塞模型的问题是一个线程只能等一个fd非阻塞轮询的问题是一个线程要在所有fd之间空转。两个方案都在用笨办法解决等待这件事。1.3 把等待交给内核select的模型与定位select的做法很聪明你把自己关心的所有fd塞给内核告诉内核这里面有任何一个可读、可写、出异常你就唤醒我。内核会把当前进程挂在这些fd对应的等待队列上有事件发生时再唤醒。这样进程在等待时是真正休眠的不占CPU也不需要为每个fd准备一个线程。这就是多路IO转接的由来原本N个fd就绪状态需要N次检查现在由一个select系统调用统一转接、统一汇报。你从挨个敲门问有没有人变成了按一个门铃有人应了再去看是谁。从历史地位看select是IO多路复用的始祖由POSIX标准定义Windows的Winsock里也有几乎一模一样的select。理解了select后面学poll、epoll就是看它们在哪些环节做了优化。2. 手写一个select模型服务端API用一遍胜过看十遍文档原理再清楚不写代码等于没学。这一节我会拆解select的全部API细节然后给出一段能直接编译运行的完整服务端代码逐段解释每个关键点。2.1 fd_set到底是什么位图与FD_ZERO/FD_SET/FD_ISSETselect最核心的数据结构是fd_set面试里被反复问。它本质上是一个位图bitmap每一位对应一个fd编号。比如bit 5为1表示编号5的fd被放进了这个集合bit 5为0表示不关心它。因为fd编号从0开始而位图默认是128字节也就是1024个bit所以传统上select能监视的fd被限制在1024以内——这个限制后面重点讲。操作fd_set只能用四个宏不应手动改内存FD_ZERO(fds); // 清空整个集合所有位置0 FD_SET(fd, fds); // 把fd对应的那一位设为1加入集合 FD_CLR(fd, fds); // 把fd对应的那一位设为0移出集合 FD_ISSET(fd, fds); // 判断fd是否在集合中是则返回非0这里有一个新手最容易忽略的大前提select调用会修改你传入的fd_set把它改写成当前实际就绪的fd集合。所以在select返回后FD_ISSET才用来确认某个fd是不是真的可读可写。如果你还指望下一次循环继续用同一个fd_set结果就是除了一开始就绪的那些fd其他全部丢失。正确做法是每次循环先FD_ZERO再重新FD_SET你关心的所有fd。2.2 原型逐参数拆解nfds为什么必须加1select的原型长这样int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);readfds要监视可读事件的fd集合放入后由内核改写成其中有可读事件的那些fd。不需要就传NULL。writefds监视可写事件。一般来说socket缓冲区不空就可写所以它经常被用来做发送大数据前的可以开始写信号。exceptfds监视异常事件最常见的是TCP带外数据。普通网络程序大多传NULL。timeout等待时间的上限。传NULL表示无限期阻塞直到有fd就绪传一个timeval指针那么超时后即便没有fd就绪也会返回0timeval两个字段都是0时select立即返回相当于非阻塞轮询。然后是那个著名的nfds参数。它的含义是内核需要检查的fd编号范围的最大值加1。比如你关心的fd中编号最大的是7那nfds传8内核就检查0到7这8个fd。为什么不是传最大fd本身因为内核内部的循环逻辑是从0遍历到nfds - 1你要让它看到7就得传7 1。传错了会非常隐蔽如果最大fd是7却传了7编号7的fd永远不在检查范围内它就算就绪select也感知不到。2.3 一段能直接编译运行的select服务端下面这段代码是一个最朴素的select模型TCP服务端。逻辑不复杂一个监听fd加上若干客户端fd放进同一个读集合等待并处理。我刻意让它保持单线程、无锁方便看清楚select的运行节奏。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/select.h #define MAX_CLIENTS 64 int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 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(8899); if (bind(listen_fd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listen_fd, 32) 0) { perror(listen); return 1; } int client_fds[MAX_CLIENTS]; for (int i 0; i MAX_CLIENTS; i) client_fds[i] -1; printf(server listening on 8899...\n); while (1) { fd_set read_fds; FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); int max_fd listen_fd; for (int i 0; i MAX_CLIENTS; i) { if (client_fds[i] 0) { FD_SET(client_fds[i], read_fds); if (client_fds[i] max_fd) max_fd client_fds[i]; } } int ret select(max_fd 1, read_fds, NULL, NULL, NULL); if (ret 0) { if (errno EINTR) continue; perror(select); break; } if (ret 0) continue; if (FD_ISSET(listen_fd, read_fds)) { int cfd accept(listen_fd, NULL, NULL); if (cfd 0) { perror(accept); continue; } printf(new client: %d\n, cfd); int placed 0; for (int i 0; i MAX_CLIENTS; i) { if (client_fds[i] -1) { client_fds[i] cfd; placed 1; break; } } if (!placed) { printf(too many clients, close %d\n, cfd); close(cfd); } } for (int i 0; i MAX_CLIENTS; i) { if (client_fds[i] 0 FD_ISSET(client_fds[i], read_fds)) { char buf[1024]; ssize_t n read(client_fds[i], buf, sizeof(buf) - 1); if (n 0) { if (n 0) printf(client %d closed\n, client_fds[i]); else perror(read); close(client_fds[i]); client_fds[i] -1; } else { buf[n] \0; printf(recv from %d: %s\n, client_fds[i], buf); } } } } close(listen_fd); return 0; }这个程序的主循环分三步先构建fd_set并算出max_fd然后调用select阻塞等待最后用FD_ISSET逐个检查哪些fd就绪并处理。注意每次循环都重新FD_ZERO和FD_SET这是必须的原因前面已经说过——select会改写集合。检查客户端fd时我按MAX_CLIENTS数组顺序遍历对每个client_fds[i]调用FD_ISSET。这里有个性能上的伏笔即便100个连接只有1个有数据你也要把100个fd全部查一遍这就是select的O(n)用户态开销。后面对比epoll时会再提到。2.4 返回值不会看等于白学selectselect的返回值有三种情况含义完全不同大于0就绪的fd总数。注意这个数是所有集合读写异常里就绪fd的个数之和不是你关心的连接数。等于0超时了超时时间内没有fd变成就绪状态。小于0出错了需要通过errno判断原因。最常见的恶作剧是EINTR——select等待期间进程收到了信号内核提前把它唤醒了。这种不是真正的错误业务逻辑里遇到EINTR应该继续select而不是退出或者报错。另外要记住只有当select返回值大于0时FD_ISSET的结果才有意义。如果返回0就去做FD_ISSET虽然不会崩溃但结果毫无价值。3. 内核视角看select轮询、拷贝与128字节限制的真相select被诟病慢限制多这些说法的根源都在内核实现里。搞懂这一层面试时被问到select为什么有1024限制select和epoll的本质区别是什么你就能答出别人答不出的深度。3.1 FD_SETSIZE与现实中的1024上限glibc里fd_set类型的定义是一个128字节的结构体换算成位就是1024个bit也就是说它最多能表达fd编号0到1023。FD_SETSIZE这个宏通常就定义为1024。对于能不能监视超过1024的fd这个问题需要分两层说清楚。第一层是用户态你用的fd_set就只有128字节FD_SET一个大于等于1024的fd行为是未定义的实际上也放不进去。第二层是内核态Linux内核的select实现并没有把这个硬编码成1024它是根据nfds动态分配临时位图的。如果你愿意绕过glibc自己定义一个超大位图、直接发起select系统调用理论上是能传超过1024号的fd的。但工程上没人这么干。原因很简单glibc内部很多代码都假设fd_set是128字节你传一个更大的位图进去稍有不慎就会内存越界而且就算你绕过了1024select本身的O(n)遍历也会在大量fd下拖垮性能。所以实践中大家直接把select最多支持1024个fd当成结论用这也是高并发场景必须上epoll的直接理由。3.2 一份fd_set的三次拷贝与全量轮询从性能角度看select一次调用做了三件并不便宜的事。第一件事是拷贝。fd_set进入内核时被拷贝一次内核根据就绪结果改写后再拷回用户态一次。fd_set虽然只有128字节但每调用一次select就要来两趟量小的时候无所谓量大且频繁时就是实打实的开销。第二件事是内核态的全量扫描。Linux的select底层其实会走到类似poll的机制针对你传入的每个fd内核都调用对应的vfs_poll拿到当前的事件掩码然后决定是否把进程加入该fd的等待队列。也就是说内核检查的fd数量和你传入的nfds成正比这是O(n)级别的循环。如果1000个fd里只有1个有数据内核照样把这1000个fd全部问一遍。第三件事是用户态的再次全量扫描。select返回后你手里只有一个位图根本不知道是哪个fd就绪必须自己拿着FD_ISSET把整个fd_set从0到max_fd全查一遍又是O(n)。三次叠加就是select在大规模fd下性能不行的完整答案。3.3 水平触发内核为什么会反复通知你select还有一个很容易被忽略的语义它是水平触发level-triggered的。意思是只要某个fd处于可读/可写状态select每次调用都会报告它就绪直到这个状态消失为止。比如你去读一个socket但application层只读了一半就把剩余数据留在内核缓冲区里那么下次调用select时这个fd照样会出现在可读集合里。如果你不清空缓冲区它就会永远通知你形成一个明显的busy loop。水平触发本身并不是bug反而是一种稳健的设计——事件处理到一半程序崩了下次select还能重新看到这个事件不容易漏。但如果你的处理逻辑有问题比如用一个阻塞read去读一个小消息而消息体还没到齐read会被卡住进而卡住整个服务端事件循环。所以用select的服务端客户端fd最好都设为非阻塞配合一个应用层缓冲区来收数据。这一点和epoll的默认模式是一致的只是epoll多提供了一种边缘触发模式可以进一步减少重复通知。4. select、poll、epoll横向对比它到底输在哪、赢在哪面试时select、poll、epoll三兄弟的区别几乎必考。但单纯背区别表没有意义你得知道每一条区别背后对应什么代价才能在实际项目里做对选型。4.1 横向参数对照表先说结论性的对比然后挑几个关键差异展开。特征selectpollepollfd数据结构fd_set位图pollfd数组内核事件表epoll_eventfd数量上限FD_SETSIZE通常1024受进程打开fd数限制受进程打开fd数限制每次调用是否重传全部fd是必须重新构造fd_set是必须重传pollfd数组否fd提前注册到内核内核返回内容修改后的位图修改后的事件掩码数组只返回就绪fd列表就绪检测方式内核和用户态各自O(n)遍历内核和用户态各自O(n)遍历内核回调用户态只读就绪列表跨平台POSIX标准Windows也支持POSIXWindows兼容较差Linux 2.6专属编程复杂度最低较低需要事件循环设计poll的pollfd数组是对fd_set的重要改良它是一个数组每个元素同时携带fd、关注的事件和实际发生的事件没有1024位的限制也不用FD_ISSET这种位运算。但它和select一样每次调用都要把整个数组从用户态拷到内核态内核逐项扫描后再拷回来依然逃不掉O(n)的路径依赖。4.2 epoll高效的本质事件表与回调epoll把监控fd集合这个事变成了三组系统调用epoll_create创建一张内核事件表epoll_ctl负责往这张表里增删改fdepoll_wait只负责等结果。从结构上看它和select/poll有个根本区别fd集合的维护作用域从每次调用的参数变成了内核里的一张持久表。这个设计带来两个直接收益。第一epoll_wait返回时只给用户态拷贝真正就绪的fd列表而不是把整个监控集合全部搬运一遍。这就把返回复杂度从O(n)降到了O(k)k是活动连接数。第二内核不再每次调用时全量扫描fd而是通过回调机制在fd就绪时主动把fd挂到就绪队列里。扫描的成本被人为消除了代价是每次注册和删除fd时要在红黑树上做一次操作。频率低连接建立/关闭时所以整体收益极大。这就是为什么在高并发、连接多但活跃比例低的场景下epoll碾压select和poll而在连接数很少、每个连接都高频活跃时两者差异并不明显。4.3 真实选型什么场景继续用select虽然epoll看起来全面占优但select在现实中仍有一席之地。跨平台性是最重要的理由。如果你写的库要同时跑在Linux、BSD、macOS甚至Windows上epoll只能在Linux用而select在几乎所有平台上都有标准实现。我做过一个嵌入式小工具目标平台是老旧内核加busybox环境没有epoll可用select就是最稳妥的答案。另一个场景是连接规模小的工具型程序。比如一个最多同时支撑几十个客户端的文件传输服务、一个测试用的模拟服务端用select写二十行代码就能跑完全没必要为了不到一百个fd引入epoll的事件循环架构。我的建议很简单Linux上做高并发业务直接用epoll嵌入式、跨平台、内部小工具大胆用selectpoll作为中间选项在实际项目里反而用得最少。5. 实战排坑我踩过的五个select相关经典问题select的API看起来简单但真跑到生产环境里坑一个接一个。下面是我在这些年实际项目中踩过、也帮别人排查过的五个典型问题每个都给出排查链路和修复方式。5.1 忘记重新填充fd_set代码跑了几天突然丢连接现象是最诡异的服务端跑着跑着某些客户端突然收不到任何数据了但连接还在重启程序又好了过段时间再次发生。排查链路走下来最后定位到主循环里。如果你的循环是先FD_SET所有fd然后select然后处理看起来没毛病但select返回后会改写read_fds只留下就绪的fd位。如果下一轮循环你在FD_SET之前没有FD_ZERO那么上一轮留下的就绪状态会被保留而其他原本在集合里的fd会因为没重新设置而丢出集合。表现就是只有上次恰好就绪过的fd还在被监视新连接进来了但你根本看不见它于是丢连接。修复方式很机械但必须养成习惯每次进入循环第一件事就是FD_ZERO然后无条件重新FD_SET所有你关心的fd。我在代码审查里看到select相关代码第一眼就找这个模式十次有八次问题出在这。5.2 listen_fd可读却看不到新连接select返回后如果FD_ISSET(listen_fd)成立说明有新的连接请求pending在accept队列里。有些新手以为select都告诉我了等一下再accept也没关系结果新连接迟迟不被accept队列越积越满。关键是TCP的accept队列是有上限的。accept队列满之后新到的SYN连接会被内核直接丢弃对端表现就是连接超时或失败。所以select只是告诉你该干活了你不能拖延。正确做法是在检测到listen_fd可读后用循环把accept队列里的连接尽可能都accept出来accept到返回EAGAIN为止。同时accept返回的新fd最好立刻设为非阻塞因为后续对这个fd的read/write如果阻塞住会卡死整个select事件循环。5.3 read返回0背后的TCP半关闭语义select可读不代表有光明正大的数据可以读。TCP有一个很容易忽略的状态对端调用close时如果它的发送缓冲区里已经没有数据那么你这边select仍然会报告这个fd可读而read返回0。这个0表示对端关闭了写端也就是半关闭状态。很多人拿到0之后还在纠结select说可读怎么没数据。排查思路是read返回值本身才是真正可靠的信息select只是告诉你这个fd有事发生至于是数据还是关闭必须由read/recv自己判断。read返回0就把fd从集合里移除并closeread返回-1且errno是EAGAIN/EWOULDBLOCK说明没有数据这是非阻塞socket的正常情况。还有更坑的场景对端进程崩溃时可能只发了RST而不是正常的FIN。这种情况下select的表现未必是可读它可能出现在异常集合里也可能直接导致后续write触发SIGPIPE。生产级代码要监听exceptfds并在write时忽略或屏蔽SIGPIPE否则服务端会被这个信号一声不吭搞死。5.4 timeout会被内核改写从超时变忙轮询某段代码用select实现每200ms做一次定时器轮询timeval设置成{0, 200000}跑了一段时间发现CPU占用异常高打进去一看select几乎没阻塞。原因现代Linux内核在select返回时会把timeval改写为剩余的等待时间。如果你在循环外面初始化了一个timeval每轮select都复用同一个变量那么第一次返回后timeval被改成接近0的值第二次调用select时参数里timeval接近0于是select立即返回形成忙轮询。解决办法是在每次循环里重新初始化timeval。不少跨平台代码还特意这么做因为POSIX标准并没有规定select一定要修改timeval不同系统的行为不完全一致。如果你在循环里每次都重构一遍timeval无论哪个平台都不会出这种问题。5.5 EINTR一次信号中断引发的假死有一个线上事故服务端每隔一段时间就完全卡住dmesg里没有任何异常进程还活着gdb看调用栈停在select上问题是超时设为永久没有fd就绪时它会一直睡。后来发现这其实不是卡住而是每次循环都把EINTR当成致命错误退出了主循环进程虽然还在但已经没有线程在处理新连接了。select在等待期间如果收到信号内核会把它提前唤醒返回值是-1errno是EINTR。这是一次非致命中断正确的语义是这一轮不算数重新再等。代码里必须写成if (ret 0) { if (errno EINTR) continue; // 其他错误才走异常流程 }如果需要更精细地控制信号屏蔽可以使用pselect。pselect和select的差别在于它可以传入一个信号掩码并在等待期间原子地替换进程信号掩码从而保证某些信号不会中断等待也比被中断后再continue更稳。6. 从select出发多路IO这条路还能怎么走把select彻底弄懂之后再往深挖就是一条完整的技术演进线select解决一个线程等一个fd的浪费poll解决select的1024上限和位图操作不便epoll解决poll每次调用全量拷贝和全量扫描的问题再往后是io_uring这种异步IO模型把系统调用的中断和上下文切换成本也压到极低。我自己的体会是select不一定是你最终会长期使用的工具但它非常适合当第一课。因为它的心智模型最简单一组fd交给内核等然后再检查。反过来说如果一开始直接学epoll很容易被各种概念绕晕比如为什么要先epoll_create、为什么fd要提前注册、边缘触发和水平触发到底差在哪。这些问题的答案本质上都藏在select的每次全量重新登记 每次全量扫描这两个短板里。最后分享一个小经验。我在代码里会把等待一组可读fd这个操作封装成一个带回调的统一入口这样底层用select还是epoll都能切换。早期嵌入式项目用select迁移到Linux服务器时把底层一换上层逻辑几乎不用改。而且封装之后每个连接的处理逻辑变成了独立的回调函数主循环再也不会膨胀成一坨几百行的if-else。这个抽象思路比select本身更值得复制到你的项目里。
返回列表