Liunx内核IO多路复用selector机制简介
Liunx内核多路复用selector机制简介
- 前言
- 内核IO多路复用selector机制简介
- 1. Select 系统调用入口与核心流程概述
- 2. 核心数据结构
- 2.1 位图管理:`struct fd_set_bits`
- 2.2 监控与唤醒桥梁:`struct poll_wqueues` 与 `struct poll_table`
- 3. 源码级深度剖析
- 3.1 入口层:`core_sys_select`
- 3.2 核心循环层:`do_select`
- 3.3 挂载与唤醒机制:`poll_initwait` 与 `__pollwait`
- 4. 驱动层与内核的交互:以 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_table
select复用了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(n<0)gotoout_nofds;// 修正 n 的最大值,不能超过当前进程的文件打开上限max_fds=limit_output_fds(n);if(n>max_fds)n=max_fds;// 计算存放 6 个位图(in, out, ex 及其对应的 res)所需的总字节数size=FDS_BYTES(n);alloc_size=6*size;bits=stack_fds;// 如果栈空间不够,则从堆中分配if(alloc_size>sizeof(stack_fds)){ret=-ENOMEM;bits=kvmalloc(alloc_size,GFP_KERNEL);if(!bits)gotoout_nofds;}// 划分为 6 个区域fds.in=bits;fds.out=bits+size;fds.ex=bits+2*size;fds.res_in=bits+3*size;fds.res_out=bits+4*size;fds.res_ex=bits+5*size;// 将用户态的位图拷贝到内核态,如果没有传入则清零if((ret=get_fd_set(n,inp,fds.in))||(ret=get_fd_set(n,outp,fds.out))||(ret=get_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);// 调用核心业务逻辑ret=do_select(n,&fds,end_time);if(ret<0)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_select
do_select实现了对所有 FD 的死循环轮询。如果第一轮轮询未发现就绪 FD,则将当前进程挂起,直至超时或被文件驱动唤醒。
intdo_select(intn,fd_set_bits*fds,structtimespec64*end_time){ktime_texpire,*to=NULL;structpoll_wqueuestable;poll_table*wait;intretval,i,timed_out=0;u64 slack=0;// 1. 初始化 poll_wqueues 结构,关键是设置 table.pt._qproc = __pollwaitpoll_initwait(&table);wait=&table.pt;if(end_time){// 计算绝对超时时间expire=timespec64_to_ktime(*end_time);to=&expire;slack=select_estimate_accuracy(end_time);}retval=0;// 2. 无条件死循环for(;;){unsignedlong*rinp,*routp,*rexp,*inp,*outp,*exp;inp=fds->in;outp=fds->out;exp=fds->ex;rinp=fds->res_in;routp=fds->res_out;rexp=fds->res_ex;// 以 unsigned long 为步长(一般为 32 或 64 位)遍历所有的 FDfor(i=0;i<n;){unsignedlongin,out,ex,bit,res_in=0,res_out=0,res_ex=0;structfdf;in=*inp++;out=*outp++;ex=*exp++;// 如果这 64 个 FD 对应的位全是 0,直接跳过,提高效率if(!(in|out|ex)){i+=BITS_PER_LONG;rinp++;routp++;rexp++;continue;}// 逐位遍历这一个 long 空间内的每一个文件描述符for(bit=1;bit&&i<n;bit<<=1,i++){// 检查信号中断if(signal_pending(current)){retval=-ERESTARTSYS;gotoout_of_loops;}// 确认该 FD 是否在用户的监控范围内if(!(bit&(in|out|ex)))continue;f=fdget(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 mask=vfs_poll(f.file,wait);fdput(f);// 检查驱动返回的 mask 是否匹配监控的事件,匹配则记录到结果位图中if((mask&POLLIN_SET)&&(bit&in)){res_in|=bit;retval++;wait->_qproc=NULL;// 一旦有就绪事件,后续 FD 就不再挂载到等待队列}if((mask&POLLOUT_SET)&&(bit&out)){res_out|=bit;retval++;wait->_qproc=NULL;}if((mask&POLLEX_SET)&&(bit&ex)){res_ex|=bit;retval++;wait->_qproc=NULL;}}}// 将当前 long 的计算结果写回结果位图if(res_in)*rinp=res_in;if(res_out)*routp=res_out;if(res_ex)*rexp=res_ex;rinp++;routp++;rexp++;}// 3. 循环退出条件检查// 如果有就绪 FD (retval > 0)、已超时 (timed_out) 或有挂起信号,跳出死循环wait->_qproc=NULL;// 第一轮遍历结束,后续即使不退出也不再重复挂载队列if(retval||timed_out||signal_pending(current))break;// 4. 休眠等待// 第一次遍历完没有任何 FD 就绪,进程通过 poll_schedule_timeout 进入睡眠if(!poll_schedule_timeout(&table,TASK_INTERRUPTIBLE,to,slack))timed_out=1;// 返回 0 说明超时时间到}out_of_loops:// 5. 清理工作:遍历各个驱动的等待队列,将当前进程从所有队列中移除poll_freewait(&table);returnretval;}3.3 挂载与唤醒机制:poll_initwait与__pollwait
在do_select第一轮循环中,vfs_poll会触发驱动层的等待队列挂载。驱动程序通常会调用poll_wait(),其本质是调用pt->_qproc。
在poll_initwait中,该指针被赋值为__pollwait:
voidpoll_initwait(structpoll_wqueues*pwq){init_poll_funcptr(&pwq->pt,__pollwait);pwq->polling_task=current;// 记录当前进程// ...}当驱动调用poll_wait时,实际执行的__pollwait源码如下:
staticvoid__pollwait(structfile*filp,wait_queue_head_t*wait_address,poll_table*p){structpoll_wqueues*pwq=container_of(p,structpoll_wqueues,pt);structpoll_table_entry*entry=poll_get_entry(pwq);// 分配一个项if(!entry)return;// 关联文件对象与等待队列头entry->filp=get_file(filp);entry->wait_address=wait_address;entry->key=p->_key;// 初始化等待队列节点的自定义唤醒回调函数:pollwakeinit_waitqueue_func_entry(&entry->wait,pollwake);entry->wait.private=pwq;// 传给回调的私有数据// 将本进程的等待节点加入到设备的等待队列头中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返回POLLIN,select成功收集到就绪 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改用红黑树与就绪队列分离机制的核心动力)。
