2. Linux内核中的阻塞IO原理:进程状态切换与调度器如何阻塞进程
阻塞I/O是Linux内核中最基础的I/O模型。当进程发起一个暂时无法完成的I/O操作(如从空管道读取数据、向满管道写入数据)时,内核不会立即返回错误,而是将进程挂起,直到I/O条件满足后再唤醒它。本章深入剖析这一过程背后的核心机制:进程状态切换与调度器阻塞。
核心要点:阻塞I/O的本质是进程主动放弃CPU,进入等待状态,由内核调度器将其移出运行队列,并在条件满足时重新放回运行队列。
2.1 进程状态与阻塞状态机
Linux进程在生命周期中会经历多种状态。与阻塞I/O直接相关的状态是 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE。
| 状态宏 | 数值 | 含义 | 阻塞I/O场景 |
|---|---|---|---|
TASK_RUNNING |
0 | 进程可运行(就绪或正在运行) | — |
TASK_INTERRUPTIBLE |
1 | 可中断睡眠,收到信号可提前唤醒 | 大部分阻塞I/O(如read、write、select) |
TASK_UNINTERRUPTIBLE |
2 | 不可中断睡眠,必须等待资源就绪 | 磁盘I/O、内核态锁等待(如mutex_lock) |
TASK_STOPPED |
4 | 进程停止(收到SIGSTOP等) | — |
TASK_TRACED |
8 | 被调试器跟踪 | — |
EXIT_ZOMBIE |
16 | 僵尸进程 | — |
EXIT_DEAD |
32 | 进程已死亡 | — |
经验之谈:驱动开发中,如果等待时间极短(微秒级),应使用
TASK_UNINTERRUPTIBLE 避免信号干扰;如果等待时间较长或允许用户通过Ctrl+C终止,应使用 TASK_INTERRUPTIBLE。
阻塞I/O的进程状态切换遵循一个清晰的闭环:
- 运行态 → 睡眠态:进程调用阻塞I/O系统调用,内核发现资源不可用,调用
schedule()将进程状态置为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE,并从运行队列中移除。 - 睡眠态 → 就绪态:当I/O条件满足(如数据到达、缓冲区可写),内核通过等待队列唤醒进程,将其状态改回
TASK_RUNNING,并重新插入运行队列。 - 就绪态 → 运行态:调度器在下次调度时选中该进程,恢复执行。
2.2 调度器如何阻塞进程:核心路径
当进程执行到阻塞I/O系统调用时,内核会沿着以下路径完成阻塞操作:
- 系统调用入口:用户态进程陷入内核,执行驱动或VFS层的
read()/write()函数。 - 资源检查:驱动判断当前I/O资源是否可用(如环形缓冲区为空)。
- 设置进程状态:调用
set_current_state(TASK_INTERRUPTIBLE)修改进程的state字段。 - 加入等待队列:调用
add_wait_queue()将进程添加到驱动维护的等待队列头。 - 主动让出CPU:调用
schedule()触发调度器重新选择进程。 - 调度器处理:调度器发现当前进程状态不是
TASK_RUNNING,将其从运行队列中移除,并选择下一个可运行进程。 - 进程挂起:被阻塞的进程不再参与调度,直到被唤醒。
关键代码路径(简化):
// 驱动阻塞读操作示例
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
struct my_device *dev = filp->private_data;
// 1. 等待数据可用
wait_event_interruptible(dev->wq, dev->data_ready);
// 展开后等价于:
// set_current_state(TASK_INTERRUPTIBLE);
// add_wait_queue(&dev->wq, &wait);
// schedule();
// remove_wait_queue(&dev->wq, &wait);
// 2. 数据已就绪,拷贝到用户空间
copy_to_user(buf, dev->buffer, dev->data_len);
dev->data_ready = 0;
return dev->data_len;
}
2.3 等待队列:阻塞与唤醒的桥梁
等待队列(wait queue)是内核实现阻塞I/O的核心数据结构。它本质上是一个双向链表,链表中每个节点代表一个被阻塞的进程。
| 数据结构 | 定义位置 | 作用 |
|---|---|---|
wait_queue_head_t |
驱动私有数据或全局变量 | 等待队列头,管理所有等待该资源的进程 |
wait_queue_entry_t |
每个阻塞进程的栈或task_struct | 等待队列节点,包含进程描述符指针和唤醒函数 |
DEFINE_WAIT() |
驱动代码中 | 宏,定义并初始化一个等待队列节点 |
wake_up() |
驱动中资源就绪时调用 | 遍历等待队列,将每个节点对应的进程状态设为TASK_RUNNING |
2.4 唤醒机制:从睡眠到就绪
当I/O条件满足时(如硬件中断到达、另一个进程写入数据),驱动需要唤醒等待队列中的进程。唤醒过程分为两步:
- 条件设置:驱动设置资源可用标志(如
dev->data_ready = 1)。 - 调用wake_up:调用
wake_up(&dev->wq)或wake_up_interruptible(&dev->wq)。
wake_up() 内部执行以下操作:
- 遍历等待队列链表。
- 对每个节点调用
default_wake_function()。 default_wake_function()调用try_to_wake_up():将进程状态设为TASK_RUNNING,将进程插入目标CPU的运行队列,如果目标CPU空闲则触发调度。
注意:唤醒并不立即执行被阻塞的进程,只是将其状态改为就绪。具体何时运行由调度器决定。如果唤醒后没有更高优先级的进程,被唤醒的进程可能很快得到CPU。
2.5 阻塞与非阻塞的对比
| 特性 | 阻塞I/O | 非阻塞I/O |
|---|---|---|
| 进程状态 | 进入TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE | 保持TASK_RUNNING |
| CPU占用 | 让出CPU,不消耗时间片 | 持续轮询,消耗CPU |
| 系统调用返回 | 条件满足后才返回 | 立即返回,通过返回值指示数据是否就绪 |
| 等待队列 | 必须使用等待队列 | 不使用等待队列 |
| 适用场景 | 大部分设备驱动、文件系统 | 需要同时监控多个文件描述符(配合poll/epoll) |
| 调度器交互 | 主动调用schedule() | 不主动让出CPU |
常见误区:初学者常认为阻塞I/O会“卡死”系统。实际上,阻塞进程会主动让出CPU,调度器会运行其他进程,系统整体吞吐量反而更高。非阻塞轮询虽然响应快,但会浪费CPU周期。
2.6 实战:手动实现阻塞读操作
以下代码展示了一个字符设备驱动中,如何使用等待队列实现阻塞读:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/wait.h>
#include <linux/sched.h>
#include <linux/uaccess.h>
struct my_dev {
char buffer[256];
int data_ready; // 0: 无数据, 1: 有数据
wait_queue_head_t wq; // 等待队列头
};
static struct my_dev dev;
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
// 定义等待队列节点(在栈上)
DEFINE_WAIT(wait);
// 循环等待数据
while (1) {
// 1. 将进程加入等待队列
add_wait_queue(&dev.wq, &wait);
// 2. 设置进程状态为可中断睡眠
set_current_state(TASK_INTERRUPTIBLE);
// 3. 检查条件
if (dev.data_ready) {
// 条件满足,跳出循环
set_current_state(TASK_RUNNING);
remove_wait_queue(&dev.wq, &wait);
break;
}
// 4. 检查是否有信号(允许被信号打断)
if (signal_pending(current)) {
set_current_state(TASK_RUNNING);
remove_wait_queue(&dev.wq, &wait);
return -ERESTARTSYS;
}
// 5. 主动让出CPU
schedule();
}
// 拷贝数据到用户空间
if (count > sizeof(dev.buffer))
count = sizeof(dev.buffer);
copy_to_user(buf, dev.buffer, count);
dev.data_ready = 0;
return count;
}
// 驱动其他部分(open, write, release等)省略
代码要点:
DEFINE_WAIT(wait)在栈上创建等待队列节点,无需动态分配。add_wait_queue()将节点加入队列头链表。set_current_state(TASK_INTERRUPTIBLE)修改进程状态,但此时尚未让出CPU。schedule()是真正的阻塞点,调度器会切换进程。- 唤醒后从
schedule()返回,重新检查条件,避免虚假唤醒。
通过以上分析可以看出,Linux内核的阻塞I/O并非简单的“等待”,而是一套由进程状态管理、调度器协作、等待队列数据结构共同构成的精密机制。理解这一机制是掌握后续等待队列高级用法和poll/epoll实现的基础。