阻塞、非阻塞、同步、异步详解(数据流本质视角)
本文档从”数据从设备到用户空间的完整旅途”这一角度,深入剖析 I/O 模型中四个核心概念的本质。区别于抽象概念的解释,本文聚焦于:数据在哪、谁在移动它、卡在哪里、为什么卡。
一、数据流模型:三站旅途
1.1 一切 I/O 的本质
数据从设备到用户空间,必须经过三站:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 设备层 │ ① │ 内核缓冲区 │ ② │ 用户缓冲区 │
│ (硬件) │ ───────> │ (kernel) │ ───────> │ (user space) │
└──────────────┘ DMA/中断 └──────────────┘ CPU └──────────────┘
第一站 第二站 第三站
数据产生地 中转站 目的地
| 站 | 所在位置 | 谁产生数据 | 数据形态 |
|---|---|---|---|
| 第一站:设备层 | 硬件(网卡、磁盘、传感器等) | 硬件产生或外部输入 | 原始数据 |
| 第二站:内核缓冲区 | 内核空间(kmalloc/vmalloc) | DMA 控制器或中断处理程序拷贝到这里 | 待处理数据 |
| 第三站:用户缓冲区 | 用户空间(malloc/全局数组) | 应用程序主动从内核取走 | 应用可用数据 |
1.2 数据必须依次穿越三站
站 ① → 站 ②:硬件/DMA 控制器完成(不占用 CPU)
站 ② → 站 ③:CPU 指令完成(必须占用 CPU,即 copy_to_user)
第一站到第二站是由 DMA(Direct Memory Access)控制器完成的,CPU 只需要配置一次,后续拷贝由 DMA 硬件独立完成。
第二站到第三站是内存拷贝,必须由 CPU 执行 memcpy 系列的指令(在内核中对应 copy_to_user)。
1.3 四个 I/O 概念的本质定义
阻塞/非阻塞 ── 数据没到时,"卡在哪一站入口"
同步/异步 ── 数据穿越站②到站③时,"谁来推动"
二、同步阻塞 — 最老老实实的模型
2.1 完整时序图
用户进程 内核 硬件
│ │ │
│ ──① read() 调用────────────> │ 发起系统调用 │
│ │ │
│ [用户态 → 内核态] │ │
│ │ ② 检查内核缓冲区 │
│ │ ─────────────────────────> │
│ │ <─ 数据不存在 ───────────── │
│ │ │
│ │ ③ 硬件未就绪 │
│ │ 当前进程加入等待队列 │
│ │ schedule() 让出 CPU │
│ [进程状态: S (睡眠)] │ │
│ [CPU 占用: 0%] │ [CPU 去执行其他进程] │
│ ──────────────────────────── │ │
│ │ ...... 时间流逝 ...... │
│ │ │
│ │ <──── 硬件中断 ──────────── │
│ │ DMA 将数据拷贝到内核缓冲区 │
│ │ wake_up() 唤醒进程 │
│ │ │
│ [进程状态: R (就绪)] │ │
│ [被调度器选中] │ │
│ │ │
│ │ ④ 内核将数据从内核缓冲区 │
│ │ 拷贝到用户缓冲区 │
│ │ copy_to_user() │
│ [进程状态: R (内核态运行)] │ │
│ [CPU 占用: 100%] │ <───────────────────────── │
│ copy_to_user() 执行中 │ 拷贝完成 │
│ ──────────────────────────── │ │
│ │ │
│ [内核态 → 用户态] │ │
│ 返回成功 │ │
2.2 三站分析
第一站(设备层)
→ 设备没数据,本应在此等待
→ 但 CPU 不能等(太慢了),所以跳过了这一步
→ 让进程直接睡在第二站入口
第二站(内核缓冲区)
→ 站②没数据 → 进程在站②入口睡眠(状态S)
→ 等待 DMA 中断唤醒
→ 这是"阻塞"真正发生的地方
第三站(用户缓冲区)
→ 站②有数据 → 进程占用 CPU 执行 copy_to_user
→ 进程在内核态运行,不是在睡觉
→ 但无法返回用户态执行其他代码 → 这也叫"阻塞"
2.3 关键点:为什么进程要在第二站入口睡眠?
原因:CPU 和设备的速率极不匹配
设备(网卡/磁盘)产生数据的速度:毫秒~秒级
CPU 处理数据的速度:纳秒~微秒级
如果 CPU 在第一站等待设备:
CPU 每秒可执行 10亿 条指令
设备 1秒 才来 1条 数据
CPU 浪费了 999,999,999 条指令的时间在空等
正确做法:
CPU 检查一次 → 发现没数据 → 立即去睡
设备有数据时通过中断通知 CPU
CPU 被唤醒后继续工作
→ CPU 利用率 100%(只做有意义的事)
2.4 阻塞的两层含义
| 层次 | 发生位置 | 进程状态 | CPU 占用 | 原因 |
|---|---|---|---|---|
| 睡眠阻塞 | 第二站入口(数据未就绪) | S(睡眠) | 0% | 设备太慢,不能等 |
| 内核态阻塞 | 第二站→第三站(copy_to_user) | R(运行,内核态) | 100% | 必须由 CPU 执行拷贝 |
常见的误解:
❌ 误解:”copy_to_user 时进程在睡觉,没有占用 CPU”
✅ 正确:”copy_to_user 时进程在内核态运行,占用 CPU 执行拷贝指令,但无法返回用户态执行其他代码”
2.5 精确回答:进程到底什么时候放弃了 CPU?
问题1:copy_to_user 时进程有没有 CPU 使用权?
有,而且 100% 占用 CPU。 它正在执行 copy_to_user 中的汇编指令序列(本质上是 memcpy)。
问题2:进程到底什么时候放弃了 CPU 使用权?
只有一次——在 read() 系统调用内部,检查到内核缓冲区没有数据时,调用 schedule() 进入睡眠状态。
完整精确的进程状态变化如下:
用户进程 内核 硬件
│ │ │
│ ① read() 调用 │ │
│ [用户态 → 内核态] │ │
│ 进程状态: R (用户态) │ │
│ CPU 占用: 100% │ │
│ ←───────────────────────────── │ │
│ │ │
│ │ ② 检查内核缓冲区 │
│ │ ← 数据不存在 │
│ │ │
│ │ ③ schedule() 让出 CPU │
│ │ 当前进程加入等待队列 │
│ 进程状态: S (睡眠) │ │
│ CPU 占用: 0% ← 放弃CPU │ [CPU 被调度器分配给其他进程] │
│ [在等待队列中睡觉] │ │
│ ──────────────────────────── │ │
│ │ <───── 硬件中断 ────────────│
│ │ DMA 将数据拷贝到内核缓冲区 │
│ │ │
│ │ ④ wake_up() 唤醒进程 │
│ 进程状态: R (就绪) │ ← 被放回可运行队列 │
│ [等待调度器选中] │ │
│ ──────────────────────────── │ │
│ │ │
│ [被调度器选中] │ │
│ ⑤ copy_to_user() 执行 │ │
│ [内核态 → 内核态] │ │
│ 进程状态: R (内核态运行) │ │
│ CPU 占用: 100% ← 关键! │ 执行 memcpy 指令序列 │
│ [正在占用 CPU 执行拷贝] │ <───────────────────────── │
│ │ 拷贝完成 │
│ │ │
│ [内核态 → 用户态] │ ⑥ 返回用户态 │
│ 进程状态: R (用户态) │ │
│ CPU 占用: 100% │ │
问题3:copy_to_user 时到底是谁在占用 CPU?
是同一个进程在占用 CPU(内核态),不是内核在占用 CPU。
【关键澄清】
内核没有"进程"的概念。内核是一个运行在所有进程上下文中的代码集合。
进程从用户态进入内核态后,CPU 上跑的还是这个进程,
只是权限更高、执行的是内核代码。
所以:
→ copy_to_user 时:是进程在内核态运行,占用 CPU
→ 进程持有 CPU 正在执行 memcpy 指令
→ 只是这个进程无法返回用户态去做别的事
问题4:睡眠阻塞 vs 内核态阻塞的本质区别
【睡眠阻塞】(发生在站②入口,数据未就绪时)
进程主动调用 schedule() → 进入 S 状态
→ 进程表被标记为睡眠
→ CPU 让给其他进程用
→ 进程真的"不跑了"
本质:进程层面真正的"阻塞",进程主动放弃 CPU
【内核态阻塞】(发生在站②→站③,copy_to_user 执行时)
进程持有 CPU,正在执行内核代码
→ 进程确实在"用"CPU
→ 但进程无法返回用户态去做用户想做的事
本质:调用层面上的"阻塞"(调用没返回),但 CPU 正在被使用
问题5:谁放弃了 CPU?谁没有放弃 CPU?
| 时间段 | 进程状态 | CPU 占用 | 谁在用 CPU | 说明 |
|---|---|---|---|---|
| read() 调用 → 检查缓冲区 | R (内核态) | 100% | 同一进程 | 检查数据是否存在 |
| 发现数据不存在 → schedule() | S (睡眠) | 0% | 其他进程 | 进程放弃 CPU,进入等待队列 |
| …等待期间… | S (睡眠) | 0% | 其他进程 | 进程完全不占用 CPU |
| wake_up 唤醒 | R (就绪) | 0% | 等待调度 | 被放回可运行队列 |
| 被调度器选中 → copy_to_user | R (内核态) | 100% | 同一进程 | 进程持有 CPU 执行拷贝 |
| 拷贝完成 → 返回用户态 | R (用户态) | 100% | 同一进程 | 继续执行用户代码 |
总结:
- 放弃 CPU 的唯一时刻:站②没数据时,调用
schedule()进入睡眠 - copy_to_user 时:进程持有 CPU 执行拷贝,CPU 占用 100%
- **区分”睡眠阻塞”和”内核态阻塞”**:前者 CPU 为 0%(进程睡觉了),后者 CPU 为 100%(进程在工作,只是被困在内核无法返回用户态)
2.6 结合你的 wait queue 代码
// wq.c 中的 read_test
wait_event_interruptible(my_wait_queue, dev->flag == 1);
// ↑ 第二站(内核缓冲区)没有数据
// ↑ 进程在站②入口睡眠,等待站①的 write 来唤醒
// ↑ 睡眠期间 CPU 占用 0%
copy_to_user(buf, dev->kbuf, sizeof(dev->kbuf));
// ↑ 站②有数据了(被 write 唤醒)
// ↑ 进程占用 CPU,将数据从站②拷贝到站③
// ↑ 这是站②→站③的跨越,必须由 CPU 完成
// wq.c 中的 write_test
copy_from_user(dev->kbuf, buf, sizeof(dev->kbuf));
// ↑ 应用层数据从站③拷贝到站②
// ↑ 这是站③→站②的跨越,也必须由 CPU 完成
dev->flag = 1;
wake_up_interruptible(&my_wait_queue);
// ↑ 通知站②数据已就绪
// ↑ 唤醒在站②入口等待的 read 进程
// ↑ 站①→站②完成
三、同步非阻塞 — 不等,但也没人帮你拿
3.1 核心改变
阻塞模式中,数据没到时进程睡眠等待。非阻塞模式中,数据没到时立即返回错误码。
3.2 完整时序图
用户进程 内核 硬件
│ │ │
│ ① read(O_NONBLOCK) ───────> │ 发起系统调用 │
│ │ │
│ [检查内核缓冲区] │ │
│ <─ 数据不存在 ────────────── │ │
│ │ │
│ 立即返回 -EAGAIN │ │
│ [进程状态: R (运行)] │ │
│ [CPU 占用: 进程自己的代码] │ │
│ │ │
│ [进程可以去做别的事] │ │
│ 或者 usleep(1000) │ │
│ 或者处理其他业务逻辑 │ │
│ │ │
│ ② read(O_NONBLOCK) 再试 ───> │ │
│ <─ 还是没有数据 ───────────── │ │
│ 立即返回 -EAGAIN │ │
│ │ │
│ ...反复轮询(忙等待)... │ │
│ │ │
│ │ <──── 硬件中断 ──────────── │
│ │ DMA 拷贝到内核缓冲区 │
│ │ │
│ ③ read(O_NONBLOCK) ───────> │ 有数据了! │
│ │ │
│ copy_to_user 执行 │ │
│ [进程状态: R (内核态)] │ <───────────────────────── │
│ [占用 CPU] │ │
│ │ │
│ 返回成功 │ │
3.3 三站分析
第一站 → 第二站:和阻塞一样,由 DMA 完成
第二站入口: → 立即返回 EAGAIN,进程不睡眠(这是和阻塞的唯一区别)
→ 进程可以去做别的事
第二站 → 第三站:和阻塞一样,copy_to_user 期间进程占用 CPU
3.4 阻塞 vs 非阻塞的本质区别
【阻塞】
数据没到站②
→ 进程: "我等一下" → 睡眠(S) → 等人唤醒 → 醒来继续
→ 进程主动选择"放弃 CPU,等好了再叫我"
【非阻塞】
数据没到站②
→ 进程: "不等了" → 立即返回 → 去做别的事 → 过会再来问
→ 进程主动选择"我先走了,下次再来"
【共同点】
数据从站②到站③:都是进程自己调用 copy_to_user 取走
→ 这就是"同步"的含义:调用者自己推动数据移动
3.5 非阻塞的代价:轮询浪费 CPU
阻塞模式:
进程A: sleep → 不占用 CPU → CPU 去跑进程B,C,D,E
总 CPU 利用率: 高(CPU 一直在跑有用的进程)
非阻塞模式(频繁轮询):
进程A: read → EAGAIN → read → EAGAIN → read → EAGAIN
进程A 每次 read 都占用 CPU 1微秒,但 99% 的 read 都白跑了
CPU 利用率: 浪费在反复检查"有没有数据"上
3.6 非阻塞的正确用法:配合 I/O 多路复用
fd = open("/dev/my_device", O_RDONLY | O_NONBLOCK);
// 方案1:纯轮询(浪费 CPU)
while (1) {
ret = read(fd, buf, sizeof(buf));
if (ret < 0 && errno == EAGAIN) {
usleep(10000); // 等 10ms 再试,避免 CPU 100%
continue;
}
process_data(buf);
}
// 方案2:配合 select(推荐)
fd_set rfds;
struct timeval tv;
while (1) {
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
tv.tv_sec = 1; // 超时 1 秒
tv.tv_usec = 0;
ret = select(fd + 1, &rfds, NULL, NULL, &tv);
if (ret > 0 && FD_ISSET(fd, &rfds)) {
read(fd, buf, sizeof(buf));
process_data(buf);
}
}
四、异步 I/O — 内核包办,数据到了送上门
4.1 核心改变
同步模式中,数据从站②到站③这一段由调用者(进程)主动调用 copy_to_user 来推动。异步模式中,数据从站②到站③这一段由内核主动完成,完成后通知调用者。
4.2 完整时序图
用户进程 内核 硬件
│ │ │
│ ① aio_read() ───────────────> │ 发起异步 I/O 请求 │
│ [立即返回 EINPROGRESS] │ │
│ [进程状态: R (运行)] │ │
│ [CPU 占用: 0% 用于此 I/O] │ │
│ [可以去干任何其他事] │ │
│ │ │
│ [完全不知道 I/O 进展如何] │ 检查缓冲区,数据未就绪 │
│ │ 注册完成后回调 │
│ │ 返回用户态 │
│ │ │
│ [干别的事: 渲染UI, 计算等] │ │
│ │ │
│ │ <──── 硬件中断 ──────────── │
│ │ DMA 将数据拷贝到内核缓冲区 │
│ │ 站①→站② 完成 │
│ │ │
│ │ ② 内核主动将数据从内核缓冲区 │
│ │ 拷贝到用户缓冲区 │
│ │ 【内核推动】 │
│ │ <───────────────────────── │
│ [用户缓冲区中已有数据] │ 站②→站③ 完成 │
│ [但进程还不知道] │ │
│ │ │
│ ③ <─── SIGIO 信号 ─────────── │ 发送信号通知进程 │
│ │ │
│ [信号处理函数或回调] │ │
│ 直接使用 buf 中的数据 │ │
│ [不需要再调用任何 read] │ │
│ 【数据已经在站③了】 │ │
4.3 三站分析
第一站 → 第二站:和之前一样,DMA 完成
第二站入口: → 立即返回,进程不等待
第二站 → 第三站:
→ 【关键区别】数据从站②到站③的拷贝
→ 同步模式: 由进程自己调用 copy_to_user(进程阻塞在内核态)
→ 异步模式: 由内核完成(进程完全不参与,进程可以去做任何事)
4.4 同步 vs 异步的本质区别
【同步】
调用者发起 I/O
→ 阶段一(站①→站②)等待(可睡/可轮询)
→ 阶段二(站②→站③)调用者主动调用 copy_to_user 推动
→ 调用者自己"取走"数据
→ 调用者和数据"同行同止"
【异步】
调用者发起 I/O
→ 阶段一(站①→站②)立即返回(完全不等待)
→ 阶段二(站②→站③)内核主动完成(调用者不参与)
→ 内核主动"送上门"
→ 调用者和数据"分离执行"
4.5 POSIX 对同步/异步的精确定义
POSIX 定义:
同步 I/O 操作 = 导致请求进程在 I/O 操作期间被阻塞
→ 指的是 copy_to_user 期间(站②→站③)
→ 进程虽然在内核态占用 CPU
→ 但无法返回用户态做其他事
→ 所以叫"阻塞"
异步 I/O 操作 = 不导致请求进程在 I/O 操作期间被阻塞
→ copy_to_user 由内核完成
→ 进程完全不参与
→ 进程可以自由执行任何代码
4.6 为什么需要内核主动推动数据?
同步模式下,站②→站③这一段为什么不能由内核自动完成,而需要进程调用 read() 来触发?
因为内核不知道用户程序什么时候想要数据
内核只知道:站②有数据了
内核不知道:哪个进程的哪个 buf 想要这个数据
内核不知道:进程是想要全部数据还是部分数据
内核不知道:进程拿到数据后要怎么处理
所以内核只能"告诉"进程"有数据了"
进程必须主动来"取走"数据
→ 这就是同步
异步 I/O 的做法:
进程提前告诉内核:"我想要数据,到时候直接放到这个地址"
内核把数据放到指定地址后,通知进程来用
→ 这就是异步
五、I/O 多路复用 — 一个进程监控多个设备
5.1 核心问题:单进程监控多设备的困境
在同步阻塞模型中,一个进程同时监控多个设备是一个难题:
假设场景:一个 Web 服务器进程需要同时监控 10000 个 socket
【方案A:每个 socket 一个进程/线程】
进程1 → read(socket1) → 阻塞等待
进程2 → read(socket2) → 阻塞等待
...
进程10000 → read(socket10000) → 阻塞等待
问题:
→ 10000 个进程/线程 → 巨大的内存开销
→ 上下文切换开销极大
→ CPU 在 10000 个进程间切换,大部分时间在"切换"而非工作
根本问题:每个进程/线程在等待时必须占用一个 CPU 时间片来维持”等待状态”。如果有 10000 个连接,就需要 10000 个进程。
解决方案:I/O 多路复用 — 一个进程同时等待多个设备,让调度器来统一管理。
5.2 I/O 多路复用的数据流本质
核心发现:select/epoll 并没有改变任何数据流
【阻塞 I/O】
read() → 检查站②有无数据 → 无 → 睡眠在站②入口 → 被唤醒 → copy_to_user
【I/O 多路复用】
select()/epoll_wait() → 检查站②有无数据 → 无 → 睡眠在站②入口 → 被唤醒 → read() → copy_to_user
↑ 完全一样 ↑
select/epoll 和 read() 在数据流上的行为完全相同——都是检查站②,然后在站②入口睡眠。
那 I/O 多路复用解决了什么问题?
解决的不是数据流问题,而是”一个进程如何同时等待多个设备”的问题。
【阻塞 I/O 的困境】
进程监控 socket1 → 在站②入口睡眠
→ 进程在睡眠,无法同时监控 socket2, 3, 4...
→ 如果进程需要同时监控多个 fd,就必须创建多个进程/线程
【I/O 多路复用的解决方案】
进程调用 epoll_wait() → 在"统一的等待入口"睡眠
→ 一个 epoll_wait() 可以同时等待任意多个 fd
→ 一个进程就能监控 10000 个 socket
→ 不需要创建 10000 个进程
关键洞察:select/epoll 把”多个站②”合并成了”一个统一的等待点”。
5.3 select/epoll 完整时序图(数据流视角)
用户进程 内核(epoll) socket1/socket2/socket3...
│ │ │
│ epoll_create() │ 创建 epoll 实例(红黑树+链表)│
│ ───────────────────────> │ │
│ │ │
│ epoll_ctl(ADD socket1) │ 将 socket1 加入监控列表 │
│ ───────────────────────> │ │
│ epoll_ctl(ADD socket2) │ 将 socket2 加入监控列表 │
│ ───────────────────────> │ │
│ epoll_ctl(ADD socket3) │ 将 socket3 加入监控列表 │
│ ───────────────────────> │ │
│ │ │
│ epoll_wait() │ 开始监控 │
│ ───────────────────────> │ │
│ │ 遍历所有监控的 socket │
│ │ 检查每个 socket 的站②缓冲区 │
│ │ ───────────────────────> │
│ │ socket1 → 无数据 │
│ │ socket2 → 无数据 │
│ │ socket3 → 无数据 │
│ │ ←───────────────────────── │
│ │ │
│ │ 所有 socket 站②都没数据 │
│ │ 当前进程加入"统一的等待队列" │
│ 进程状态: S (睡眠) │ schedule() 让出 CPU │
│ CPU 占用: 0% │ │
│ ─────────────────────── │ │
│ │ <────── socket2 数据到达 ────│
│ │ socket2 的站②数据就绪 │
│ │ │
│ │ 从"统一等待队列"中唤醒进程 │
│ 进程状态: R (就绪) │ wake_up() │
│ ─────────────────────── │ │
│ │ │
│ [进程被调度器选中] │ │
│ │ │
│ epoll_wait() 返回 │ 返回就绪的 socket2 │
│ <──────────────────────── │ │
│ │ │
│ [进程知道 socket2 就绪] │ │
│ read(socket2) │ │
│ ───────────────────────> │ 检查 socket2 站② → 有数据 │
│ │ │
│ copy_to_user() 执行 │ │
│ [进程状态: R (内核态)] │ <───────────────────────── │
│ [CPU 占用: 100%] │ copy_to_user 完成 │
│ │ │
│ 处理数据... │ │
5.4 关键对比:阻塞 I/O vs I/O 多路复用
数据流完全相同,但等待方式不同
【阻塞 I/O:每个 fd 一个等待点】
进程A ──> read(socket1) ──> 睡眠在 socket1 的等待队列
进程B ──> read(socket2) ──> 睡眠在 socket2 的等待队列
进程C ──> read(socket3) ──> 睡眠在 socket3 的等待队列
→ 需要 3 个进程/线程
【I/O 多路复用:所有 fd 共用一个等待点】
进程A ──> epoll_wait(socket1+socket2+socket3)
──> 睡眠在统一的 epoll 等待队列
→ 一个进程监控 3 个 fd
核心数据结构
epoll_create() 创建的核心结构:
┌─────────────────────────────────────────────────────────┐
│ epoll 实例 │
│ │
│ ┌─────────────┐ 红黑树(O(log n) 增删查) │
│ │ socket1 │ 存储所有监控的 fd │
│ │ socket2 │ 用于快速添加/删除/修改监控项 │
│ │ socket3 │ │
│ │ socket4 │ │
│ │ ... │ │
│ └─────────────┘ │
│ │
│ ┌─────────────┐ 双向链表(就绪通知) │
│ │ socket2 ★ │ 只存储"站②有数据"的 fd │
│ │ socket5 ★ │ epoll_wait 只遍历这个链表 │
│ └─────────────┘ → 高效,只处理真正就绪的 fd │
│ │
└─────────────────────────────────────────────────────────┘
为什么用红黑树 + 双向链表?
- 红黑树:管理所有监控的 fd,添加/删除/修改 O(log n)
- 双向链表:只存放已就绪的 fd,遍历 O(k),k = 就绪数量
5.5 select/poll/epoll 的本质区别
【select】
→ 所有监控的 fd 都放到一个 fd_set(位图)中
→ 每次调用 select,内核需要把所有 fd 从用户态拷贝到内核态
→ 每次调用 select,内核需要遍历所有 fd 检查是否就绪
→ fd 数量受限(通常 1024)
→ 时间复杂度: O(n) — n = 所有监控的 fd 数量
【poll】
→ 用 pollfd 数组代替 fd_set
→ 本质和 select 一样,只是解决了 fd 数量限制
→ 每次调用仍需拷贝 + 遍历所有 fd
→ 时间复杂度: O(n)
【epoll】(Linux 独有)
→ epoll_create: 创建 epoll 实例(红黑树 + 链表)
→ epoll_ctl: 添加/删除/修改监控项(只拷贝一次)
→ epoll_wait: 只遍历"已就绪"的 fd(双向链表)
→ fd 不需要重复拷贝到内核态(已预先注册)
→ 时间复杂度: O(k) — k = 就绪的 fd 数量(通常很小)
5.6 它们在数据流的哪一站工作?
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 设备层 │ ① │ 内核缓冲区 │ ② │ 用户缓冲区 │
│ (硬件) │ ───────> │ (kernel) │ ───────> │ (user space) │
└──────────────┘ DMA └──────────────┘ CPU └──────────────┘
第一站 第二站 第三站
──────────────────────────────────────────────────────────────
↑
select/poll/epoll 在这里工作
它们在站②入口(统一等待点)睡眠
等待"任何一个站②"有数据
↓
──────────────────────────────────────────────────────────────
【select/poll/epoll 的工作范围】
→ 它们工作在"站②入口"
→ 它们监控"哪些站②有数据"
→ 它们在统一的等待队列中睡眠
→ 数据从站②→站③的 copy_to_user 仍然由进程自己完成(同步)
5.7 为什么说 I/O 多路复用仍然是同步 I/O?
【关键】
站②→站③这一段:进程自己调用 read() → copy_to_user()
↓
同步的本质:调用者自己推动数据移动
↓
select/epoll 没有改变这一点
数据到达后,仍然是进程自己来取
↓
所以:select/epoll/epoll_wait 是"同步阻塞在统一的等待点"
不是异步 I/O
5.8 epoll 的两种触发模式
【水平触发(LT, Level Triggered)— 默认模式】
现象:站②有数据期间,每次 epoll_wait 都会返回
场景:
→ 站②缓冲区有 100 字节数据(只读取了 50 字节)
→ epoll_wait() → 返回
→ 进程只 read() 了 50 字节
→ 再次 epoll_wait() → 仍然返回(还剩 50 字节没读)
类似:始终提醒你"还有数据没读完"
特点:编程简单,不会漏掉数据
【边缘触发(ET, Edge Triggered)】
现象:站②数据从"无"变"有"时,epoll_wait 只返回一次
场景:
→ 站②缓冲区一直是空的
→ 站② 突然收到 100 字节数据
→ epoll_wait() → 返回(通知"有数据了")
→ 进程必须一次性把 100 字节全部读完
→ 如果只读了 50 字节就走了
→ 再次 epoll_wait() → 不会返回(内核认为"已经通知过了")
→ 但还剩 50 字节没读 → 数据可能丢失!
类似:只提醒你一次,"数据来了,自己赶紧处理完"
特点:需要用 while 循环把数据读完,编程复杂但性能更高
(nginx 使用 ET 模式)
5.9 等待队列(Wait Queue)详解 — 阻塞 I/O 的核心基础设施
5.9.1 等待队列是什么?
等待队列(Wait Queue) 是 Linux 内核中用于管理”等待某个资源的进程”的双向链表数据结构。
等待队列的本质:
→ 一个由"等待同一资源的进程"组成的链表
→ 每个节点是一个 struct wait_queue_entry(或 wait_queue_t)
→ 节点中包含:进程描述符指针 + 唤醒回调函数
它的位置在站②入口——所有想在站②等待数据的进程,都被挂入这个链表。
5.9.2 每个资源都有一个等待队列——不是每个进程
这是最关键的误解:
❌ 错误理解:每个进程有一个等待队列
✅ 正确理解:每个资源有一个等待队列
socket1 ──> 等待队列 ──> [进程A在等] → [进程B在等]
socket2 ──> 等待队列 ──> [进程C在等]
socket3 ──> 等待队列 ──> [进程A也在等]
→ 同一个进程可以同时等待多个资源(每个资源一个队列节点)
→ 同一个资源可以同时被多个进程等待(链表多个节点)
为什么是”每个资源一个”?
因为阻塞的本质是:"这个资源没数据,我要等这个资源"
→ 等待队列的组织单位是"资源"
→ 不是"进程"
socket1 的数据就绪了,只唤醒等 socket1 的进程
socket2 的数据就绪了,只唤醒等 socket2 的进程
→ 资源粒度的唤醒,精确匹配
5.9.3 等待队列的完整工作流程
以同步阻塞 read() 为例:
步骤1:进程A调用 read(socket_fd)
↓
步骤2:内核检查 socket 的站②(内核缓冲区)
↓ 有数据? → 直接 copy_to_user → 返回
↓ 无数据!↓
步骤3:进程A被封装成等待队列节点,加入 socket 的等待队列
┌─────────────────────────────────────────┐
│ socket 的等待队列(双向链表) │
│ [进程A的节点] ↔ [进程B的节点] ↔ [进程C的节点] │
└─────────────────────────────────────────┘
↑ 进程A在这里睡觉
↓
步骤4:进程A调用 schedule() 让出 CPU
→ 进程A状态变为 S(可中断睡眠)
→ CPU 去执行其他进程
[等待期间...]
→ 进程A完全不在 CPU 上
→ CPU 利用率:0%(进程A的视角)
步骤5:数据到达 socket(硬件中断/DMA)
↓
步骤6:内核将数据拷贝到站②(socket 内核缓冲区)
↓
步骤7:wake_up() 遍历 socket 的等待队列
↓
┌─────────────────────────────────────────┐
│ socket 的等待队列(双向链表) │
│ [进程A的节点] ← [进程B的节点] ← [进程C的节点]│
│ ↑ 被唤醒!移出队列 │
└─────────────────────────────────────────┘
→ 进程A从等待队列中移出
→ 进程A状态变为 R(就绪)
→ 进程A被放回可运行队列,等待调度器选中
步骤8:调度器选中进程A
↓
步骤9:copy_to_user() 执行
↓
步骤10:返回用户态,read() 调用完成
5.9.4 等待队列的核心数据结构
// Linux 内核中的等待队列节点(旧式)
typedef struct __wait_queue {
unsigned int flags;
void *private; // 指向等待的进程(struct task_struct *)
wait_queue_func_t func; // 唤醒函数(默认 default_wake_function)
struct list_head task_list;
} wait_queue_t;
// Linux 内核中的等待队列头
typedef struct wait_queue_head {
spinlock_t lock;
struct list_head head;
} wait_queue_head_t;
等待队列头的结构:
wait_queue_head_t(队列头)
├── spinlock_t lock ← 保护队列的锁(防止并发问题)
└── struct list_head head ← 双向链表的哨兵节点
↓
[节点A] ↔ [节点B] ↔ [节点C] ↔ ...
每个节点 = 一个等待的进程
5.9.5 等待队列的关键操作
// 1. 定义并初始化等待队列头
wait_queue_head_t my_wq;
init_waitqueue_head(&my_wq);
// 2. 进程进入等待队列并睡眠(阻塞点)
// wait_event 宏:条件为假则睡眠,为真则立即返回
wait_event_interruptible(my_wq, condition);
// 相当于:
// while (!(condition)) {
// prepare_to_wait(&my_wq, &__wait, TASK_INTERRUPTIBLE);
// if (signal_pending(current))
// break; // 被信号唤醒
// schedule(); // ← 关键:进程让出 CPU,进入 S 状态
// finish_wait(&my_wq, &__wait);
// }
// 3. 其他进程/中断处理程序唤醒(唤醒点)
wake_up_interruptible(&my_wq);
// 相当于:
// → 遍历等待队列
// → 把睡眠的进程移出队列
// → 进程状态从 S → R(就绪)
// → 进程被放回可运行队列
// → 下次调度时进程会从 wake_up 之后的代码继续执行
wake_up_all(&my_wq); // 唤醒队列中所有进程
wake_up_interruptible(&my_wq); // 只唤醒可中断睡眠的进程
wake_up_nr(&my_wq, 3); // 唤醒队列中的 3 个进程
5.9.6 等待队列 vs epoll:独立等待点 vs 统一等待点
这是两种不同的等待队列组织方式:
【普通阻塞 I/O:每个资源一个独立的等待队列】
socket1 ──> 等待队列1 ──> [进程A在等]
socket2 ──> 等待队列2 ──> [进程B在等]
socket3 ──> 等待队列3 ──> [进程A在等]
→ 问题:一个进程无法同时等多个 socket
→ 必须用 epoll 把"多个独立等待点"合并成"一个统一等待点"
【epoll:创建自己的统一等待队列】
epoll 实例
├── 红黑树:管理所有监控的 socket(ADD/DEL/MOD)
└── 统一的等待队列:所有监控的 socket 共用
↓
[进程A在这里等待 socket1+socket2+socket3 的数据]
↓
socket1 有数据了? → 唤醒
socket2 有数据了? → 唤醒
socket3 有数据了? → 唤醒
→ 关键:epoll 在每个 socket 的独立等待队列上都注册了回调
当 socket 数据就绪时,回调把 socket 加入 epoll 的就绪链表
然后从 epoll 的统一等待队列中唤醒进程
epoll 的回调机制(关键):
当 socket2 的数据到达时:
步骤1:硬件中断 → DMA 拷贝数据到 socket2 的站②
↓
步骤2:socket2 的等待队列被遍历
↓
步骤3:socket2 的等待队列节点中注册了 epoll 的回调函数
↓
步骤4:回调函数被调用
→ 把 socket2 加入 epoll 的就绪链表(双向链表)
↓
步骤5:从 epoll 的统一等待队列中唤醒进程A
↓
步骤6:进程A醒来,epoll_wait 返回,告知"socket2 就绪了"
这意味着:epoll 的统一等待队列并不”替代”资源的独立等待队列。epoll 在每个资源的独立等待队列上都注册了一个回调,当资源就绪时回调被执行。
┌──────────────────────────────────────────────────────────────────┐
│ 完整的等待队列架构 │
│ │
│ socket1 的独立等待队列 ──> [进程A] ──> [epoll回调节点] │
│ socket2 的独立等待队列 ──> [进程B] ──> [epoll回调节点] │
│ socket3 的独立等待队列 ──> [epoll回调节点] │
│ ↑ ↑ │
│ │ └── 进程的 read() 阻塞在这里 │
│ └── epoll 注册的回调节点(不在独立队列中睡眠) │
│ │
│ epoll 的统一等待队列 ──> [进程A] │
│ ↑ │
│ └── 当任一 socket 就绪时,通过回调唤醒这里 │
└──────────────────────────────────────────────────────────────────┘
5.9.7 等待队列在驱动开发中的体现
// 驱动中定义等待队列头
static DECLARE_WAIT_QUEUE_HEAD(my_wait_queue);
// read 函数中的阻塞等待
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
// 站②入口:检查数据是否就绪
int ret;
ret = wait_event_interruptible(my_wait_queue, dev->data_ready);
if (ret)
return ret; // 被信号打断
// 数据就绪,进程被唤醒后从这里继续
copy_to_user(buf, dev->kbuf, count);
dev->data_ready = 0; // 消费数据
return count;
}
// write 函数中唤醒
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *ppos)
{
copy_from_user(dev->kbuf, buf, count);
dev->data_ready = 1; // 标记数据就绪
wake_up_interruptible(&my_wait_queue); // 唤醒等待队列中的读进程
return count;
}
5.9.8 等待队列与进程状态的对应关系
等待队列状态与进程状态的关系:
进程进入等待队列前:
→ 进程状态:R(运行)
→ CPU 占用:100%(正在执行 read() 系统调用)
↓
进程加入等待队列后调用 schedule():
→ 进程状态:S(可中断睡眠)或 D(不可中断睡眠)
→ CPU 占用:0%(进程完全不在 CPU 上)
→ 进程被链接到等待队列链表上
↓
进程被唤醒(wake_up):
→ 进程从等待队列链表上移出
→ 进程状态:R(就绪)
→ CPU 占用:0%(等待调度器选中)
→ 进程被放回可运行队列
↓
进程被调度器选中:
→ 进程状态:R(运行,内核态)
→ CPU 占用:100%
→ 从 wake_up 之后的代码继续执行
5.10 一句话总结
I/O 多路复用(select/poll/epoll)的本质:
它们没有改变任何数据流(三站模型完全不变)
它们没有改变同步的本质(站②→站③仍是进程自己取)
它们解决的唯一问题是:
"一个进程如何同时等待多个设备的站②数据?"
答案是:
创建一个"统一的等待点"
让一个进程在这个等待点上睡眠
由内核来通知"哪个站②有数据了"
最终效果:
不需要创建多个进程/线程
一个进程就能监控成百上千个设备
大大降低了内存占用和上下文切换开销
六、四种 I/O 模型全景对比
6.1 以数据流三站为基准
设备 ──①DMA──> 内核缓冲区 ──②CPU──> 用户缓冲区
站① 站② 站③
┌────────────────────────────────────┐
│ 站①→站②: 始终由 DMA 完成 │
│ (不占用 CPU,硬件自动) │
└────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 站②数据未就绪时(阶段一) │
├───────────┬──────────────┬──────────────┬────────────────────────────┤
│ 模型 │ 进程行为 │ 进程状态 │ CPU 占用 │
├───────────┼──────────────┼──────────────┼────────────────────────────┤
│ 同步阻塞 │ 睡眠等待 │ S(睡眠) │ 0%,CPU 跑其他进程 │
│ 同步非阻塞 │ 立即返回轮询 │ R(运行) │ 轮询时占用,有用率低 │
│ I/O多路复用│ 阻塞在select │ S(睡眠) │ 0%,但只监控多个fd │
│ 异步非阻塞 │ 立即返回 │ R(运行) │ 0%,完全做别的事 │
└───────────┴──────────────┴──────────────┴────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 站②→站③ 拷贝时(阶段二) │
├───────────┬──────────────┬──────────────┬────────────────────────────┤
│ 模型 │ 谁来拷贝 │ 进程状态 │ 进程能否做别的事 │
├───────────┼──────────────┼──────────────┼────────────────────────────┤
│ 同步阻塞 │ 进程调用 │ R(内核态) │ 不能 │
│ │ copy_to_user │ │ │
│ 同步非阻塞 │ 进程调用 │ R(内核态) │ 不能 │
│ │ copy_to_user │ │ │
│ I/O多路复用│ 进程调用 │ R(内核态) │ 不能 │
│ │ copy_to_user │ │ │
│ 异步非阻塞 │ 内核主动完成 │ R(用户态) │ 能(数据已经送上门了) │
└───────────┴──────────────┴──────────────┴────────────────────────────┘
6.2 用排队场景类比
【同步阻塞】:你去奶茶店点单,站在柜台前等,做好了亲手接过。
→ 你等的时候什么都没干(睡眠)
→ 你自己去拿奶茶(同步)
→ 期间不能干别的
【同步非阻塞】:你去奶茶店点单,发现要等,就走了,过5分钟再来问。
→ 你不等(立即返回)
→ 还是你亲手接过奶茶(同步)
→ 可以干别的,但得记着回来问
【I/O多路复用】:你派了一个助理帮你盯着多家奶茶店,你在家等助理通知。
→ 助理帮你监控多家店(epoll 监控多 fd)
→ 有奶茶做好了助理告诉你
→ 你自己去拿(同步)
→ 好处是一个助理管多家店
【异步非阻塞】:你下单后留了地址,外卖送到门口,然后发短信通知你。
→ 你完全不等(下单后立即返回)
→ 奶茶被送到你门口(内核主动送)
→ 你收到短信后直接用(通知驱动)
→ 整个过程你没有"去取"这个动作
七、驱动开发中的实际应用
7.1 wait queue(阻塞)— 对应同步阻塞
// wq.c read_test
wait_event_interruptible(my_wait_queue, dev->flag == 1);
// ↑ 站②没数据 → 进程在站②入口睡眠(S)
// ↑ CPU 让给其他进程用
copy_to_user(buf, dev->kbuf, sizeof(dev->kbuf));
// ↑ 站②有数据了(被 write 唤醒)
// ↑ 站②→站③:进程自己推动(同步)
数据流视角:
阶段一(站①→站②):write 触发,硬件/内存 → 内核缓冲区
阶段二(站②→站③):wait_event 唤醒后 → copy_to_user → 用户缓冲区
阻塞点:站②入口(wait_event 处)
同步点:站②→站③(copy_to_user 处)
7.2 O_NONBLOCK(同步非阻塞)
// 应用层
fd = open("/dev/my_device", O_RDONLY | O_NONBLOCK);
ret = read(fd, buf, sizeof(buf));
if (ret < 0 && errno == EAGAIN) {
// 站②没数据,立即返回
// 进程去做别的事
}
7.3 epoll(I/O 多路复用)
// 应用层
epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
while (1) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
// ↑ 进程睡眠在 epoll_wait(监控多个 fd)
// ↑ 任一 fd 数据就绪则唤醒
// ← epoll 通知"站②有数据了"
read(fd, buf, sizeof(buf)); // 同步取走
}
epoll 的本质:一个进程同时监控多个设备(多站②),哪个站②有数据就通知进程。仍然是同步(进程自己取数据),只是监控方式更高效。
八、进程状态总览
8.1 各阶段进程状态对照
┌────────────────────────────────────────────────────────────────────┐
│ 同步阻塞 I/O 完整流程 │
├──────────────┬───────────────┬───────────────┬────────────────────┤
│ 阶段 │ 进程状态 │ CPU 占用 │ 所在位置 │
├──────────────┼───────────────┼───────────────┼────────────────────┤
│ 用户态调用read│ R (用户态运行) │ 100% │ 站③(用户空间) │
│ 陷入内核态 │ R (内核态运行) │ 100% │ 内核(系统调用) │
│ 站②无数据 │ S (可中断睡眠) │ 0% │ 等待队列 │
│ 站②有数据 │ R (就绪) │ 0% │ 可运行队列 │
│ copy_to_user │ R (内核态运行) │ 100% │ 内核(拷贝) │
│ 返回用户态 │ R (用户态运行) │ 100% │ 站③(用户空间) │
└──────────────┴───────────────┴───────────────┴────────────────────┘
8.2 Linux 进程状态速查
| 状态 | 符号 | 含义 | I/O 中出现的位置 |
|---|---|---|---|
| R | Running | 正在 CPU 上运行(或就绪) | 系统调用执行中、copy_to_user 时 |
| S | Sleeping (Interruptible) | 可中断睡眠,可被信号唤醒 | wait_event、down_interruptible |
| D | Sleeping (Uninterruptible) | 不可中断睡眠,不可被信号唤醒 | 等待磁盘 I/O |
| T | Stopped | 被暂停 | debugger 暂停 |
| Z | Zombie | 僵尸,资源已释放但进程表项未回收 | 子进程 exit 后父进程未 wait |
九、本质总结
9.1 一句话总结
阻塞/非阻塞:卡在站②入口时,进程选择"等(睡)"还是"走(轮询)"
同步/异步: 站②→站③这一段,进程选择"自己去取"还是"让人送来"
9.2 本质原因汇总
| 概念 | 为什么存在 | 根本原因 |
|---|---|---|
| 阻塞 | CPU 和设备速度差太大(100万倍) | CPU 不能等设备,等不起 |
| 非阻塞 | 进程需要同时做多件事 | 一个进程不能只等一个 I/O |
| 同步 | 内核不知道用户何时要什么数据 | 调用者必须主动来取 |
| 异步 | 某些场景下进程不能等 | 内核需要主动推数据 |
9.3 最核心的一张图
数据从站②到站③时,谁在推动?
站② 内核缓冲区
│
┌───────────────┼───────────────┐
│ │ │
进程调用 内核主动 (不存在)
copy_to_user 完成拷贝
【同步】 【异步】
│ │
▼ ▼
调用者自己去取 数据送上门
调用者"拉" 内核"推"
进程在内核态阻塞 进程完全自由
附录:等待队列(Wait Queue)完全指南
A.1 等待队列的本质定义
等待队列是 Linux 内核中用于组织和管理等待某个资源的进程的双向链表数据结构。
为什么需要等待队列?
场景:进程A调用 read(),但 socket 的站②没有数据
如果不用等待队列:
→ 进程A只能不停循环检查:"有没有数据?有没有?有没有?..."
→ CPU 浪费在空转上(忙等待)
→ CPU 利用率:100% 但全是无用功
用等待队列:
→ 进程A说:"没数据我先睡了,叫我一声"
→ 进程A加入 socket 的等待队列,调用 schedule() 让出 CPU
→ CPU 去跑其他有用的进程
→ 数据来了 → 中断处理程序调用 wake_up() → 唤醒进程A
→ CPU 利用率:最优(只在有工作时跑)
等待队列 = 让进程"有尊严地等待"
而不是"傻傻地空转"
A.2 等待队列的数据结构
// Linux 4.15 内核中的等待队列头(include/linux/wait.h)
struct wait_queue_head {
spinlock_t lock; // 自旋锁:保护队列的并发访问
struct list_head head; // 双向链表头(哨兵节点)
};
#define DECLARE_WAIT_QUEUE_HEAD(name) \
struct wait_queue_head name = { \
.lock = __SPIN_LOCK_UNLOCKED(name), \
.head = LIST_HEAD_INIT(name.head) \
}
// 等待队列节点(新式 API,Linux 2.6+)
struct wait_queue_entry {
unsigned int flags; // 标志:WQ_FLAG_EXCLUSIVE(独占等待)等
void *private; // 通常指向 task_struct(等待的进程)
wait_queue_func_t func; // 唤醒函数指针
struct list_head entry; // 链表节点
};
队列结构图:
wait_queue_head_t(队列头)
┌──────────────────────────────┐
│ spinlock_t lock │ ← 保护队列的锁
├──────────────────────────────┤
│ list_head head │ ← 哨兵节点
│ │ │
│ ▼ │
│ [哨兵] ↔ [节点A] ↔ [节点B] ↔ [节点C] ↔ ... ↔ [哨兵]
│ │ │
│ ▼ ▼
│ task_struct task_struct
│ (进程描述符) (进程描述符)
└──────────────────────────────┘
A.3 等待队列的完整 API
初始化
// 静态初始化(声明时直接初始化)
DECLARE_WAIT_QUEUE_HEAD(my_wq);
// 动态初始化
wait_queue_head_t my_wq;
init_waitqueue_head(&my_wq);
进入等待(睡眠)
// 宏:条件为假则睡眠,为真则立即返回
wait_event(wq, condition); // 不可中断睡眠(D 状态)
wait_event_interruptible(wq, condition); // 可中断睡眠(S 状态),可被信号唤醒
wait_event_timeout(wq, condition, timeout); // 带超时
wait_event_interruptible_timeout(wq, condition, timeout);
// 底层 API(wait_event 的内部实现)
prepare_to_wait(&wq, &wait, state); // state = TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE
schedule(); // ← 关键:让出 CPU,进程进入睡眠
finish_wait(&wq, &wait); // 醒来后清理
唤醒
wake_up(&wq); // 唤醒等待队列中一个进程(不可中断)
wake_up_interruptible(&wq); // 唤醒等待队列中一个可中断睡眠的进程
wake_up_nr(&wq, nr); // 唤醒 nr 个进程
wake_up_all(&wq); // 唤醒所有进程
wake_up_all_exclusive(&wq); // 唤醒所有进程,并设置 EXCLUSIVE 标志
exclusive(独占)唤醒
// 场景:多个进程都在等同一个锁(互斥锁),锁释放时只需唤醒一个进程
// 等待者将自己标记为独占等待
wait_event_interruptible_exclusive(wq, condition);
// 唤醒者使用 wake_up 族函数
wake_up_nr(&wq, 1); // 只需唤醒 1 个,其他独占等待的进程继续睡
A.4 等待队列的典型使用模式
// ============== 驱动中的标准模式 ==============
// 1. 定义等待队列头(通常在设备结构体中或全局)
static DECLARE_WAIT_QUEUE_HEAD(read_wq);
// 2. 在 read() 中等待
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
ssize_t ret = 0;
// 站②没数据?睡在等待队列里
ret = wait_event_interruptible(read_wq, dev->data_ready);
if (ret)
return ret; // 被信号打断
// 醒来时数据已就绪
ret = copy_to_user(buf, dev->kbuf, count);
if (ret == 0)
ret = count;
else
ret = -EFAULT;
dev->data_ready = 0;
return ret;
}
// 3. 在 write() 或中断处理中唤醒
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *ppos)
{
ssize_t ret;
ret = copy_from_user(dev->kbuf, buf, count);
if (ret)
return -EFAULT;
dev->data_ready = 1; // 标记数据已就绪
// 唤醒所有等待读数据的进程
wake_up_interruptible(&read_wq);
return count;
}
A.5 等待队列的内核源码解析
以 wait_event_interruptible 为例,看内核如何实现:
// include/linux/wait.h
#define wait_event_interruptible(wq, condition) \
({ \
int __ret = 0; \
if (!(condition)) \
__ret = __wait_event_interruptible(wq, condition); \
__ret; \
})
#define __wait_event_interruptible(wq, condition) \
__wait_event_interruptible_timeout(wq, condition, 0)
展开到底层实现:
// kernel/sched/wait.c 中的实际实现
#define __wait_event_interruptible_timeout(wq, condition, timeout) \
({ \
long __ret = timeout; \
for (;;) { \
if (condition) \
break; \
if (!__ret) { \
__ret = -ETIME; \
break; \
} \
__ret = schedule_timeout(__ret); \
if (!__ret) { \
__ret = -EINTR; \
break; \
} \
} \
__ret; \
})
schedule_timeout() 的关键流程:
// 简化版理解
void schedule_timeout(long timeout)
{
set_current_state(TASK_INTERRUPTIBLE); // 设置进程状态为可中断睡眠
schedule(); // ← 关键:调用调度器,让出 CPU
// 醒来后从这里继续执行
}
wake_up 的关键流程:
// kernel/sched/wait.c
void wake_up_interruptible(wait_queue_head_t *q)
{
unsigned long flags;
spin_lock_irqsave(&q->lock, flags); // 加锁保护
__wake_up(q, TASK_INTERRUPTIBLE, 1); // 唤醒
spin_unlock_irqrestore(&q->lock, flags);
}
void __wake_up(wait_queue_head_t *q, unsigned int mode, int nr)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->head, task_list) {
// 遍历等待队列中的每个节点
wait_queue_func_t func = curr->func;
// 调用唤醒函数(通常是 default_wake_function)
if (func(curr, mode, wake_flags, curr->private))
if (++nr <= 0)
break;
}
}
A.6 等待队列 vs 信号量(Semaphore)
等待队列和信号量在阻塞等待方面有相似之处,但用途不同:
【等待队列】
用途:等待"某个条件变为真"
典型场景:等待数据到达、等待某个事件发生
等待条件:condition 表达式(任意 C 表达式)
唤醒方式:需要其他代码显式调用 wake_up
【信号量】
用途:管理"资源的数量"
典型场景:控制对共享资源的访问数量(互斥锁、读写锁)
等待条件:semaphore->count > 0(内部管理)
唤醒方式:up() 会自动唤醒等待的进程
内核中的等价对应:
等待队列 + 条件变量 ↔ 信号量
wait_event_interruptible down_interruptible
wake_up_interruptible up
A.7 等待队列在Linux内核中的使用场景
| 场景 | 等待队列 | 唤醒时机 |
|---|---|---|
| 进程 read() 阻塞 | socket/file 的等待队列 | 数据到达中断 |
| 进程 close() 阻塞 | inode 的等待队列 | 所有使用完毕 |
| 磁盘 I/O 等待 | request_queue 的等待队列 | I/O 完成中断 |
| 进程 wait() 子进程 | pid 的等待队列 | 子进程 exit |
| 互斥锁等待 | mutex 的等待队列 | mutex 解锁 |
| 条件变量 | 条件变量的等待队列 | signal/broadcast |
A.8 一图总结:等待队列完整生命周期
进程A
│
▼
┌─────────────────┐
│ read() 系统调用 │
│ [内核态, R状态] │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 检查站②数据 │
└────────┬────────┘
│
┌─────────┴─────────┐
▼ ▼
[有数据] [无数据]
│ │
▼ ▼
copy_to_user prepare_to_wait(&wq, &wait, TASK_INTERRUPTIBLE)
[内核态, 100%] set_current_state(TASK_INTERRUPTIBLE)
│ list_add_tail(&wait.entry, &wq.head)
│ schedule() ← 进程让出 CPU
▼ │
返回用户态 ▼
┌─────────────────┐
│ S状态(睡眠) │
│ [CPU占用: 0%] │
│ 在等待队列中睡觉 │
└────────┬────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
[被wake_up唤醒] [超时/被信号打断]
│ │
▼ ▼
finish_wait() finish_wait()
remove from queue remove from queue
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ R状态(就绪) │ │ 返回 -ERESTARTSYS│
│ [等待调度] │ │ 或 -EINTR │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ 被调度器选中 │
│ copy_to_user() │
│ [内核态, 100%] │
└────────┬────────┘
│
▼
返回用户态