io多路复用的三种方式
select和 poll
select和poll是写在一个文件中的。
先来看看poll的写法
do_poll()
来看这段至关重要的方法
函数内部分数据结构的解释
- pollfd:用户传入的结构,表示一个要监视的 fd;
- poll_table:内核内部用于构建“等待队列”的结构,pt->_qproc 是用于注册事件回调的指针;
- poll_wqueues:内核为当前线程构建的等待队列集合,整个轮询过程中用于挂接所有 fd 的 wait queue;
- mask:__poll_t 类型,是各个事件位(比如 POLLIN, POLLOUT)的组合。
明确这里的对象是什么?主要这里的传参
文件描述符列表 poll_list?
可以看到对于io多路复用如何理解最小单元的文件描述符是通过这个结构描述的也就是一个封装了事件信息的文件描述符对象。
为什么这个ilst还有指针相关的定义?
当用户调用 poll() 系统调用,并传入一个很大的 pollfd 数组时,内核不会直接在线栈或内核栈上全部保存这些数据,而是会按一定大小分段拆分成多个 poll_list 结构,通过链表组织起来。
等待队列?
int triggered; // 是否有事件触发
int error; // 错误码
int inline_index; // inline_entries 的使用个数
// poll_queue_proc 是一个函数指针
// 指向这样的函数:接受3个参数(file指针,等待队列头指针,poll_table指针)并返回 void
void some_func(struct file *filp, wait_queue_head_t *wq, struct poll_table_struct *p);
poll_queue_proc f = some_func;
在 poll() 的实现中,系统调用函数不会直接知道每个 fd 怎么等待。这个责任由每个 fd 自己(也就是设备/文件的 f_op->poll())来完成。但 poll() 需要一种方式告诉这些 f_op->poll()。
也就是do_poll函数中的
这个方法
mask = do_pollfd(pfd, pt, &can_busy_loop, busy_flag);
调用该 fd 类型注册的 poll() 方法(比如 socket_poll、tty_poll);
传入 poll_table,对方如果发现“还没就绪”,就会注册当前线程到它的等待队列中(add_wait_queue());
一旦底层事件触发,会唤醒当前线程,重新执行这个 for(;;)。
跟踪过来
do_pollfd()
└──> 获取 fd -> struct file *
└──> 设置 poll_table->_key
└──> 调用 vfs_poll(file, poll_table)
└──> 调用 file->f_op->poll(file, poll_table)
└──> 设备注册等待队列 & 返回就绪掩码
└──> 返回就绪掩码 &filter
可以看到获取文件描述符的过程是不会阻塞的。但是在调用状态这边又陷入到了vfs的虚拟文件系统中去。所以这个文件描述符的状态是一个维护在vfs中的。
- 检查 `file->f_op`(即文件操作函数集合)里有没有实现 `.poll` 方法;
- 如果没有(这是极少数情况,用 `unlikely` 标记),就直接返回默认掩码:`DEFAULT_POLLMASK`;
- 默认值一般为 `POLLERR | POLLHUP`,意味着“我啥也不知道,最好你别等”。
然后vfs这边也是检查文件描述符的注册信息来判断是否吧可读可就绪的行为返回给上游。
结论
所以结论上
光是从
do_poll
这个方法上来说。poll 本质上要做的不断的遍历pollfd 这些结构记录了用户传入的 fd + 事件类型 + 返回值。
上面的for (;;) 则是无限循环
不断地轮询所有 fd 是否有就绪事件如果有事件,或者超时/信号打断,就 break 返回结果。
select
我们来看下select的poll和下游的poll的一些差异。
整理了下主流程。
1. 获取最大 fd 数(max_select_fd)
2. 初始化 poll 等待队列 poll_initwait
3. 检查是否为立即返回
4. 开始轮询检查循环 for (;;)
- 遍历所有 fd,调用 select_poll_one
- 判断是否满足条件(POLLIN、POLLOUT、POLLEX)
- 如果有任何一个满足,设置对应 result bitmap
5. 判断是否有事件就绪 / 超时 / 收到信号
6. 若未触发事件,根据是否支持 busy loop 继续轮询
7. 若有 timeout,转换为 ktime_t 格式后调度等待
- 清理等待队列 poll_freewait
先看下数据结构吧
先看下三个传参
- n:要检查的最大文件描述符数量。
- fds:三个文件描述符集合的封装(in、out、ex 及对应结果)。
- end_time:等待的超时时间(绝对时间戳,非相对超时)。
我们可以看看到不同于poll使用封装的poll_fd及对应的List select直接用一个fd_bits相关的数据结构来表示文件描述符。
字段 含义 对应用户态参数
in 输入:读事件 fd 的位图 readfds
out 输入:写事件 fd 的位图 writefds
ex 输入:异常事件 fd 的位图 exceptfds
res_in 输出:就绪的读事件位图 readfds
res_out 输出:就绪的写事件位图 writefds
res_ex 输出:就绪的异常事件位图 exceptfds
着重看这段流程
宏观逻辑
for (;;) {
检查所有 fd;
如果有就绪:直接返回;
否则挂载等待队列;
调度等待(或忙等);
}
重点是这个方法
select_poll_one()
。
它的作用是:
- 根据 fd 编号 i,获取该 fd 对应的 file;
- 调用 file->f_op->poll(file, wait);
- 由该设备决定当前是否就绪;
- 还会顺带调用 poll_wait(file, wait_queue, wait),把当前进程注册到设备的等待队列中。
这是一个对单个 fd 做“一次轮询检查”的动作,其逻辑包括:
- 获取 fd 对应的 file 对象。
- 判断 fd 是否有效,无效直接返回 EPOLLNVAL。
- 构建当前感兴趣的事件集(_key),包括读写/异常/busy-polling。
- 调用 VFS 的 poll 接口,去检查当前 fd 是否就绪,或者是否需要挂起等待事件。
然后从位图拿出文件描述符。还是要走那个 select_poll_one 逻辑通过vfs侧的poll才能够拿到真实的状态
所以位图的作用本质上是告诉进程那些文件描述符被进程“注册了”然后实际的状态校验还是得要调用vfs侧的方法。
select 和poll的总结
所以小小的做个总结
比较项 select() poll()
系统调用入口 __do_sys_select() → do_select() __do_sys_poll() → do_poll()
使用数据结构 fd_set_bits 三个位图结构 pollfd[] 数组结构
等待队列封装 poll_table 结构 同样用 poll_table
目的 兼容 POSIX 早期标准 更现代,效率更高,适用于大量 fd
epoll
epoll的路径在这个位置
这里有一段很重要的注释。
他告诉我们在文件描述符注入到epoll之后是用这种树形的层级结构进行存储的
这里有一段值得关注的注释和说明
当一个包到达设备后,net代码将在它的轮询唤醒列表上发出wake_up()。
也就是说epoll的唤醒驱动是由设备经由网络层的调用唤醒的?
代码实验
弄一段测试代码
运行的式例
其中 Ready fd: 5 表示 触发事件的文件描述符是 5
这段代码的事件触发流程:
- 写入数据:
write(sv[0], "ping", strlen("ping"));
这会让 sv[1](管道的另一端)变成可读状态。
- sv[1] 触发事件 ev1:
在 epfd1 这个 epoll 实例中注册了对 sv[1] 的监听,事件是 EPOLLIN(可读)。
因为数据来了,sv[1] 变得可读,epfd1 会检测到这个事件。
- 嵌套监听,ev2 被触发:
又在 epfd2 这个 epoll 实例中注册了对 epfd1 这个文件描述符的监听(同样监听 EPOLLIN)。
当 epfd1 有事件发生(即 sv[1] 可读时),epfd1 本身就会产生一个事件通知,这个事件被 epfd2 监听到。
- epoll_wait(epfd2, ...) 返回:
因为 epfd1 发生事件了,epfd2 会被唤醒,epoll_wait 返回,告诉你 epfd1(你看到的数字5,就是 epfd1 的文件描述符)发生了事件。
那如果返回的是epfd1监听的内容是什么呢?
就是我们写入管道的Ping 字符串了
内核跟踪
由于题主的liunx 内部版本不高这边想要使用比较高级的调试工具比较麻烦。只能够用老办法了。为了避免过多的干扰只侧重的关注的epoll_wait()相关的流程。
1️⃣ ep_poll() 是 epoll 的主循环
这个是内核中的主函数,负责等待事件发生,调用 waitqueue 等机制阻塞自己,然后被唤醒。
2️⃣ ep_eventpoll_poll() 是内部核心调度函数
它会遍历 epoll 的红黑树/链表,检查是否有已就绪的事件,并安排回调触发唤醒机制。
3️⃣ ep_poll_callback() 是事件触发时执行的回调函数
当文件描述符上有事件发生时,会执行这个回调函数,用于将就绪事件插入 ready list,并触发 wakeup。
4️⃣ ep_poll_safewake() 负责唤醒阻塞中的 epoll_wait
内部封装了 wake_up 机制,确保不会出现错误或并发竞态唤醒失败。
5️⃣ ep_remove() 说明有 fd 被删除了
可能是 epoll_ctl(..., EPOLL_CTL_DEL, ...) 触发,也可能是自动清理无效 fd。
大概的调用链就是长这样。我们仔细一点去看下。每个具体的方法
代码解读
先看下用户态的流程
具体实现
主要是做一些参数合法性的检查
陷入内核态
回到我们的调用链 。先看epoll的函数
主干
ep_poll()
├── ep_events_available(ep) // 检查是否有就绪事件
├── ep_try_send_events() // 发送事件到用户空间
├── ep_busy_loop() // 自旋轮询(可选)
├── signal_pending() // 中断信号检测
├── init_wait(&wait) // 准备 wait queue 项
├── __add_wait_queue_exclusive() // 将当前线程加入 epoll 的等待队列
├── schedule_hrtimeout_range() // 睡眠(直到事件或超时)
├── __remove_wait_queue() // 唤醒后清理
├── ep_try_send_events() // 再次尝试发送事件
先看下数据结构
这边的解读参考 这篇文章
https://www.bluepuni.com/archives/epoll-in-depth/
我重点关注到这个。文件对象直接关联到这个结构体中了。
struct epoll_event 中的字段 events 确实可以看作是一个 “事件类型的位图(bitmask
每一位代表一种事件类型,你可以通过 按位或(|) 将多个事件组合起来监听。
epoll_ctl(epfd2, EPOLL_CTL_ADD, epfd1, &ev2);
这句代码中的事件映射就是依赖这个数据结构配合宏定义完成的。
还记得我们之前的这里的写法。
int n = epoll_wait(epfd2, events, 10, 1000); 这个配置的事件就是poll的那个struct timespec64 *timeout 参数
ep_poll() 会阻塞当前线程到 ep->wq 等待队列中,直到某个事件通过 ep_poll_callback() 来唤醒它。
ep_poll_callback() 从上到下大概的引用链大概这样。
这个方法引用
这个方法引用
我们仔细来看下这段代码
所以唤醒是有两个条件的ctl注册的事件链 以及wait维护的线程唤醒链
我们都知道epoll是一个异步的过程
这段调用就比较典型
关注代码的这里这里就有明确的说明从等待队列中唤醒重新进入就绪状态。我们知道对于另外一侧阻塞的那个线程是调用那个do_epoll()那些线程。然后他的上游是wait()我们又知道wait()是一个带过期时间相关的阻塞等待.....
回顾这个调用链
ep_poll_safewake() 的核心目标是:
在嵌套 epoll 文件之间安全地唤醒挂在 poll_wait 上的 epoll 实例,并处理嵌套级别锁的问题。
对比总结
同样的逻辑我们用select和poll写一版
select版本
poll版本
我发现无论是select还是wait 还是poll 在用户侧都是阻塞的写法。
然后我们知道select和poll本质是都是依赖于vfs_poll也就是由文件描述符返回可读状态。这对于我们的查询线程来说是轮询的。
而epoll则是一个等待队列然后等待硬件进程来执行唤醒。
所以本质epoll相对于poll和select最大的优势还是省下那个对文件描述符轮询的资源和一个搜索上的数据结构的优化。对于上游都是阻塞的。但是epoll的wait()则是间断阻塞的去调用。这种情况下定时加事件驱动机制。应该会在某些时候会比传统的用户态调用更加消耗用户态的资源。所以这个时候也应该引入对于用户态来说不必间断的去获取fd的方法或者说是策略那这个就是水平触发和边缘触发的优势了。然后这个边缘触发和水平触发的配置主要就体现在ctl()中的事件类型了。那如果非要不严谨的对应的话。select和poll又是什么触发呢?这个是要明确的是二者的原理都是把本质是用户态每次都要把整个 fd 集合“拷贝到内核态”做遍历判断。只要文件描述符的状态不改变就不会出现数据没读完数据状态没发生改变。再次调用还会发生等待变化的阻塞。而对于,epoll的边缘触发来说。因为事件驱动的性质,你前后调用的两次wait()可能会发生文件描述符可读了但是没有收到驱动信号。所以第二次wait就会阻塞到下一个文件描述符的改变周期中去。
补充:
在 Linux 内核中,每个进程维护一个 files_struct 结构体,里面有一个 fdtable
struct fdtable {
unsigned long *open_fds; // 位图:哪些 fd 是打开的(每一位表示一个 fd)
struct file **fd; // 数组:fd[i] 是真正的 file 指针
}
我们可以看到select()这里就是通过位图扫描的方式去判断文件描述符的状态。但是位图是一种数据化的驱动方式那这样子我们不妨进一步追问基于这个数据的一些状态维护是怎么做的?
查阅资料我们知道
真正的 fd -> struct file* 是进程控制块里的 files_struct 来维护的!
struct fdtable {
unsigned int max_fds; // 支持的最大 fd 数量(如 1024, 4096 等)
struct file __rcu **fd; // fd[i] 是一个指向 file 的指针数组
unsigned long *close_on_exec; // 位图,fd 执行 exec 后是否自动关闭
unsigned long *open_fds; // 位图,当前是否打开
unsigned long *full_fds_bits; // select/poll 用的辅助位图缓存
struct rcu_head rcu; // RCU 用于释放旧的 fdtable
};
Linux 用 unsigned long 来做位图,是因为:
- long 是 CPU 原生处理的最小位宽单位(32位机器是 4 字节,64位机器是 8 字节);
- 它是对 CPU 指令 bt, bts, btr, btc 等位操作指令最优化的底层映射;
- unsigned long 数组天然可以用于快速 find_first_bit() 等内核算法。
然后我们明确位图不是“真实的文件描述符指针集合”
然后我们随便找一个进程和文件描述符操作相关的函数来看
这里有个加锁的方法好像是。
这个方法里面有一个这个方法
在追一层
就找到了一致性原子的去操作位图的函数。
所以本质上位图和真实维护的文件描述符指针的一致性是依赖于实际代码中的原子一致性处理。
所以这就是为什么在上面那个do_select中我们拿到位图遍历。就可以拿到对应到文件描述符对象。本质上来说是因为各种操作中作了映射。