ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入select模型:从fd_set位图到TCP服务器实战

2026/9/11 12:56:02 拓冰建站 浏览量
深入select模型:从fd_set位图到TCP服务器实战 写了好几年网络编程相关的代码见过太多初学者在select模型上栽跟头。有人觉得它太老掉牙一上来就追着epoll跑也有人从教科书上背了一堆概念真到写服务器的时候连fd_set怎么初始化都搞不清楚。其实select模型这东西确实不是最先进的但它背后那套把多个fd交给内核统一监视的思想是所有I/O多路复用的地基。你要是能把select吃透后面再去理解poll、epoll基本就是顺着同一个思路往下走一点都不费劲。这篇文章我不打算给你念教科书。我想用一个真实的TCP服务器为例从为什么需要它它到底怎么工作代码怎么一行行写实际项目中会踩哪些坑这几个角度把select模型拆透了讲。适合刚接触socket编程的读者也适合那些会调API但始终没搞明白底层逻辑的人。1. 阻塞I/O的困局一个客户端就把服务器卡死了1.1 最简单的accept循环问题出在哪你第一次写TCP服务器的时候大概率是这么干的创建一个socketbind一个端口listen开始监听然后一个while循环里不停地调accept等客户端来连接。单个连接的场景下这套代码跑得挺好。但只要客户端一多马上就露馅——accept是一个阻塞调用程序执行到它这里就停住了得等有客户端发起连接才会返回。这就意味着当服务器忙着跟某个客户端进行业务交互、收发数据的时候如果这时候有另一个客户端发来连接请求服务器根本无暇顾及它还在上一个连接的逻辑里没出来呢。换句话说这是一个单线程、串行处理模型整个服务器的吞吐能力被一条连接死死拽住。我知道有人会立刻说那我用多线程不就行了主线程accept每来一个连接就开一个线程去处理。这个方案确实能解决问题但它是有代价的。线程的创建和销毁要消耗系统资源每条线程还要分一块独立的栈空间线程之间的切换也有开销。你服务的客户端从几个变成几百个的时候线程数量跟着往上飙操作系统光是维护线程上下文就够受的。1.2 你以为多线程就万事大吉了IO等待依然浪费CPU更隐蔽的问题在于即使你开了多线程每条线程里的收发操作仍然是阻塞的。recv函数在没有数据到达的时候会一直堵着CPU被白白空转在等待上。表面上看起来是多个客户端被同时处理实际上系统大量的算力都耗在了线程调度的无谓开销上。那有没有一种办法让一个进程/线程同时盯着所有的连接哪个连接有数据来了我就去处理哪个这就是select模型的核心思想我不是一个个地等待每个socket而是把我关注的一堆socket统统交给内核内核帮我看好了一旦任何一个socket变得可读或者可写立刻通知我。这样我就能把精力集中在真正有事情要处理的socket上而不是干等着某一个。这个思路放到生活里类比一下就很好懂你在办公室等几份文件如果是阻塞I/O你只能一份一份地催盯着一个人干完才去问下一个。而select模型相当于你雇了一个前台把所有文件都交给他盯着谁送来了他喊你一声你再去拿。你的时间没有被任何一份文件绑死谁有货你就处理谁。2. fd_set位图理解select底层机制的钥匙2.1 那几个宏到底做了什么select模型的核心数据结构是fd_set很多初学者第一次看到这个类型就懵了。它本质上就是一个位图bitmap每一个bit代表一个文件描述符的状态。比如fd_set里有1024个bit那第5个bit如果置为1就表示我关注fd5这个socket。为了让这个位图用着方便系统提供了四个宏FD_ZERO(fd_set *set)把整个集合清零也就是不关注任何fd。FD_SET(int fd, fd_set *set)把fd对应的bit置1表示把这个fd加进关注集合。FD_CLR(int fd, fd_set *set)把fd对应的bit清零移除关注。FD_ISSET(int fd, fd_set *set)检查fd对应的bit是否为1判断这个fd是否有事件发生。我在实际教学的时候经常看到有人写成FD_SET(server_fd, read_fds)却忘了在一开始先FD_ZERO(read_fds)结果初始化出来的位图里残留了垃圾数据行为完全不可预测。这块必须养成肌肉记忆先ZERO再SET。这有点像一个登记表你得先把所有名字划掉再往里填今天要关注的人不然旧记录混在里面你以为你只关注了几个实际上内核拿到的是个乱七八糟的名单。2.2 select的三类监视集合select可以同时监视三类事件对应三个fd_set指针参数readfds监视哪些fd可读也就是说有没有数据到达、有没有新连接请求。writefds监视哪些fd可写一般用于判断发送缓冲区是否还有空间。exceptfds监视哪些fd有异常比如带外数据OOB。绝大多数的业务场景里核心关注的是readfds。服务器要第一时间知道有客户端连进来了或者某个客户端发了数据过来。writefds常用的场景很少一般在你向一个发送缓冲区已经满了的socket继续写数据时才会关心它什么时候变得可写。这三个参数都有一个特点它们是值-结果参数。在调用select之前你传入的是我想要监视哪些fd这个名单select返回之后内核会把真正发生事件的fd集合覆盖写回来你只能从返回值里看到哪些fd有事。换句话说select返回之后原来的名单就被改了你再想拿下一轮使用就得重新复制一份。2.3 用户态到内核态再回到用户态的完整链路select的工作过程如果拆开讲大致是这样几步用户程序把fd_set从用户态拷贝到内核态。内核拿着这份fd列表逐个检查对应socket的事件状态。没有任何事件发生的时候内核把进程挂起进入阻塞状态。直到某个socket有了事件或者超时时间到内核唤醒进程。内核把有事件发生的fd集合重新拷贝回用户态。用户程序通过FD_ISSET挨个检查哪些fd就绪了然后处理。这个流程里有一个必须理解的细节select返回的只是有多少个fd就绪这个数字它不会告诉你具体是哪几个。你要找出具体是哪些fd就得自己从你维护的最大fd编号往下遍历一个一个FD_ISSET。这就是select的一个明显缺点每次返回后都要做O(n)的轮询来定位事件源n是你监听的fd数量。fd少的时候无所谓fd一多这个遍历开销就很可观。我一直觉得理解了这一步才算真正理解select。面试的时候我也喜欢问这个问题——很多人知道select能监视多个fd但说不清楚它返回之后你还要自己遍历这件事。能把这块讲通透的人说明是真的在网上写过代码不是背了两页八股文。3. 手写一个Select模型TCP服务器代码逐段拆解3.1 初始化socket、bind、listen的老三样写一个基于select的TCP服务器前几步跟普通的socket编程完全一样创建socket、设置端口复用、bind、listen。这段代码比较好写我直接放出来#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include sys/select.h #include errno.h #define PORT 8888 #define MAX_CLIENTS 128 int main(void) { int server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(EXIT_FAILURE); } int opt 1; setsockopt(server_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(PORT); if (bind(server_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(EXIT_FAILURE); } if (listen(server_fd, 64) 0) { perror(listen); exit(EXIT_FAILURE); } printf(select server listening on port %d\n, PORT);SO_REUSEADDR这个选项很多人会忽略但它非常实用。没有它的话服务器程序重启时会因为TIME_WAIT状态而报出Address already in use开发时极其折磨人。listen的backlog参数表示内核中排队等待accept的连接数量上限64算是个相对合适的值。3.2 维护一份全量fd集合初始化完监听socket之后select模型的核心准备工作就开始了你要建两份fd_set。一份是当前所有的连接的总名单叫它all_fds另一份是本次select要监视的名单在循环里每次从all_fds复制出来叫它read_fds。fd_set all_fds, read_fds; FD_ZERO(all_fds); FD_SET(server_fd, all_fds); int max_fd server_fd;这里有一个非常容易搞混的点为什么不能直接用一个read_fds反复用原因我在前面讲过了select调用返回之后内核会把read_fds覆盖掉只保留就绪的fd。所以下一轮循环你必须通过read_fds all_fds来恢复完整的名单。如果你偷懒想省一次复制结果就是你下一轮select监视的fd越来越少最后整个服务器基本等于瘫痪。max_fd这个变量也是必不可少的。select的第一个参数是最大fd编号1内核需要这个值来确定扫描的范围。每次有新连接加入你都要检查一下这个客户端的fd是不是比当前max_fd大是的话就更新。这一步漏了新加的fd永远不会被select扫描到客户端连上了也是白连。3.3 select循环阻塞、返回、分发事件主循环是select模型最核心的部分我先把代码放出来然后一段一段讲while (1) { read_fds all_fds; int ready select(max_fd 1, read_fds, NULL, NULL, NULL); if (ready 0) { if (errno EINTR) { continue; } perror(select); break; } if (FD_ISSET(server_fd, read_fds)) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } FD_SET(client_fd, all_fds); if (client_fd max_fd) { max_fd client_fd; } printf(new connection accepted, fd %d\n, client_fd); } for (int fd server_fd 1; fd max_fd; fd) { if (!FD_ISSET(fd, read_fds)) { continue; } char buf[1024]; ssize_t n recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; printf(recv from fd %d: %s\n, fd, buf); send(fd, buf, n, 0); } else if (n 0) { printf(client fd %d disconnected\n, fd); close(fd); FD_CLR(fd, all_fds); } else { perror(recv); close(fd); FD_CLR(fd, all_fds); } } } close(server_fd); return 0; }第一层理解select阻塞等待直到有人连接或有数据到达一旦返回ready就是就绪fd的个数。如果小于0说明出错EINTR错误表示被信号打断这种情况不应该当作致命错误而是继续循环。第二层理解先单独判断监听socket是否就绪。为什么socket排在第一个因为它的优先级最高有新的连接请求进来了你必须第一时间去accept把新客户的fd纳入监视范围。如果不及时accept内核里accept队列总有一天会堆积到上限新连接就进不来了。第三层理解遍历所有可能的fd从server_fd 1开始到max_fd结束。对每个fd用FD_ISSET检查是否真的就绪。有人说这里遍历会不会很慢对于几百个fd的规模效率完全可以接受但等你到了几千个fd这个O(n)的遍历就成了性能瓶颈。在事件处理分支里recv返回值有三种情况要注意n 0收到数据正常处理。我在例子里做了回显。n 0对端关闭连接。这时候必须调用close(fd)并且用FD_CLR把fd从all_fds里移除。n 0读取出错严格来说要检查errno再决定是否真的关闭但简单场景下直接关闭是最稳妥的。这段代码完整的可运行性我已经验证过你自己跑一遍用nc工具或者telnet连接上来发消息就能直观感受到select的工作过程。我建议你把printf打出来的信息配合netstat命令一起看会发现连接的状态变化和你程序里的日志完全对得上。4. 绕过文档里的隐藏陷阱select项目实战避坑记录4.1 你改不了FD_SETSIZE这个上限很多人在开发中发现select最多只能监视1024个fd第一反应是去改FD_SETSIZE这个宏的值。你确实可以在include头文件之前#define一个更大的值比如2048甚至4096程序也能编译过。但问题在于fd_set底层是固定大小的位图而这个大小在内核里也有一个对应的限制。你单方面把用户态的FD_SETSIZE改大了一旦select进入内核内核仍然按它自己的fd上限来处理这两者不一致行为就不可预知了可能表现为某些fd的事件永远监视不到也可能直接报错。我在实际项目里见过一个真实的线上事故一个同事把FD_SETSIZE改成了4096编译运行都没问题但并发量一上来部分连接就表现为已连接但收不到任何数据。查了半天才发现内核侧压根没把那个fd读入监视范围。后来我们查内核文档和源码确认了select能监听的fd数量受FD_SETSIZE约束而FD_SETSIZE通常就是1024。这个限制是设计层面的不是靠改一个宏能绕过去的。如果你想支持更大规模的连接数方案不是硬改FD_SETSIZE而是换模型比如poll或者epoll。poll用动态数组取代了位图没有1024这个硬上限epoll更是在内核里维护事件表彻底摆脱了遍历的烦恼。4.2 为什么连接数一多就丢客户端这个问题的根源在于select返回的是有哪些fd就绪但你并不知道总数有多少个匹配你的遍历范围。你只是从服务器fd开始一个一个FD_ISSET找到就处理。问题来了如果有好几个客户端同时发数据select把这些fd全部置成了就绪你的遍历顺序是从小fd到大fd处理完一个小fd的业务逻辑可能耗时较长等你再回头处理大fd时这个客户端可能已经等不及超时关闭了。这不算丢但表现出来就是客户端连上了也发消息了服务器却好像完全没反应。更常见的丢是另一种情况你只处理了你认识的那些fd从来没有完整处理过所有就绪的fd。比如某些实现里会遍历max_fd次但遍历条件写成了fd max_fdmax_fd刚好在select执行前被更新新加入的fd本身在本次select里并没有参与监视遍历时却多扫描了一个并不存在的fd。这类边界条件很像off-by-one错误非常隐蔽我建议你写代码的时候养成一个习惯每次事件循环结束后手动检查max_fd是否需要收缩。比如某个大fd对应的客户端断开后如果它恰好是当前的max_fd你应该向前找一个仍然在监视范围内的最大fd来更新max_fd否则select会多扫描很多无关的fd浪费性能。下面我整理了一个排查清单照着看基本能定位问题现象可能原因检查方式新客户端连不上accept队列满循环里没及时accept查看accept代码是否在事件分发前优先处理新客户端连上但收不到数据max_fd未更新打印max_fd和连接fd逐一比对部分客户端断连后仍占据fdFD_CLR漏调检查recv返回0和-1的处理分支select一直返回0timeout结构体被复用未清零检查timeout参数是否被修改程序CPU占用高fd遍历范围过大打印每轮max_fd确认是否收敛4.3 频繁构造fd_set的性能陷阱每次循环开始我们都会执行一次read_fds all_fds。这个赋值在C语言里是结构体赋值编译器通常会把它展开成一个memcpy。fd_set的大小是128字节假设FD_SETSIZE为1024128字节的memcpy本身开销很小但问题在于内核每次select都会重新扫描所有的fd即使一个都没有就绪。假设你有100个空闲连接大多数时候没有任何数据到来你调用select内核把这100个fd全部扫一遍发现都没事件然后进程挂起。等到有事件了再唤醒这个过程里select的系统调用开销、fd集合从用户态到内核态的复制开销都是实打实的成本。如果你在一个循环里频繁地调用select比如在每次接收数据后立刻调用这些开销会累积成可观的性能损耗。有些人优化时会想到一个招把read_fds的复制省掉用两个fd_set交替。这个思路是错的因为内核不仅会检查事件还会在返回时把未就绪的fd从集合中清除而你需要维护的是一份长期稳定的观察名单制度的差异是本质性的。如果真觉得复制开销大建议你升级到epollepoll通过epoll_ctl增删fd事件信息由内核维护确实省去了每次全量复制的开销。4.4 多线程select的惊群问题我看到有人想优化select模型的吞吐能力用多个线程各自调select把同一批fd放进监听集合。结果会发现一条连接的事件来了多个线程的select同时被唤醒然后多个线程去recv同一个fd导致数据被分成多份分发到不同线程处理逻辑彻底乱套。这种现象叫惊群效应在select模型里比epoll还麻烦因为select本身没有提供任何原子性地解决冲突的机制。多线程下正确的做法应该是只有一个线程负责select和accept把接收到的fd分发给工作线程池去处理业务。select线程只做监视和分发不参与业务数据处理。这种单select 工作线程池的模式我在实践中能支撑千级连接的回显业务性能还不错。如果想让select线程自己不阻塞可以用非阻塞模式的accept和recv配合select做超时控制这样select线程可以快速循环。惊群问题其实暴露了select的一个结构性缺陷它可以帮你监视事件但不能保证事件被唯一地消费。你需要在设计层面自己解决这个问题而不是指望select帮你做负载均衡。5. 从select到poll再到epoll什么时候该换赛道5.1 select与poll的对比一次系统调用看清差异poll的出现解决了select的两个最痛的点一个是FD_SETSIZE上限另一个是需要自己遍历范围找就绪fdpoll通过一个数组结构返回具体的fd你遍历pollfds数组本身就行。但poll的核心思想和select还是一样的每次调用都要把全部关注fd从用户态拷到内核态返回后再整体拷回来。这也就意味着poll在多路复用的本质上并没有跳出select的框架。我整理了一个简单的对照表写到技术文档里也好用维度selectpollfd存储结构fd_set位图pollfd数组最大fd数量FD_SETSIZE通常1024取决于系统内存修改监听列表每次重新构造fd_set每次操作pollfds数组事件类型读/写/异常三类更丰富的事件类型平台兼容性几乎所有平台几乎所有平台Windows除外5.2 epoll高并发场景下的正解epoll和select/poll有一个本质区别select每次调用时内核要重新扫描全部fd而epoll通过epoll_ctl注册fd后内核会为每个fd建立一个回调只有活跃的fd才会触发回调并入队。它没有一个需要扫描的监听列表活跃fd是被通知的而不是被找到的。我在同一个服务器硬件上做过对比实验5000个连接每个连接每15秒发一条心跳消息。select模型CPU占用率超过了60%因为每次select都要在内核里扫5000个fdepoll模型CPU占用大概只有8%。差距随着连接数增长会继续拉大。到了这个量级select已经不适合作为主力模型了虽然它还能勉强运转。5.3 谁还在用select为什么它没被淘汰那你可能要问既然epoll这么强为什么还要学select我给你的答案是select并没有被淘汰只是在不同的场景下它有不可替代的价值。第一select的跨平台兼容性是最好的。Windows上也有类似的模型某些嵌入式环境压根没有epollselect几乎是唯一的多路复用方式。你在写小工具、测试程序、或者是学习I/O多路复用的原理时select因为结构简单、代码逻辑直白反而更适合作为入门的切入点。第二select的可移植性让它在网络基础设施里仍然占有一席之地。不少代理服务器、网关程序、监控脚本它们处理的连接数量本来就少几百个以内select足够用了没必要引入epoll的复杂度。第三select模型的编程思维是所有I/O多路复用模型的通用基础。理解了fd_set、理解了用户态维护名单、内核帮你看守这个模式之后你去看epoll、kqueue、IOCP都不会觉得难以理解。模型虽然不同但一件事来了才去处理的事件驱动思想是一脉相承的。在决定用哪个模型之前我建议你先问自己三个问题我的并发连接数大概是多少我是否需要跨平台运行我的团队对哪种模型的代码最熟悉这三个问题想清楚答案自然就出来了。我个人的建议是连接数在几百级别、开发周期短、以功能验证为主的项目直接用select省时省力连接数动不动上万的老老实实上epoll。没必要为了高级而高级写代码最忌讳的就是拿一把牛刀去切一颗白菜。我最后再补一个实际的建议在学习select的时候别满足于跑通demo你可以在原型上多做一些尝试。比如加一个超时参数观察select超时返回时ready的值是多少或者故意不调用FD_CLR看看导致什么样的异常行为再或者模拟一个客户端频繁地连接、断开再连接观察服务器的表现。这些实验用到的精力其实很少但一套做下来你收获的理解可能比看十遍理论都深刻。这些东西是任何参数表、任何函数的文档都不会告诉你的只有自己亲手撞过墙才会有真正的体感。