TaterLi 个人博客

[内核学习]IO多路复用

一个进程盯着几百个 fd,任何一个有动静就去处理 — select,poll,epoll 三代接口解决的都是这件事.上一篇讲阻塞时埋了伏笔,这篇把它收掉:驱动侧的 poll 回调怎么写,epoll 为什么快,水平触发和边缘触发到底差在哪.全部实测,LT 和 ET 的分野一行输出就能看清.

这篇回答几件事:

先写设备.支持多路复用的驱动只需要一个回调:

static __poll_t poll_poll(struct file *f, poll_table *wait)
{
	poll_wait(f, &poll_wq, wait);	/* 把自己挂进等待队列, 仅注册不睡 */
	return data_ready ? (EPOLLIN | EPOLLRDNORM) : 0;
}

两件事:调 poll_wait 登记等待队列,然后报告当前状态.poll_wait 是整个机制里最容易被误解的一句 — 它不阻塞,只把"如果这个设备有动静,请通知我"的回调挂进驱动的等待队列.这句话是驱动和复用层之间的全部契约:驱动数据来了要 wake_up_interruptible(&poll_wq),谁来收,复用层自己安排.设备这边我加了个 ioctl 模拟"数据到达",设置标志并 wake_up.

三代接口先排个队.select 最老,fd_set 是个固定位图,FD_SETSIZE 1024 是硬顶,每次调用还要整张位图在用户态内核态之间拷来拷去.poll 时代去掉了位图,fd 数量随你,但本质没变:do_sys_poll(fs/select.c)每次调用把你给的每个 fd 的 .poll 回调挨个跑一遍,O(n),一百个 fd 就跑一百次,一千个就一千次,大多数次的结果都是"没动静",白跑.epoll 的革命在这:把"注册"和"等待"拆开.epoll_ctl 加进红黑树,只此一次;epoll_wait 只看就绪链表 — 链表平时是空的,谁把 fd 挂进去?就是驱动的 wake_up 触发 poll_wait 当初登记的 ep_poll_callback(fs/eventpoll.c),数据到哪个设备,回调从哪个设备出发,直接把对应 fd 塞进就绪链表并唤醒 epoll_wait.事件驱动的回调代替了遍历,十万 fd 也只和活跃的那几个相关,这才是 epoll 常说的 O(1).

然后是 LT/ET,同一份数据,两种脾气.epoll_ctl 加 fd 时带不带 EPOLLET 的区别,实测一把最清楚 — 放一次数据,连续调两次 epoll_wait:

[LT] 放数据后第一次: n=1 ev=0x1; 第二次: n=1 (水平仍就绪!)
[ET] 放数据后第一次: n=1 ev=0x1; 第二次: n=0 (边沿已消费)

LT 水平触发,只要"可读"这个状态还在,每次 epoll_wait 都如实报告,你不读它就一直喊.ET 边缘触发,只报"状态从无到有"那一瞬间,消费一次就闭嘴,哪怕数据还剩着.ET 省了重复通知,代价是使用纪律:必须一口气把数据读完 — 循环 read 直到返回 EAGAIN,否则残留数据永远不会再有通知,连接就"卡死"了,这是 ET 的经典事故.ET 还必须配非阻塞 fd,阻塞 read 读完一波再读会睡死.默认 LT 不是因为落后,是因为它安全,除非你真的在管十万连接,别急着切 ET.

最后看唤醒链路怎么真正跑通.fork 一个子进程,epoll_wait 带无限超时死等;父进程睡一秒,然后 ioctl 让驱动放数据:

[parent] 1 秒后放数据...
[child] 被唤醒! n=1 ev=0x1

这条链在内核里走了四站:驱动 wake_up_interruptible 摇等待队列 → 队列里挂着的不是进程,是 ep_poll_callback → 回调把 fd 挂进 epoll 就绪链表,顺便唤醒睡着的 epoll_wait → 子进程拿号返回.注意妙处:睡的是 epoll_wait,而摇的是设备的等待队列,中间的路由完全由复用层自动完成 — 这就是 poll_wait 那一句注册的价值,驱动对谁在等,怎么等,一无所知.

最后留几个容易忽略的细节:

退出移动版