ARTICLE DETAIL

资讯详情

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

深度拆解select:从系统调用原理到性能陷阱与epoll迁移实战

深度拆解select:从系统调用原理到性能陷阱与epoll迁移实战 1. 从一次真实bug说起select为什么值得重新认识如果你写过Linux下的网络程序多路转接这三个字一定不陌生。select、poll、epoll几乎每本网络编程书都会把这三个函数放在一起讲。但你有没有过这种感觉书翻完了代码也会抄了可真到了线上排查问题select到底在内核里干了什么、为什么有人说它“慢”、为什么1024这个数字阴魂不散还是说不清楚。两个月前我接手一个老项目一个跑在嵌入式设备上的TCP网关历史代码用了select做主循环。设备运行一段时间后会出现诡异现象网络连接还在但数据吞吐量急剧下降CPU占用飙到90%以上。当时的排查过程让我把select从调用到内核交互重新捋了一遍很多之前“想当然”的细节原来都是性能瓶颈的来源。这篇文章不是教科书式的复述而是基于那次排查以及后来反复实验把select从用户态到内核态、从参数到返回值、从原理到应用场景做个彻底拆解。内容偏底层但保证让你读完能真正“掌控”这个函数而不仅仅是会用。先说结论select本身就是个出身20多年前的老家伙它出现在一个“文件描述符数量没那么大、并发没那么疯狂”的年代。它设计的核心思路是“我一次性把所有需要等待的fd告诉你你帮我盯着有消息了告诉我”。听起来很合理但它的具体实现方式决定了它在高并发场景下必然被poll和epoll取代。可在低并发、嵌入式、跨平台Windows的Winsock也支持select等场景select仍然是你最优的选择之一。所以这篇文章的核心关键词是系统调用、fd_set、用户态与内核态的数据复制、超时时间、可移植性。2. select的核心机制从两个问题入手2.1 问题一select到底在等待什么我第一次教新同事用select他问了一个让我哑口无言的问题“select阻塞的时候CPU在干什么”当时我支支吾吾说“内核在帮你等”但心里知道这没解释清楚。搞懂select等待的本质是理解整个多路转接机制的钥匙。select的核心任务是内核代替用户程序同时监视一批文件描述符的可读、可写、异常事件并且可以设置一个超时时间。当任何被监视的fd出现对应的事件或者超时时间到select返回然后用户程序逐个检查是哪个fd就绪了。这里的“可读”和“可写”含义很具体内核不是猜测而是有明确的判断标准可读fd对应的内核接收缓冲区中有数据或者对方关闭了连接读到EOF或者fd本身是监听socket且已完成队列非空有新连接等待accept可写fd对应的内核发送缓冲区有空闲空间可以写入数据异常fd发生了带外数据等特殊情况实际开发中很少用到所以select做的事情用一个不太恰当的类比来说就像你同时盯好几个监控摄像头隔一段时间看一眼每个画面有没有变化。你盯着摄像头这件事本身不消耗画面里的资源但“隔一段时间看一眼”这个动作在高分辨率、多路画面下成本并不低。2.2 问题二fd_set这个位图到底怎么用接触select最先遇到的不是函数本身而是fd_set这个类型和FD_SET、FD_ZERO、FD_ISSET、FD_CLR这四个宏。很多教程就是让你“先FD_ZERO再FD_SET然后select”但从来不讲这样做的真正原因。fd_set本质上是一张位图bitmap每一位对应一个文件描述符编号。比如位图的第0位表示fd0第5位表示fd5fd5恰好对应了fd_set中第5个bit。这种设计在几十年前是为了节省内存一个fd_set默认大小由FD_SETSIZE决定在Linux上通常是1024即最多监视1024个fd占用的内存仅仅128字节。所以下面这四个宏做的事情就很直白了FD_ZERO把整个位图清零相当于把所有“监视位”置0FD_SET(fd, set)把fd在set位图中对应的那个bit置1表示“我关心这个fd”FD_CLR(fd, set)把对应bit清0表示“不再关心这个fd”FD_ISSET(fd, set)检查对应bit是否为1用于select返回后判断哪个fd就绪关键点来了select返回后内核会就地修改你传入的fd_set位图把所有未就绪的fd对应的bit清0只保留就绪的fd。这意味着你的fd_set在调用select之前和之后内容是不一样的。要想在下一个循环继续使用同一批fd必须重新FD_ZERO再重新FD_SET。这就是为什么网上所有教程代码里主循环里总是有FD_ZERO和FD_SET的重复操作因为不这么做第二次select监视的就是“上一次返回后剩下的就绪fd”了。提示这个“就地修改”的行为在不同平台上不完全一致有些平台甚至不会修改fd_set。可移植的写法就是每次都重建。性能上这点开销几乎可以忽略没必要为此耍小聪明。3. 从select到内核一次系统调用的完整旅程3.1 三次复制与一次轮询select是个系统调用它的入口在Linux内核的fs/select.c里核心函数是do_select。我们把一次调用过程拆解成几个大步骤第一步用户程序调用select(nfds, readfds, writefds, exceptfds, timeout)这个调用会陷入内核态。内核把readfds、writefds、exceptfds这三个位图从用户空间拷贝到内核空间这个过程叫copy_from_user。如果传入的fd_set指针是空指针表示对这类事件不关心。第二步内核根据位图中被置1的bit遍历0到nfds-1的所有fd逐个调用fd对应的文件系统poll方法获取每个fd当前的真实状态。这个poll方法是VFS层定义的一个操作每个具体文件系统socket、管道、普通文件、设备文件都有自己的poll实现socket的poll实现会去检查自身的接收缓冲区和发送缓冲区状态。第三步内核把收集到的每个fd的可读、可写状态分别填入对应的内核空间fd_set位图。第四步如果当前没有就绪的fd且timeout还没到内核会把当前进程挂起加入每个被监视fd的等待队列然后调度到其他进程直到某个fd的状态发生变化或者超时定时器触发唤醒当前进程。第五步进程被唤醒后内核再次遍历所有fd重新生成就绪fd集合把所有就绪的fd集合从内核空间复制回用户空间copy_to_userselect返回。返回值表示就绪fd的总数如果超时未就绪返回0。“三次复制一次轮询”这个概括虽然不足以描述等待队列和唤醒机制的复杂性但已经足够解释select为什么在大量fd场景下性能差每次调用三张位图要从用户态复制到内核态再复制回来尽管128字节不多但架不住select频繁调用每次调用内核都要从0到nfds-1线性扫描全部fd不管这些fd有没有就绪。注意select的复杂度是O(n)n是fd最大值而不是fd数量本身。如果你监听了fd 1000和fd 1001两个fdnfds至少是1002内核要遍历0到1001的每一个fd和你有多少个活跃fd无关这还没完fd_set就绪之后的内核重建过程也是一次全量遍历。这就意味着select的每次调用至少要遍历一遍fd区域无论有没有事件发生。想想看一个稀松平常的while(1){ select; if(readable) handle; }循环每转一圈就是一次全量遍历这就是“select慢”的本质原因。3.2 timeval与五类超时行为select函数的第五个参数timeout类型是struct timeval精度是微秒。这个参数的行为细节常常被忽略但它直接决定了程序的实时性和CPU占用。当你传入的timeout指针为NULLselect会无限期阻塞直到至少一个fd就绪。这是纯阻塞模式适合事件驱动的主循环但要注意如果被信号中断select可能返回-1errno设置为EINTR处理不好就会莫名其妙提前退出。当你传入一个timeval结构体select会等到最多这个时长后返回。如果期间有fd就绪立即返回否则超时返回0。这个模式下每次超时返回后你必须重建fd_set重新调用select形成周期性的轮询循环。实际开发中timeout通常被用作心跳、定时任务、超时保护的载体。还有一个容易踩坑的细节Linux的select会修改timeout参数把剩余等待时间写回去。但在其他平台比如Windowstimeout在调用后是未定义的。所以可移植的做法是每次调用前重新赋值timeout不要重复使用同一个已修改过的结构体。timeout设置为全零tv_sec0, tv_usec0select会立即返回相当于一次非阻塞的fd就绪检查。这在“不阻塞主线、快速扫描fd状态”的场景很有用但代价是CPU占用的无谓消耗因为即使没有任何fd就绪select也立刻返回0你的主循环很可能就在空转。我把select超时行为整理成一个速查表方便你在设计循环结构时快速对照超时设置行为典型使用场景NULL永久阻塞直到有fd就绪或信号中断纯事件驱动的主循环{0, 0}立即返回非阻塞检查在复杂循环里顺手检查fd状态{秒, 微秒}等待固定时间有事件提前返回带心跳、定时任务的循环被修改后的timeoutLinux会写回剩余时间精细控制每次等待的剩余量超大值如ULONG_MAX秒相当于几乎永久阻塞但会占用内核定时器不推荐直接用NULL更合理4. 动手实现一个完整的select TCP服务器4.1 代码搭建与关键细节说了这么多原理直接上一个可以跑起来的实操例子。下面这个TCP服务器用select处理多客户端连接核心逻辑就是一个监听socket加多个客户端socketselect统一等待这些socket的可读事件。代码风格是简洁优先方便你对照前面讲的概念逐行看。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include sys/select.h #include netinet/in.h #include arpa/inet.h #include fcntl.h #define PORT 8888 #define MAX_CLIENTS 10 int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(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(PORT); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 8) 0) { perror(listen); exit(1); } set_nonblock(listen_fd); fd_set read_fds; int max_fd listen_fd; int client_fds[MAX_CLIENTS]; for (int i 0; i MAX_CLIENTS; i) client_fds[i] -1; printf(server listening on port %d\n, PORT); while (1) { FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); max_fd listen_fd; for (int i 0; i MAX_CLIENTS; i) { if (client_fds[i] ! -1) { FD_SET(client_fds[i], read_fds); if (client_fds[i] max_fd) max_fd client_fds[i]; } } struct timeval timeout; timeout.tv_sec 5; timeout.tv_usec 0; int ret select(max_fd 1, read_fds, NULL, NULL, timeout); if (ret -1) { if (errno EINTR) continue; perror(select); break; } if (ret 0) { printf(select timeout, no fd ready\n); continue; } if (FD_ISSET(listen_fd, read_fds)) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { perror(accept); } else { set_nonblock(conn_fd); int placed 0; for (int i 0; i MAX_CLIENTS; i) { if (client_fds[i] -1) { client_fds[i] conn_fd; printf(new client fd%d\n, conn_fd); placed 1; break; } } if (!placed) { printf(too many clients, close %d\n, conn_fd); close(conn_fd); } } } for (int i 0; i MAX_CLIENTS; i) { int fd client_fds[i]; if (fd -1) continue; if (FD_ISSET(fd, read_fds)) { char buf[1024]; ssize_t n read(fd, buf, sizeof(buf) - 1); if (n 0) { buf[n] \0; printf(recv from fd%d: %s\n, fd, buf); write(fd, buf, n); } else if (n 0) { printf(client fd%d closed\n, fd); close(fd); client_fds[i] -1; } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) { perror(read); close(fd); client_fds[i] -1; } } } } } close(listen_fd); return 0; }这段代码里有几个细节需要专门讲一下它们往往是新手写select服务器最容易翻车的地方。4.2 监听socket必须非阻塞我把监听socket设置了非阻塞这不是可选项而是必须项。原因在于select返回“监听socket可读”后即使你已经知道了有新连接ready在真正调用accept时仍然存在连接被客户端撤销的竞态窗口。如果监听socket是阻塞的accept会卡在那里整个服务器主循环被挂起其他已经就绪的客户端fd也无法处理。设置为非阻塞accept在这种情况下返回-1errno为EAGAIN或EWOULDBLOCK循环可以继续跑不会阻塞业务流程。类似的道理适用于客户端socket当select返回某个fd有数据可读你立刻read但数据可能在极短时间内已经被另一个线程或进程读走或者对方已经关闭连接导致read阻塞。非阻塞模式下read返回EAGAIN代码就能正确处理这种“事件过期”的情况。4.3 每次循环重建fd_set这段代码每次循环都执行FD_ZERO和FD_SET这正是我在第二章节强调的select副作用。select调用会修改fd_set把未就绪的fd对应的bit清0。如果不重建循环第二次进入select时read_fds的内容就是上次select的返回值监视的全是上次就绪的fd监听socket反而可能不在集合里直接导致服务器无法接受新连接。这类bug非常隐蔽程序一开始正常客户端连接几个之后新的连接就卡住不再响应了。4.4 半关闭和粘包处理代码里当read返回0时表示对端关闭了连接此时要close(fd)并从数组里移除同时FD_CLR也会在下次循环重建时自动生效。这里有个值得注意的点对方close了写端半关闭我们这边的read会返回0但此时我们还可以往对方写数据。如果业务逻辑不处理半关闭简单地把n 0当成完全关闭来close可能导致对方最后一次发来的数据还没被我们完整处理就被终止。实际产品中根据协议定义决定是否区分半关闭但至少要知道这个区别。粘包问题在select这种事件驱动的循环里尤其常见select说fd可读可能一次到达了多个完整报文也可能只到了半个报文。如果应用层协议有固定长度或者分隔符你的处理逻辑必须要有缓冲区把不完整的包攒到下一次可读事件再继续解析。上面示例代码只是简单的echo不存在粘包问题但真实的业务处理一定要在应用层实现拆包逻辑别指望select或者TCP帮你分好边界。5. select到底能扛多少并发实测与FD_SETSIZE的真相5.1 1024的限制是不是硬件天花板FD_SETSIZE默认1024这是Linux上一个很出名的限制。但这个数字是写死在glibc头文件里的一个常量并不代表操作系统只能管理1024个文件描述符。你可以修改这个值方法是在引入所有头文件之前#define FD_SETSIZE 2048 #include sys/select.h这么改可以但它带来的问题很实际fd_set这块位图会从128字节翻倍成256字节这只是小事。真正的限制在于select系统调用内部内核里同样有fd_set大小的限制。Linux内核源码里定义了一个常量通常在include/linux/posix_types.h叫__FD_SETSIZE值是1024。如果你把用户态的FD_SETSIZE改大了提供更大的位图给内核内核会检查你传入的nfds是否超过了它自己的限制超过就会返回EINVAL。所以结论是用户态改FD_SETSIZE只能改到内核允许的上限以内也就是1024。想突破这个限制就得修改内核重编译这在生产环境显然不现实。这也是select在“高并发连接数”场景下必须让位于poll和epoll的最直接原因。那么稳定内存场景下select更适合用在什么规模我个人经验是同时监视的fd数量在几十到一两百之间性能表现都还很健康。当监视的fd数超过512select的遍历开销开始变得明显此时即使连接数还在1024以内CPU使用率也会随着空转而攀升。5.2 一个验证select性能特征的小实验如果你也想直观感受select的“遍历代价”我建议你做这样一个实验在服务器程序里让一部分客户端连接建立后不发送任何数据然后观察服务器CPU占用。代码里用select阻塞等待所有fd没有任何数据时CPU几乎为0这是正常的。但如果你改用非阻塞selecttimeout全零并在循环里高频调用连接数从10加到500观察CPU占用曲线你会看到一个接近线性的上涨而poll的上涨曲线要平缓得多。这个实验也说明了另一个问题select里timeout的设计要非常小心。timeout5秒程序最多每5秒醒来一次做超时处理但如果设置timeout0主循环的空转频率变成几百万次每秒CPU飙升是必然的。很多线上性能问题查到最后都是这类“看似无害的写码习惯”。5.3 select、poll、epoll的本质对比先把结论放在这里select和poll在事件通知模型上几乎一样都是用户态到内核态的“全量拷贝全量遍历”差别只在于poll用pollfd数组替代了位图突破了1024限制并且每次调用不需要重建fd_set。而epoll从设计上就不同它在内核中维护一棵红黑树来登记fd通过回调机制只通知有事件发生的fd而且在事件发生时内核的遍历范围只限于就绪链表的长度不再是0到nfds全扫一遍。所以三者的核心差异可以用一句话概括select和poll是“每次调用前把所有fd全量告知内核都让内核全量检查”epoll是“提前把关心的fd注册进内核有事件时内核只告诉你哪些fd有事”。明白这个差异你就知道为什么epoll在高并发场景下几乎全面胜出。不过我也要提醒你select并不是一无是处。它的可移植性在Linux、Windows、macOS和各类Unix系统上都保持一致epoll则只存在于Linux。如果你的应用需要跨平台或者只是监视几十个fd的低并发工具select反而是最简单直接的选择。6. 常见问题与排查技巧实录写代码是一回事debug又是另一回事。这一节整理我实际工作里遇到过的select问题不一定是select本身的bug但往往都和select的某些特性有因果关系。6.1 客户端一多程序CPU就飙升现象连接数增加到一定数量CPU开始大比例占用网络吞吐下降。排查时先在代码里加日志打印select返回值和循环执行频率如果发现select返回0的频率极高基本可以判断是timeout设置过小或为全零。处理办法是确认业务对延迟的容忍度timeout设置成合理的秒级或毫秒级数值一般不建议小于1毫秒宁可把一些定时任务拆出独立线程也不要让主循环空转。6.2 select永远不返回即使数据已经到了这个问题的常见原因之一是你把fd_set传给select之后在另一个线程里close了该fd或者再次使用同一个fd_set做FD_SET但没有重新FD_ZERO。fd_set内容错乱会导致内核监视了错误的fd集合。另一个原因是nfds传递错误nfds必须是所有被监视fd的最大值1漏算最大值会导致靠后的fd不被检查到看起来就像“没收到数据”。排查技巧在select返回后打印所有fd的FD_ISSET状态同时打印ret的值。如果select一直阻塞你还可以通过gdb attach到进程调用select的返回地址查看当前内核栈确认是否卡在等待队列里。6.3 fork之后select行为异常fork后子进程会继承父进程的文件描述符表但select的等待队列不会自动迁移。如果父进程在fork前就陷入了select阻塞子进程即使拿到了同一个fd也无法唤醒父进程的select。解决思路是fork之前不要进入select或者使用更现代化的机制如eventfd配合poll这样能更好的跨进程唤醒。6.4 可读事件不停地触发如果你的协议是“每次读取固定长度”而对方的发送速度大于你的接收速度select每次返回就绪后只读一部分数据剩下的数据还在内核接收缓冲区里于是select立刻又返回可读主循环陷入“疯狂读数据”的状态。表面看是select的问题实际是读写节奏不匹配。处理办法是一旦有可读事件尽量一次性把当前内核缓冲区的数据读完用recv MSG_DONTWAIT或者非阻塞read或者维护一个应用层缓冲区把读到的数据缓存起来避免反复唤醒select。6.5 跨平台差异Windows上的selectWindows上的select用法类似但有一个明显的差异Windows的socket不是文件描述符而是一个独立的句柄类型SOCKETfd_set的实现也完全不同。FD_SETSIZE在Windows上的默认值是64非常小。如果跨平台建议在Windows上把FD_SETSIZE事先调大并且要明确fd_set在Windows上只针对socket有效不能监听普通文件。同时Windows的select不支持timeout写回调用后timeout的值是不可预测的。6.6 大厂网络框架对select的取舍很多高性能网络库比如libevent、libuv早期版本其实都支持select作为底层事件后端原因就是可移植性。它们也暴露了select的缺点在大量事件场景下性能倒挂。libevent在Linux上默认用epollBSD上用kqueueWindows上用IOCPselect通常只作为最后的兜底方案。如果你想从select平滑迁移到epoll比较好的思路是把“事件等待”和“事件处理”拆成两层先抽象一个循环接口底层先实现select版本再实现epoll版本上层业务无需改动。我之前的一个项目就是这么做的从select切到epoll代码量增加很少但并发处理能力提升了一个数量级。7. select的超时时间容易被忽视的性能陷阱前面提过timeout为全零会导致CPU空转这块值得单独拿出来细说因为它太容易踩坑了。很多人为了实现“非阻塞检查”习惯性把timeout设为{0, 0}放在一个高频循环里。这个循环如果还做了其他运算select空转的开销还不算致命。但如果主循环只做了select和简单的FD_ISSET判断CPU占用可以瞬间飙到100%。我曾经优化过一个程序就是把timeout从{0, 0}改成{0, 20000}20毫秒CPU占用从96%降到了5%以下对业务的影响几乎为零。如果你的程序需要“定期检查多个fd有没有数据同时还要执行其他非阻塞任务”正确做法不是timeout0空转而是用事件循环配合定时器select阻塞在一个合理的超时上到点醒来再处理其他任务。这样既能保证及时性又不会浪费CPU。这里再补充一个关于精度的问题select使用struct timeval精度是微秒但Linux内核的实际定时器精度通常远达不到微秒级别特别是非实时内核。所以你设置的10微秒超时实际等待时间可能是一到两个调度器tick通常是几毫秒。如果你的业务逻辑依赖高精度定时select不是好的选择建议使用timerfd或者POSIX定时器。8. 从select到epoll一次平滑迁移的经验8.1 迁移思路如果你已经掌握select想平滑迁移到epoll我建议不要直接重写整个网络层而是分两步走。第一步把使用select的主循环抽象出来封装成一个事件循环模块。这个模块对外只暴露几个接口注册fd、注销fd、触发事件回调。内部实现先用select保证业务层不感知。第二步实现epoll版本的底层替换掉select实现。epoll的核心API是epoll_create、epoll_ctl、epoll_wait。用epoll时不需要每次重建监听集合epoll_ctrl在每次fd状态变化时才调用epoll_wait只返回就绪的事件效率确实高一个级别。关键注意点是epoll_wait返回的事件数组里每次都要通过data字段判断是哪个fd用红黑树维护fd到回调的映射关系或者直接用fd作为索引。8.2 迁移中常踩的三个坑迁移过程中我遇到过三个坑这里提前排掉。第一个是水平触发和边缘触发的选择。epoll默认是水平触发语义上和select基本一致如果你代码是从select迁移过来用默认水平触发改动最小。边缘触发要用非阻塞fd并且要一次性读干净否则会丢数据。新手不建议一上来就边缘触发。第二个是事件长城问题。epoll会在内核事件表中维护fd的注册状态如果事件处理函数里没有及时delete或者修改fd重复触发会导致循环空转CPU又飙升。解决方法是严格区分“可读事件”和“可写事件”可写事件在数据写完要立即从epoll中注销。第三个是LT/ET与accept的配合。ET模式下一次accept可能只接受了一部分连接剩下连接还留在内核队列里如果没有及时处理就会丢失。处理方法是ET模式下在可读事件里用while循环accept直到EAGAIN为止。8.3 什么时候不需要迁移如果你的程序满足这几个条件我认为完全没有必要迁移到epoll同时监视的fd数量少于100每个fd的处理都是简单读写没有复杂的状态机需要跨平台运行代码已经经过稳定性和性能验证这种情况下用select逻辑最直观代码最易维护没必要为了“用上新东西”而重构。技术选型的核心永远是匹配业务场景不是堆砌新鲜名词。9. 写在最后掌握多路转接的核心是理解“谁在等待、等多久、如何唤醒”这段时间把select从代码到内核捋了一遍我的感受是select虽然老但它的设计思想非常朴素把它吃透再去看poll和epoll你能清楚地知道它们改进的每一处设计都是为了解决哪个具体问题。很多资料喜欢用“select效率低”一句话带过但真正驱动你技术进步的恰恰是弄清楚它低效在哪里、为何低效、什么场景下低效。一个小建议如果你还在上学或者刚工作不久值得自己动手写一个select版本的服务器再用epoll重写一遍然后压测对比。这种亲手得出来的结论比看一百篇博客都更能让你记住。至少对我自己来说这些坑踩过一次后面就再也不会在同一个地方摔倒了。
返回列表