Linux驱动 2026年5月5日 145 分钟

IO模型详解(数据流本质视角)

阻塞、非阻塞、同步、异步详解(数据流本质视角) 本文档从”数据从设备到用户空间的完整旅途”这一角…

阻塞、非阻塞、同步、异步详解(数据流本质视角)

本文档从”数据从设备到用户空间的完整旅途”这一角度,深入剖析 I/O 模型中四个核心概念的本质。区别于抽象概念的解释,本文聚焦于:数据在哪、谁在移动它、卡在哪里、为什么卡。


一、数据流模型:三站旅途

1.1 一切 I/O 的本质

数据从设备到用户空间,必须经过三站

┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│   设备层      │   ①     │   内核缓冲区   │   ②     │   用户缓冲区   │
│   (硬件)      │ ───────> │   (kernel)    │ ───────> │  (user space) │
└──────────────┘ DMA/中断  └──────────────┘   CPU     └──────────────┘
    第一站                 第二站                  第三站
   数据产生地              中转站                  目的地
所在位置谁产生数据数据形态
第一站:设备层硬件(网卡、磁盘、传感器等)硬件产生或外部输入原始数据
第二站:内核缓冲区内核空间(kmalloc/vmallocDMA 控制器或中断处理程序拷贝到这里待处理数据
第三站:用户缓冲区用户空间(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_userR (内核态)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 中出现的位置
RRunning正在 CPU 上运行(或就绪)系统调用执行中、copy_to_user 时
SSleeping (Interruptible)可中断睡眠,可被信号唤醒wait_event、down_interruptible
DSleeping (Uninterruptible)不可中断睡眠,不可被信号唤醒等待磁盘 I/O
TStopped被暂停debugger 暂停
ZZombie僵尸,资源已释放但进程表项未回收子进程 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%]   │
            └────────┬────────┘
                     │
                     ▼
              返回用户态
上一篇 同步、异步、阻塞、非阻塞详解

同步、异步、阻塞、非阻塞详解 第一部分:四个概念的本质区别与联系 一、两个维度 维度 关注的问题 概念 消息通知机制 结...

下一篇 设备树2-设备树语法

本章主要探讨设备树的基本语法和使用技巧 子节点子节点是根节点的子项,用于描述具体的硬件设备或设备集合。 [label:]...