
Liunx内核多路复用selector机制简介前言内核IO多路复用selector机制简介1. Select 系统调用入口与核心流程概述2. 核心数据结构2.1 位图管理struct fd_set_bits2.2 监控与唤醒桥梁struct poll_wqueues 与 struct poll_table3. 源码级深度剖析3.1 入口层core_sys_select3.2 核心循环层do_select3.3 挂载与唤醒机制poll_initwait 与 __pollwait4. 驱动层与内核的交互以 Socket 唤醒为例5. Select 的局限性分析基于源码视角的总结前言本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限文中内容难免存在疏漏恳请读者不吝指正。内核IO多路复用selector机制简介1. Select 系统调用入口与核心流程概述Linux 内核中的select系统调用允许进程同时监控多个文件描述符FD的读、写和异常事件。当某个或某些文件描述符就绪或者超时时间到select就会返回。在 Linux 内核源码中以现代内核fs/select.c为例select的执行路径如下SYSCALL_DEFINE5(select, ...)系统调用入口处理用户态传入的参数。core_sys_select()在内核态分配内存将用户态的fd_set位图拷贝到内核态并初始化fd_set_bits结构体。do_select()核心循环。遍历所有监控的 FD调用对应驱动的poll函数。如果没有就绪事件进程将进入睡眠等待驱动程序如网卡、标准输入唤醒。清理与返回将就绪结果从内核态拷贝回用户态释放分配的资源。2. 核心数据结构理解select的关键在于其位图管理与等待队列结构。2.1 位图管理struct fd_set_bits为了减少内存分配与拷贝开销内核将用户态传入的输入、输出、异常位图合并管理structfd_set_bits{unsignedlong*in,*out,*ex;// 用户传入的监控位图内核副本unsignedlong*res_in,*res_out,*res_ex;// 用于存放结果的位图};2.2 监控与唤醒桥梁struct poll_wqueues与struct poll_tableselect复用了poll的机制。poll_table内部包含一个函数指针_qproc用于向驱动的等待队列中注册唤醒回调。typedefvoid(*poll_queue_proc)(structfile*,wait_queue_head_t*,structpoll_table_struct*);typedefstructpoll_table_struct{poll_queue_proc _qproc;// 核心回调指向 __pollwait__poll_t _key;// 监控的事件类型 (如 POLLIN, POLLOUT)}poll_table;structpoll_wqueues{poll_table pt;structpoll_table_page*table;structtask_struct*polling_task;// 当前执行 select 的进程inttriggered;// 标识是否被唤醒// ...};3. 源码级深度剖析3.1 入口层core_sys_select该函数负责在内核栈或堆中分配空间完成用户态数据的拷贝。intcore_sys_select(intn,fd_set __user*inp,fd_set __user*outp,fd_set __user*exp,structtimespec64*end_time){structfd_set_bitsfds;void*bits;intalloc_size,ret,max_fds;size_tsize;// 预分配在栈上的缓冲区避免小规模 FD 监控时的堆分配开销longstack_fds[SELECT_STACK_ALLOC/sizeof(long)];ret-EINVAL;if(n0)gotoout_nofds;// 修正 n 的最大值不能超过当前进程的文件打开上限max_fdslimit_output_fds(n);if(nmax_fds)nmax_fds;// 计算存放 6 个位图in, out, ex 及其对应的 res所需的总字节数sizeFDS_BYTES(n);alloc_size6*size;bitsstack_fds;// 如果栈空间不够则从堆中分配if(alloc_sizesizeof(stack_fds)){ret-ENOMEM;bitskvmalloc(alloc_size,GFP_KERNEL);if(!bits)gotoout_nofds;}// 划分为 6 个区域fds.inbits;fds.outbitssize;fds.exbits2*size;fds.res_inbits3*size;fds.res_outbits4*size;fds.res_exbits5*size;// 将用户态的位图拷贝到内核态如果没有传入则清零if((retget_fd_set(n,inp,fds.in))||(retget_fd_set(n,outp,fds.out))||(retget_fd_set(n,exp,fds.ex)))gotoout;// 初始化结果位图为 0zero_fd_set(n,fds.res_in);zero_fd_set(n,fds.res_out);zero_fd_set(n,fds.res_ex);// 调用核心业务逻辑retdo_select(n,fds,end_time);if(ret0)gotoout;// 将结果位图拷贝回用户空间if(set_fd_set(n,inp,fds.res_in)||set_fd_set(n,outp,fds.res_out)||set_fd_set(n,exp,fds.res_ex))ret-EFAULT;out:if(bits!stack_fds)kvfree(bits);out_nofds:returnret;}3.2 核心循环层do_selectdo_select实现了对所有 FD 的死循环轮询。如果第一轮轮询未发现就绪 FD则将当前进程挂起直至超时或被文件驱动唤醒。intdo_select(intn,fd_set_bits*fds,structtimespec64*end_time){ktime_texpire,*toNULL;structpoll_wqueuestable;poll_table*wait;intretval,i,timed_out0;u64 slack0;// 1. 初始化 poll_wqueues 结构关键是设置 table.pt._qproc __pollwaitpoll_initwait(table);waittable.pt;if(end_time){// 计算绝对超时时间expiretimespec64_to_ktime(*end_time);toexpire;slackselect_estimate_accuracy(end_time);}retval0;// 2. 无条件死循环for(;;){unsignedlong*rinp,*routp,*rexp,*inp,*outp,*exp;inpfds-in;outpfds-out;expfds-ex;rinpfds-res_in;routpfds-res_out;rexpfds-res_ex;// 以 unsigned long 为步长一般为 32 或 64 位遍历所有的 FDfor(i0;in;){unsignedlongin,out,ex,bit,res_in0,res_out0,res_ex0;structfdf;in*inp;out*outp;ex*exp;// 如果这 64 个 FD 对应的位全是 0直接跳过提高效率if(!(in|out|ex)){iBITS_PER_LONG;rinp;routp;rexp;continue;}// 逐位遍历这一个 long 空间内的每一个文件描述符for(bit1;bitin;bit1,i){// 检查信号中断if(signal_pending(current)){retval-ERESTARTSYS;gotoout_of_loops;}// 确认该 FD 是否在用户的监控范围内if(!(bit(in|out|ex)))continue;ffdget(i);// 获取文件对象 struct fileif(f.file){// 设置当前期待的事件掩码wait_key_set(wait,in,out,ex,bit);// 调用 VFS 层的 vfs_poll实际执行 f.file-f_op-poll// 这一步会做两件事// a. 执行驱动的 poll返回当前就绪的状态掩码 mask// b. 如果 wait-_qproc 不为空第一轮循环则将当前进程挂入驱动的等待队列__poll_t maskvfs_poll(f.file,wait);fdput(f);// 检查驱动返回的 mask 是否匹配监控的事件匹配则记录到结果位图中if((maskPOLLIN_SET)(bitin)){res_in|bit;retval;wait-_qprocNULL;// 一旦有就绪事件后续 FD 就不再挂载到等待队列}if((maskPOLLOUT_SET)(bitout)){res_out|bit;retval;wait-_qprocNULL;}if((maskPOLLEX_SET)(bitex)){res_ex|bit;retval;wait-_qprocNULL;}}}// 将当前 long 的计算结果写回结果位图if(res_in)*rinpres_in;if(res_out)*routpres_out;if(res_ex)*rexpres_ex;rinp;routp;rexp;}// 3. 循环退出条件检查// 如果有就绪 FD (retval 0)、已超时 (timed_out) 或有挂起信号跳出死循环wait-_qprocNULL;// 第一轮遍历结束后续即使不退出也不再重复挂载队列if(retval||timed_out||signal_pending(current))break;// 4. 休眠等待// 第一次遍历完没有任何 FD 就绪进程通过 poll_schedule_timeout 进入睡眠if(!poll_schedule_timeout(table,TASK_INTERRUPTIBLE,to,slack))timed_out1;// 返回 0 说明超时时间到}out_of_loops:// 5. 清理工作遍历各个驱动的等待队列将当前进程从所有队列中移除poll_freewait(table);returnretval;}3.3 挂载与唤醒机制poll_initwait与__pollwait在do_select第一轮循环中vfs_poll会触发驱动层的等待队列挂载。驱动程序通常会调用poll_wait()其本质是调用pt-_qproc。在poll_initwait中该指针被赋值为__pollwaitvoidpoll_initwait(structpoll_wqueues*pwq){init_poll_funcptr(pwq-pt,__pollwait);pwq-polling_taskcurrent;// 记录当前进程// ...}当驱动调用poll_wait时实际执行的__pollwait源码如下staticvoid__pollwait(structfile*filp,wait_queue_head_t*wait_address,poll_table*p){structpoll_wqueues*pwqcontainer_of(p,structpoll_wqueues,pt);structpoll_table_entry*entrypoll_get_entry(pwq);// 分配一个项if(!entry)return;// 关联文件对象与等待队列头entry-filpget_file(filp);entry-wait_addresswait_address;entry-keyp-_key;// 初始化等待队列节点的自定义唤醒回调函数pollwakeinit_waitqueue_func_entry(entry-wait,pollwake);entry-wait.privatepwq;// 传给回调的私有数据// 将本进程的等待节点加入到设备的等待队列头中add_wait_queue(wait_address,entry-wait);}pollwake作用当底层硬件如网卡接收到数据包触发中断时底层驱动会调用wake_up(wait_address)。这会触发pollwake它通过pwq-polling_task找到阻塞在select的进程并将其状态设为TASK_RUNNING接着唤醒该进程。进程被唤醒后从poll_schedule_timeout恢复执行重新进入do_select的for(;;)循环在第二轮遍历中拿到就绪数据并返回。4. 驱动层与内核的交互以 Socket 唤醒为例为了更清晰地理解内核态如何联动以 TCP Socket 读事件就绪为例用户态调用select监控某个socket_fd。**内核态do_select**调用vfs_poll→ \rightarrow→sock_poll→ \rightarrow→tcp_poll。tcp_poll内发现当前接收缓冲区为空未就绪但由于wait-_qproc存在它会调用poll_wait(file, sk_sleep(sk), wait)从而将当前进程以pollwake回调的形式挂载到 Socket 的睡眠队列sk_sleep(sk)中。网卡收到网络包产生硬件中断软中断交由内核协议栈处理。协议栈将数据放入 Socket 接收队列随后调用sk-sk_data_ready(sk)对应sock_def_readable。sock_def_readable内部遍历sk_sleep(sk)队列触发pollwake进而将select进程唤醒。进程唤醒重新在do_select中执行一轮遍历此时tcp_poll返回POLLINselect成功收集到就绪 FD 并返回用户态。5. Select 的局限性分析基于源码视角的总结通过上述源码分析可以非常直观地看出select的设计缺陷以及为什么在大并发C10K 问题下表现极差由于内核/用户态的频繁拷贝每次调用select用户态都必须把监控的fd_set整个拷贝到内核态copy_from_user返回时再整份拷贝回用户态copy_to_user。随着 FD 数量增加拷贝的内存带宽开销呈线性增长。O ( N ) O(N)O(N)的无脑轮询在do_select的核心双重循环中内核不知道具体哪一个 FD 就绪它必须从0挨个遍历到n。即使只有 1 个 FD 就绪内核也必须把其余几千个未就绪的 FD 的驱动poll函数全部执行一遍造成了极大的 CPU 资源浪费。重复的挂载与卸载销毁每次调用select在第一轮轮询时都需要将当前进程挂载到所有监控 FD 对应的驱动等待队列中触发__pollwait当select返回时又必须调用poll_freewait将进程从所有的队列中一一移除。这种高频的队列链表操作在连接数多时极其低效。FD 数量的物理限制内核中为了防止栈溢出并限定单进程消耗通常硬编码了__FD_SETSIZE 1024。若要突破该限制必须重新编译内核这进一步限制了它的可扩展性这也是后来poll改用数组结构、epoll改用红黑树与就绪队列分离机制的核心动力。