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的进程状态切换遵循一个清晰的闭环:

  1. 运行态 → 睡眠态:进程调用阻塞I/O系统调用,内核发现资源不可用,调用 schedule() 将进程状态置为 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE,并从运行队列中移除。
  2. 睡眠态 → 就绪态:当I/O条件满足(如数据到达、缓冲区可写),内核通过等待队列唤醒进程,将其状态改回 TASK_RUNNING,并重新插入运行队列。
  3. 就绪态 → 运行态:调度器在下次调度时选中该进程,恢复执行。
进程状态切换与阻塞I/O状态机 TASK_RUNNING TASK_INTERRUPTIBLE TASK_UNINTERRUPTIBLE 就绪(RUNNING) 资源不可用 schedule() 资源不可用 schedule() 条件满足/信号唤醒 条件满足唤醒 调度器选中 说明:阻塞I/O使进程从TASK_RUNNING进入睡眠态,调度器将其移出运行队列,不再分配CPU时间片

2.2 调度器如何阻塞进程:核心路径

当进程执行到阻塞I/O系统调用时,内核会沿着以下路径完成阻塞操作:

  1. 系统调用入口:用户态进程陷入内核,执行驱动或VFS层的 read()/write() 函数。
  2. 资源检查:驱动判断当前I/O资源是否可用(如环形缓冲区为空)。
  3. 设置进程状态:调用 set_current_state(TASK_INTERRUPTIBLE) 修改进程的 state 字段。
  4. 加入等待队列:调用 add_wait_queue() 将进程添加到驱动维护的等待队列头。
  5. 主动让出CPU:调用 schedule() 触发调度器重新选择进程。
  6. 调度器处理:调度器发现当前进程状态不是 TASK_RUNNING,将其从运行队列中移除,并选择下一个可运行进程。
  7. 进程挂起:被阻塞的进程不再参与调度,直到被唤醒。
关键代码路径(简化):
// 驱动阻塞读操作示例
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
等待队列数据结构与阻塞关系 wait_queue_head_t 节点1: 进程A TASK_INTERRUPTIBLE 节点2: 进程B TASK_INTERRUPTIBLE 节点3: 进程C TASK_UNINTERRUPTIBLE wake_up() 遍历链表 说明:等待队列头管理所有阻塞进程。当资源就绪时,wake_up()遍历链表,将每个节点的进程状态改回TASK_RUNNING 并调用try_to_wake_up()将进程重新插入运行队列

2.4 唤醒机制:从睡眠到就绪

当I/O条件满足时(如硬件中断到达、另一个进程写入数据),驱动需要唤醒等待队列中的进程。唤醒过程分为两步:

  1. 条件设置:驱动设置资源可用标志(如 dev->data_ready = 1)。
  2. 调用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实现的基础。