2. 进程状态与调度时机
进程调度是内核最核心的活动之一,而理解进程状态模型与调度触发时机是掌握调度器架构的前提。本章将深入剖析 Linux 内核中进程的七种状态及其转换关系,并详细说明调度器在哪些关键路径上被唤醒执行。
2.1 进程状态模型
Linux 内核使用 task_struct 中的 state 字段来标识进程当前所处的状态。该字段是一个位掩码,允许进程同时处于多个状态(如 TASK_INTERRUPTIBLE | TASK_KILLABLE)。
核心原则: 只有状态为
TASK_RUNNING 的进程才会被放入运行队列(runqueue)参与调度;其他状态均表示进程正在等待某种资源或事件。
2.1.1 状态定义与语义
| 状态宏 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
TASK_RUNNING |
0x0000 | 可运行状态,进程正在 CPU 上执行或等待被调度 | 普通用户进程、内核线程执行中 |
TASK_INTERRUPTIBLE |
0x0001 | 可中断睡眠,等待资源时可被信号唤醒 | 等待 I/O 完成、等待 socket 数据 |
TASK_UNINTERRUPTIBLE |
0x0002 | 不可中断睡眠,等待资源时忽略信号 | 等待磁盘 I/O、等待内核锁 |
__TASK_STOPPED |
0x0004 | 进程被停止执行 | 收到 SIGSTOP/SIGTSTP 信号 |
__TASK_TRACED |
0x0008 | 进程被调试器跟踪 | ptrace 系统调用、gdb 调试 |
EXIT_DEAD |
0x0010 | 进程已退出,等待父进程回收 | 调用 do_exit() 后 |
EXIT_ZOMBIE |
0x0020 | 僵尸状态,进程已死但 task_struct 未释放 | 父进程未调用 wait() 前 |
TASK_KILLABLE |
0x0001 | 0x0002 | 可杀睡眠,等同于 UNINTERRUPTIBLE 但可被致命信号唤醒 | NFS 文件系统等待、某些锁等待 |
经验提示:
TASK_KILLABLE 是 Linux 2.6.25 引入的优化状态,它解决了 TASK_UNINTERRUPTIBLE 进程无法被 kill 的问题,同时保留了不可中断睡眠的语义。内核开发者应优先使用 TASK_KILLABLE 替代纯 TASK_UNINTERRUPTIBLE。
2.1.2 状态转换关系
进程状态转换遵循严格的规则,下图展示了核心状态之间的迁移路径:
2.1.3 特殊状态:TASK_KILLABLE 与 TASK_IDLE
- TASK_KILLABLE:通过
set_current_state(TASK_KILLABLE)设置,底层是TASK_UNINTERRUPTIBLE | TASK_WAKEKILL。当进程等待 NFS 回复或某些内核锁时,如果收到致命信号(SIGKILL),内核会强制唤醒进程并返回-EINTR。 - TASK_IDLE:用于空闲线程(idle thread),表示 CPU 完全空闲。该状态不会参与调度,仅在
cpu_idle_loop()中使用。
避坑指南: 不要在内核模块中直接修改
current->state,应使用 set_current_state() 宏。该宏包含内存屏障,确保状态修改与唤醒操作之间的顺序正确,避免出现"唤醒丢失"问题。
2.2 调度发生的时机
调度器并非持续运行,而是在特定"调度点"被触发。Linux 内核定义了三种主要的调度时机:时钟中断、进程阻塞和主动调度。
2.2.1 调度时机分类
| 调度时机 | 触发源 | 调度类型 | 典型入口函数 |
|---|---|---|---|
| 时钟中断 | 硬件定时器(HPET/APIC timer) | 抢占式调度 | scheduler_tick() |
| 进程阻塞 | 系统调用(read/wait/sleep) | 自愿调度 | schedule() |
| 主动调度 | 显式调用 cond_resched() 或 yield() |
自愿调度 | __schedule() |
| 进程唤醒 | 资源就绪、信号传递 | 抢占式调度(若唤醒的进程优先级更高) | try_to_wake_up() |
| 中断返回 | 硬件中断处理完成后返回用户态 | 抢占式调度 | preempt_schedule_irq() |
2.2.2 时钟中断调度流程
时钟中断是抢占式调度的核心驱动。每个 CPU 的本地 APIC 定时器以固定频率(HZ)产生中断,触发以下流程:
- 中断处理:CPU 保存上下文,跳转到
timer_interrupt()处理函数。 - 更新时间记账:调用
update_process_times(),更新当前进程的utime/stime和虚拟运行时间。 - 触发调度器 tick:调用
scheduler_tick(),检查当前进程的时间片是否耗尽。 - 设置重调度标志:如果当前进程需要被抢占,设置
TIF_NEED_RESCHED标志。 - 中断返回时调度:在
ret_from_intr路径中检查TIF_NEED_RESCHED,调用schedule()。
2.2.3 进程阻塞调度
当进程执行系统调用需要等待资源时(如 read() 等待磁盘数据),内核会主动调用 schedule() 让出 CPU。这是最自然的调度时机,称为"自愿调度"。
代码示例: 进程阻塞调度典型路径
// fs/read_write.c - 简化版 read 系统调用
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd);
// ... 参数检查 ...
// 如果文件没有数据可读,进入等待队列
if (file->f_op->read) {
// 调用具体文件系统的 read 实现
ret = file->f_op->read(file, buf, count, &pos);
}
// 如果返回 -EAGAIN 且文件是非阻塞模式,则可能进入睡眠
if (ret == -EAGAIN && !(file->f_flags & O_NONBLOCK)) {
// 将当前进程加入等待队列
DEFINE_WAIT(wait);
add_wait_queue(&file->f_wqh, &wait);
// 设置进程状态为 TASK_INTERRUPTIBLE
set_current_state(TASK_INTERRUPTIBLE);
// 主动让出 CPU
schedule();
// 被唤醒后继续执行
remove_wait_queue(&file->f_wqh, &wait);
// 重新尝试读取
}
fdput_pos(f);
return ret;
}
2.2.4 主动调度与 cond_resched()
内核提供了 cond_resched() 函数,允许长时间运行的内核代码路径主动让出 CPU,避免导致系统响应延迟。该函数在以下场景中特别重要:
- 长循环遍历:如遍历大量内存页、链表扫描。
- 内核锁持有时间过长:在释放锁后调用
cond_resched()给其他进程机会。 - 实时系统:确保低优先级任务不会长时间阻塞高优先级任务。
实现细节:
cond_resched() 的实现非常轻量——它首先检查 TIF_NEED_RESCHED 标志,如果未设置则立即返回,开销仅为一次内存读取和条件跳转。因此可以在性能敏感的路径中安全使用。
// kernel/sched/core.c - cond_resched 实现
int __sched cond_resched(void)
{
if (should_resched(0)) {
preempt_disable();
__cond_resched();
preempt_enable();
return 1;
}
return 0;
}
// 检查是否需要重新调度
static inline int should_resched(int preempt_offset)
{
return unlikely(tif_need_resched());
}
2.2.5 抢占式调度与内核抢占
Linux 内核支持三种抢占模式,决定了调度器在哪些时机可以抢占当前进程:
| 抢占模式 | 配置选项 | 抢占时机 | 适用场景 |
|---|---|---|---|
| 无抢占 | CONFIG_PREEMPT_NONE |
仅在内核返回用户态时 | 服务器、批处理系统 |
| 自愿抢占 | CONFIG_PREEMPT_VOLUNTARY |
在 cond_resched() 和显式抢占点 | 桌面系统 |
| 完全抢占 | CONFIG_PREEMPT |
任何内核代码路径(除原子上下文) | 实时系统、嵌入式 |
注意事项: 完全抢占模式会显著增加内核的调度延迟不确定性,因为任何内核代码都可能被中断。在实时系统中,需要配合
PREEMPT_RT 补丁使用,该补丁将自旋锁替换为可睡眠的互斥锁,确保抢占安全。
2.3 调度时机总结
综合以上分析,调度器在以下五个关键路径上被触发:
- 时钟中断:周期性检查,实现时间片轮转和公平调度。
- 进程阻塞:资源等待时的自愿让出,效率最高。
- 主动调度:内核代码显式让出 CPU,用于长路径。
- 进程唤醒:高优先级进程被唤醒时立即抢占当前进程。
- 中断返回:从中断上下文返回时检查抢占标志。