资讯动态

Linux select内核源码解析:从fd_set到do_select的性能真相

发布时间:2026/10/17 11:09:42 来源:尧图企业网站定制
做Linux服务端开发select应该算得上每个人最早接触的I/O多路复用接口了。网上教你怎么用select、怎么填fd_set、怎么处理超时的文章一抓一大把可一旦聊到内核里到底怎么实现的大家基本都含糊其辞有的说select是O(n^2)所以慢有的说它每次都要全量遍历还有人说是位图拷贝导致性能差。这些说法各说各话老实说大多只讲对了一半。我之前被一个fd超过1024导致线上模块行为异常的问题折磨了好久最后狠下心把fs/select.c从头到尾啃了一遍才把这些零碎的说法拼成了一个完整的图景。这篇就把我梳理出来的内核源码链路整理出来从系统调用入口、数据结构一直到do_select主循环中间穿插一些我实际排查问题时的体会。适合两类人看一类是准备把内核源码当阅读理解来做的初学者另一类是想彻底搞清楚select到底慢在哪、好在哪的服务器开发者。1. 系统调用入口select在内核里的第一站1.1 用户态的一层皮宏与封装应用层写select几乎离不开一组以FD_开头的宏FD_ZERO(fdsetp); FD_SET(fd, fdsetp); FD_ISSET(fd, fdsetp); FD_CLR(fd, fdsetp);这些宏本质上都是位运算。fd_set就是一个位图数组每个fd对应bitmap里的一个bit。fd为5就在第5个bit置1。你监听哪些fd就是把对应bit填成1select返回后内核把哪些fd可读、可写、有异常分别写到三张结果位图里。但注意这一层皮在最开始就定下了一个边界。fd_set的容量由__FD_SETSIZE决定在64位Linux上这个值是1024。所以用户态的fd_set最多只覆盖0到1023号fd。凡是超过这个范围的fd底层宏会直接越界写内存行为就像踩了地雷谁也别想预测结果。很多资料把这一点归结为select最多支持1024个fd根源就在这里不是内核select实现本身故意限死1024而是用户态的fd_set大小一共就这么大。glibc的select封装本身不算复杂它会做一次系统调用最终落到内核的select相关入口。如果平台支持还可能有pselect6这类变体走的是更精细的时间参数和信号掩码但核心选型逻辑和select基本一致。所以看select源码完全没必要纠结用户态这层包装重点永远在内核侧。1.2 sys_select和core_sys_select到底干了什么内核里select的系统调用入口在fs/select.c用SYSCALL_DEFINE5定义。函数签名剥掉内核特有的声明方式后就是用户熟悉的int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);内核入口的大致逻辑是SYSCALL_DEFINE5(select, int, n, fd_set __user *inp, fd_set __user *outp, fd_set __user *exp, struct timeval __user *tvp) { struct timespec64 end_time; struct timeval tv; int ret; if (tvp) { if (copy_from_user(tv, tvp, sizeof(tv))) return -EFAULT; ret poll_select_set_timeout(end_time, tv.tv_sec, tv.tv_usec); if (ret) return ret; } return core_sys_select(n, inp, outp, exp, end_time); }这里sys_select做的事情非常杂役把用户态的timeval取进来经过poll_select_set_timeout转成内核更爱用的timespec64顺便做溢出检查如果timeout传的是NULL表示无限等待就不做转换。真正干活的是core_sys_select。core_sys_select的职责至少有三块。第一计算需要几个字节的位图空间。内核要为用户态三张fd_set准备对应的内核态副本大小取决于n不是固定128字节。第二通过get_fd_set把用户态传进来的in/out/ex三张位图完整拷贝到内核内存。第三调用do_select做真正的轮询扫描最后再通过set_fd_set把结果位图从内核态拷回用户态。这一块给我最大的感触是select本身是个非常直白的系统调用它没有做任何缓存、增量或事件分拣。每次调用内核都把三张完整的位图搬进来再把三张结果位图搬出去。一进一出一共六次内存拷贝。虽然单张位图最多128字节量不大但这是每次调用都必然发生的成本躲不掉的。1.3 每次调用都逃不掉的位图搬运很多人讲select性能差喜欢把锅全甩给全量遍历fd。但从入口这一层看位图搬运的开销同样是一个结构性缺陷。select是全量状态传递模型你把所有关心的fd都塞进集合内核也把全部fd的就绪状态都还给你。哪怕你只关心一个fd而且这个fd也确实没就绪内核依然要把完整的输入集合搬进去、再检查一遍、然后把空的结果集合搬出来。这种一次传递全量状态的思路在编程上很省事也符合直觉但代价就是每次调用都有固定成本。和epoll后来的只拷贝就绪事件比起来select吃亏就吃亏在无论有没有事件往来开销都一样大。我记得Linux内核代码里在core_sys_select的临时内存分配上做过不少小优化比如小规模位图可以直接在内核栈上初始化不需要每次都走堆分配。但这种优化也只是把成本从分配内存降低到还是得拷贝没法从根本上改变select每次调用都要搬集合的宿命。看源码的时候可以先记下这个结论select的慢从入口拷贝那一刻就已经注定了。2. 核心数据结构位图、等待队列与poll_table2.1 fd_set一张密密实实的位图用户态看到的是一个叫fd_set的结构体在内核里它其实就是一个unsigned long数组。以64位系统为例typedef struct { long int __fds_bits[1024 / (8 * sizeof(long int))]; } fd_set;1024个bit每个bit对应一个可能的fd编号。ndfd为0时占bit 0fd为63时占bit 63fd为64时占第二组bit以此类推。FD_SET这类宏做位运算时最需要注意的就是边界。比如#define __FD_SET(fd, fdsetp) \ ((fdsetp)-__fds_bits[fd / __NFDBITS] | (1UL (fd % __NFDBITS)))如果fd给的比1023还大数组下标fd / __NFDBITS就会越界直接踩到fd_set周边的内存。这属于调用方的责任C语言不给你兜底。这也是为什么很多老牌项目会对select的fd传入做一层额外校验避免线上踩到这条越界。还有个容易忽略的点fd_set本身不携带这个fd当前是否被进程打开的信息。它只是位图单纯地标记我要检查0号fd、3号fd。这些fd到底存不存在select进入内核后会逐个核实。窗口期如果文件被并发关闭就很容易触发EBADF这个坑后面单独展开。2.2 fd_set_bits与内核态地址空间内核态里select不直接用用户态的fd_set结构而是定义了一个叫fd_set_bits的中间结构typedef struct { unsigned long *in, *out, *ex; unsigned long *res_in, *res_out, *res_ex; } fd_set_bits;看到没有它里面全是指针指向内核里为这次调用分配的临时位图空间。in、out、ex分别对应可读、可写、异常三张输入集res_in、res_out、res_ex三张则是结果集。core_sys_select把用户态的数据拷贝到这些指针指向的内存中do_select只在这套本地视图上做读写。这个结构的作用是解耦。do_select在反复循环、扫描的过程中不需要再跟用户态打交道只需要通过六个指针来回访问内核内存。等到扫描结束core_sys_select统一把res_x三张结果集写回用户态。这种先把用户态数据搬进内核干完活再整体搬出去的方式也是绝大多数系统调用的标准做法。顺便提一句do_select里遍历fd时用的正是三个输入集指针的位运算。它判断某个fd是否在可读集合中就是看in[fd / BITS_PER_LONG] (1UL (fd % BITS_PER_LONG))是否为真。逐bit判断的过程看着简单却也反映了select线性扫描的本质。2.3 poll_wqueues一段临时等待关系select要做阻塞等待前提是能把当前进程挂到各个fd对应的等待队列上。可是每个fd的等待队列头完全不一样socket有socket的等待队列管道有管道的等待队列字符设备有自己驱动的等待队列。select要做的事情是把当前进程这个等待者同时塞进多个等待队列然后睡下去。内核为此准备了一个专用的结构struct poll_wqueues { poll_table pt; struct poll_table_page *table; struct poll_table_entry *inline_entries; int inline_index; unsigned int registered; unsigned int error; };其中poll_table_entry代表一个具体的等待项struct poll_table_entry { struct file *filp; __poll_t key; wait_queue_entry_t wait; wait_queue_head_t *whead; };select每次被调用都会通过poll_initwait初始化一个poll_wqueues。随后do_select在扫描fd时会不断调用vfs_poll驱动内部的poll回调又会用poll_wait(file, wait_address, wait)把当前进程挂到具体的等待队列上。每次挂接都会申请一个poll_table_entry记录文件指针、等待队列头、回调函数等信息并且get_file(filp)给文件引用计数加一防止在等待过程中文件被并发关闭导致悬空指针。等select返回前内核会调用poll_freewait逐个把等待项从等待队列里摘掉再执行put_file释放引用。这个过程相当于注册一批等待关系用完统一拆除。从这里能看出select的一个重要开销每次调用都要为所有被监听的fd临时建立一整张等待关系表返回前再拆除。fd少的时候无所谓fd一多这个注册和拆除的反复操作就会累积成明显的性能损耗。而epoll的高明之处恰恰在于把这张表做成长期缓存靠epoll_ctl一点一点增删不用每次都用旧了再重建。3. do_select主循环从全量扫描到睡醒重扫3.1 第一轮逐位检查与vfs_polldo_select是整个select机制的核心函数。它的主循环长这样简化示意for (;;) { unsigned long res_in 0, res_out 0, res_ex 0; for (i 0; i n; i) { if (在in/out/ex任一集合中检测到位i) { f fdget(i); if (!f.file) { retval -EBADF; break; } mask vfs_poll(f.file, wait); if (mask POLLIN_SET) res_in | bit_i; if (mask POLLOUT_SET) res_out | bit_i; if (mask POLLEX_SET) res_ex | bit_i; fdput(f); } } fds-res_in res_in; fds-res_out res_out; fds-res_ex res_ex; if (retval || timed_out || signal_pending(current)) break; poll_schedule_timeout(...); }注意这里的vfs_poll。它是驱动poll回调的统一入口最终会调用file-f_op-poll(file, wait)。每个具体文件类型自己实现poll函数。以socket为例poll内部会检查socket接收队列里有没有数据、发送队列是否可写、是否有异常事件并返回一组__poll_t位掩码比如POLLIN、POLLOUT、POLLERR。这些poll回调有一个约定如果传入的wait指针非空驱动在执行poll_wait时会主动把当前进程挂到自己的等待队列上。这正好和前面说的poll_table_entry对应上。所以检查一遍和注册等待是同一次遍历里完成的不是分开两步。3.2 就绪判定与返回值细节select返回值的语义经常被用错。它的返回值是有多少个fd处于就绪状态而不是有多少个事件发生。同一个fd如果同时可读又可写逻辑上算两个事件但在do_select里if (res_in || res_out || res_ex) retval;它只会给retval加一次。这一点在写循环时尤其要注意如果只用返回值判断有没有事件很可能漏处理同一fd同时读写的情况。正确做法是只要返回值大于0就遍历三个结果集逐个检查可读集里有没有它、可写集里有没有它、异常集里有没有它。另一个经常踩的坑是EBADF。do_select在遍历时如果发现某个fd在集合中但fdget(i)拿不到对应的struct file也就是这个编号在当前进程里根本不存在它会直接设置retval为-EBADF并跳出循环而不是跳过这个fd继续检查。这符合POSIX对select的要求也让很多跨线程关fd导致select报错的问题变得有迹可循。还有一点值得说poll回调返回的mask是此刻的即时状态。就算某一次返回里没有可读位只要有驱动调用过poll_wait之后事件发生时会来唤醒进程。select会在被唤醒后再重新扫描一遍用最新的mask做最终决策。所以select天然是水平触发语义只要fd一直处于就绪状态每次select都会报告它。3.3 睡眠、唤醒和醒后重扫的轮询模型如果第一轮扫描完了一个就绪fd都没有也还没超时do_select就会进入睡眠。它调用的函数核心是poll_schedule_timeout做的事情包括把当前进程设置为TASK_INTERRUPTIBLE。调用schedule_timeout(timeout)主动让出CPU。被唤醒或超时后返回。这个睡眠不是无限期的timeout由前面的end_time计算得出。也就是说select的阻塞并不是简单dead loop而是真正进入调度器的等待队列不会空转CPU。关键的机制在后面。当某个等待队列收到事件比如socket收到了数据包这个socket的等待队列会唤醒所有挂在上面的等待项。select的等待项回调是pollwake它做的事情非常朴素调用默认的wake函数把当前进程从睡眠状态唤醒。它不携带任何是哪个fd、发生了什么事件的详细情报。所以进程被唤醒以后并不知道刚才到底是哪个fd触发了事件。它唯一能做的就是回到do_select的循环顶部重新把所有fd扫描一遍挨个调用poll回调看看现在到底谁就绪了。这就是我常说的醒后重扫模型。理解了这个模型就能明白为什么select在大规模fd下效率堪忧。它用睡眠换来了CPU不空转但信息获取方式依然是全量轮询。事件通知只是敲门敲完门之后它还得把整条走廊挨个问一遍。需要注意的是select有可能在一个调用里被唤醒不止一次。如果进程被唤醒后重扫发现没有事件比如是一次虚假唤醒它会再次回到睡眠。这个过程会反复注册新的等待项直到select返回。所以从等待队列的角度看select的每次调用带来的都是临时的、动态增长的一张等待表。3.4 超时与信号怎么打断这套逻辑do_select循环的退出条件有三个有fd就绪、超时、收到了信号。超时判断依赖timed_out标志。每次睡眠前它会算出剩余时间如果在指定时间内没有事件poll_schedule_timeout返回超时信息循环在下一次检查时退出。返回值变成0表示超时这是最常规的没事件结果。信号的处理更微妙。select被信号打断时do_select会返回-ERESTARTNOHAND。这个返回值在内核里表示系统调用可以被信号重启到了用户态通常表现为EINTR错误。很多新手在这里写代码时不做判断select返回-1就直接当超时处理导致程序行为完全失控。我记得测试时用timeout为NULL让select无限阻塞然后给进程发一个信号select立刻返回-1且errno是EINTR。这说明select并不是被信号打断之后自动重试而是明确地把打断结果抛给用户态。严谨的应用层代码必须判断EINTR并决定要不要重新调用select。4. 源码级性能剖析select慢的真相4.1 复杂度谜题一次select到底遍历几遍网上流传最广的错误说法是select是O(n^2)的。从源码看do_select的基本遍历是外围循环一整套内层又是逐fd循环。如果只数vfs_poll调用次数一次普通无事件返回的select在尝试睡眠之前会遍历0到n-1号fd在睡眠等待后会被唤醒再遍历一遍。所以整次调用大致是两遍全量扫描复杂度是O(n)不是O(n^2)。那O(n^2)的说法是怎么来的可能是因为每次select调用都全量扫描如果你在一个大的fd集上不停地调用select总开销是调用次数乘以O(n)但单次调用确实不是平方级。这个区分对理解性能很重要select的核心问题不是一次调用有多复杂而是就算低活跃度时期也得做全量的成本。再叠加前面提到的位图拷贝和等待项注册拆除一次没有任何事件的select调用也至少包含三张输入位图拷入内核全量扫描一遍所有监听fd并调用poll回调对每个监听fd做等待项注册进程睡眠被事件唤醒后再全量扫描一遍三张结果位图拷回用户态拆除所有等待项。其中全量扫描和等待项注册是捆绑的因为驱动一次poll回调里通常既查状态也poll_wait。这就是select在大量fd场景下表现不佳的根本原因每次调用不论有没有实际事件该做的全套动作一步都不少。4.2 与poll、epoll的源码路径对比对比才能真正看出差距下面表格是我整理后的直观印象对比维度selectpollepoll数据结构fd_set位图pollfd数组内核红黑树就绪链表最大fd限制FD_SETSIZE1024无硬性1024上限无硬性上限等待项注册时机每次调用临时注册每次调用临时注册epoll_ctl时注册并缓存事件收集方式醒后全量重扫醒后全量重扫事件回调把fd挂入就绪链表用户态拷贝内容完整输入完整结果位图完整pollfd数组仅就绪事件触发模式水平触发水平触发水平触发边缘触发poll和select在源码上非常像都是全量遍历临时等待项。poll的优势只是不再受1024字节位图限制fd上限取决于内存和进程fdtable但整体模型依然是每次调用全量搬数据。epoll则完全不同。它的核心工作流是epoll_ctl向内核注册一批fd每个fd的等待项被持久化保存epoll_wait睡眠等待事件事件发生时回调函数直接把就绪fd挂到rdlist上并唤醒进程进程醒来后只需把rdlist里的就绪项拷到用户空间。它不需要醒后重扫不需要重新注册等待项也不需要搬运完整集合。这种差异在连接数上万的低活跃场景下差距被放大得非常明显。不过也别把epoll神化。epoll为了维护红黑树、管理epitem、维护回调关系在每次增加删除fd时都比select复杂得多。如果fd数量很少select一遍全量扫描的开销其实比epoll那套复杂结构更划算。源码逻辑的取舍最终落到场景上。4.3 select真正适合的场景结合源码我把select的适用场景总结成三条都是经验之谈。第一监听fd数量少。几十个以内的情况下select的全量扫描和位图拷贝开销完全不值一提代码可读性也很好。第二需要水平触发且对精度要求不高。socket服务里如果有现成老代码不追求极致吞吐用select完全能跑。第三需要跨平台可移植。select是几乎所有操作系统都支持的老牌接口代码在一台陌生的机器上重编译往往就能用。这一点在嵌入式环境和各种受限的接入设备里很关键。我自己平时写多路复用代码的倾向是模块里fd规模明确不超过几十个直接select维护成本最低fd规模可能增长到成百上千直接用poll或者epoll绝不能靠提高FD_SETSIZE绕过1024限制去硬撑select那是拿未定义行为换性能早晚出事。5. 实战避坑源码里藏着的经典问题速查5.1 1024上限与EINVALselect在n FD_SETSIZE时会返回EINVAL这个检查一部分在用户态glibc层一部分在内核入口。我在实际项目里遇到最多的情况是进程用ulimit -n把文件描述符上限调到了65535然后在select里传入了大于1023的fd编号结果发现select行为诡异。一查才知道fd_set在用户态只有1024个bitfd号超过1023根本塞不进去。解决方法也很简单直接监听fd规模超过1024时换poll或epoll。不要试图去改FD_SETSIZE因为内核里max_fds和位图大小不止影响一次调用整个线程/进程的fd管理都跟它绑定强行改容易出连锁反应。5.2 timeout修改和重复使用Linux的select有一个历史行为返回时会把timeout改成剩余时间。这在几乎所有Linux编程书里都写过但很多人实际写代码时还是会踩。最常见的一个坑是初始化一个timeval然后在一个循环里反复用同一个timeval调用select。第一次select等待了200ms这个timeval就被内核更新了第二次循环timeout几乎变成0select立刻返回0CPU被白白吊起来打。我的习惯是在每次调用select之前都重新设置timeout绝不复用。如果信号掩码还要一起控制直接考虑pselect它不会去修改timeout参数语义更干净。5.3 无效fd与EBADF前面提到do_select遇到集合中无效fd会返回EBADF。这个错误码在并发场景下特别容易出现。比如多线程程序里线程A正在select监听某个socket fd线程B此时调用了close关闭了这个fd线程A的select就可能撞上EBADF。排查这类问题经验是先用strace看系统调用到底传了哪些fd和什么结果再结合代码里所有close的位置做排查。如果没有strace环境可以设计成所有fd的关闭和select操作都由同一个线程负责从架构上规避并发竞争。5.4 多进程/多线程下的惊群select的等待项是普通等待唤醒时会把所有挂在同一等待队列上的进程都唤醒。假如多个进程/线程同时对一个listen socket做select新的连接到来时所有等待者都会被唤醒然后争抢同一连接。源码里没有给select加WQ_FLAG_EXCLUSIVE这类排他机制惊群问题完全暴露给应用层。老牌服务器的做法是在accept之后自己加锁或限制同一时刻只有一个线程进入accept路径或者干脆用SO_REUSEPORT让内核做分发。如果用的是epoll还可以用EPOLLEXCLUSIVE减少唤醒范围select没有这个选项。5.5 驱动poll回调忘调poll_wait这条在写Linux驱动时会特别明显。内核文档明确要求如果你的驱动想支持select/poll/epoll等待机制实现file_operations-poll时必须在检查状态之外调用poll_wait(file, 你的等待队列头, wait)。有些驱动作者偷懒poll回调里只返回mask不调用poll_wait。后果是什么select扫描这个fd时虽然从poll回调里拿到了即时状态但当前进程压根没有挂到这个驱动的等待队列上。当设备的状态变化发生时没有哪个等待项会唤醒进程。应用层表现就是select永远卡在睡眠状态设备明明有数据了却不返回。我排查嵌入式设备问题时最后用逻辑分析仪定位到驱动上补上了poll_wait那一行应用层立刻恢复正常。6. 看完源码之后的几点心得如果你也想把这段源码当作内核学习的第一站我建议的路线是先看fs/select.c里的SYSCALL_DEFINE5接着core_sys_select然后一头扎进do_select最后再看poll_initwait、__pollwait、pollwake、poll_freewait这一串等待机制。把这几个函数串起来select的完整生命周期就呈现在你眼前了。我个人实际写完代码、排查完问题后的体会是select并不像舆论说的那样一无是处。它是一种非常古老的全量状态模型优点是好懂、可移植、实现直接缺点是每一次调用都要背负位图拷贝、临时等待项、醒后重扫这些固有成本。对小型fd集合它依旧是清爽实用的选择对大规模高并发它才真正力不从心。最后分享一个小技巧。阅读select源码时建议同时把poll的源码也摆出来对照看你会发现两者的主循环结构高度相似只差在数据结构上。把select看明白了再去读epoll那套红黑树和就绪链表就能真切地感觉到事件驱动四个字的分量。技术选型从来不是选一个绝对最好的方案而是选一个最贴合当前场景的方案。select是不是适合你取决于你的fd规模、可移植性需求和团队维护成本。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑