☰
Linux select内核源码解析:从fd_set到do_select的性能真相
2026/10/16 8:34:07 网站建设 项目流程

做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填成1;select返回后,内核把哪些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_table

2.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 0,fd为63时占bit 63,fd为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_poll

do_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,做的事情包括:

  1. 把当前进程设置为TASK_INTERRUPTIBLE。
  2. 调用schedule_timeout(timeout)主动让出CPU。
  3. 被唤醒或超时后返回。

这个睡眠不是无限期的,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_SETSIZE=1024无硬性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上限与EINVAL

select在n > FD_SETSIZE时会返回EINVAL,这个检查一部分在用户态glibc层,一部分在内核入口。我在实际项目里遇到最多的情况是,进程用ulimit -n把文件描述符上限调到了65535,然后在select里传入了大于1023的fd编号,结果发现select行为诡异。一查才知道,fd_set在用户态只有1024个bit,fd号超过1023根本塞不进去。

解决方法也很简单直接:监听fd规模超过1024时,换poll或epoll。不要试图去改FD_SETSIZE,因为内核里max_fds和位图大小不止影响一次调用,整个线程/进程的fd管理都跟它绑定,强行改容易出连锁反应。

5.2 timeout修改和重复使用

Linux的select有一个历史行为:返回时会把timeout改成剩余时间。这在几乎所有Linux编程书里都写过,但很多人实际写代码时还是会踩。最常见的一个坑是,初始化一个timeval然后在一个循环里反复用同一个timeval调用select。第一次select等待了200ms,这个timeval就被内核更新了,第二次循环timeout几乎变成0,select立刻返回0,CPU被白白吊起来打。

我的习惯是,在每次调用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规模、可移植性需求和团队维护成本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询