Skip to content

第十二章:天道法则

炼气初期

涉及内核源码:

林小源在重启后的世界中安静地修炼。

经历了 panic 的洗礼,他对内核有了更深的敬畏。他开始系统地整理自己所学的知识——从 BIOS POST 到 ,从调度器到内存管理,从中断到系统调用。

他把这些知识称为"天道法则"。

天道法则不是某个高高在上的存在制定的规则——它们是内核世界的"物理定律"。就像万有引力支配着天体运行,天道法则支配着内核的运作。

法则一:上下文切换是调度的基本单位。

林小源第一次真正理解这条法则,是在一次调度器切换中。

他坐在 idle 循环里,看着调度器在两个进程之间做决定——一个 shell 进程(PID 100),一个编译器进程(PID 200)。调度器看了看它们的 ,做出了选择。

"切到编译器,"调度器说。

然后切换发生了。

林小源看到了整个过程——不是抽象的描述,而是肉眼可见的过程。shell 进程的寄存器状态被一一保存:返回地址 ra、栈指针 sp、通用寄存器 s0。这些值被写入 shell 的 中,像把一个人的记忆封存进档案袋。

然后,编译器进程的寄存器状态被恢复——从它的 中读出,填进 CPU 的寄存器里。栈指针切到了编译器的内核栈。程序计数器跳到了编译器之前被中断的位置。

整个过程只用了几微秒。

"你看到了?"调度器的声音传来,冷淡而精确。

"看到了,"林小源说,"保存旧进程的状态,恢复新进程的状态,跳转。"

"没错。但你只看到了表面,"调度器说,"上下文切换的真正开销不在寄存器保存和恢复——那只需要大约 10 纳秒。真正的开销在别处。"

"什么别处?"

"TLB 刷新,"调度器说,"每个进程有自己的地址空间,切换进程后,TLB 中的地址映射就失效了。如果没有 PCID 这样的优化,TLB 必须全部刷新——这大约需要 100 纳秒。然后是缓存冷启动——新进程的数据不在缓存中,需要从内存重新加载——这可能需要 1 到 10 微秒。"

"总计大约 1 到 10 微秒。"

"对。所以我要尽量减少上下文切换的次数。每次切换都有代价——不是免费的。"

林小源点了点头。调度器不是在做简单的"轮转"——它在权衡。切换太频繁,开销太大;切换太少,响应变慢。这是一个精密的平衡。

法则二:进程有多种状态,状态之间可以转换。

林小源开始观察进程的状态变化,像一个医生观察病人的生命体征。

他看到 shell 进程在等待用户输入时,状态从 变成了 ——可中断睡眠。shell 不再消耗 CPU 时间,它在等待键盘中断的到来。

"它还能被唤醒吗?"林小源问。

"可以,"调度器回答," 意味着它可以被信号唤醒。比如用户按下 Ctrl+C,发送 ,它就会醒来。"

"如果它等待的是磁盘 I/O 呢?"

"那就是 ——不可中断睡眠,"调度器说,"磁盘 I/O 是关键资源,不能被信号打断。进程必须等到 I/O 完成才能醒来。你看到那些状态为 D 的进程了吗?它们就是在等磁盘。"

林小源继续观察。他看到一个进程退出了——调用了 。但它的 没有被释放。它的状态变成了

"僵尸进程,"调度器说,"进程已经退出了,但它的父进程还没有调用 来回收它。它的 还在系统中,占用一个 PID,但不占用 CPU 时间和内存。"

"如果父进程也退出了呢?"

"init 进程会收养这些孤儿进程,然后回收它们。init 是所有进程的最终祖先——它是最后的回收者。"

林小源看着那个僵尸进程在系统中游荡,像一个没有归宿的灵魂。它已经死了,但还没有被安葬。

进程的"死亡"不是一瞬间的事。

法则三:内核代码不能睡眠,除非明确允许。

这条法则,林小源是从一个差点 panic 的场景中学到的。

他看到一段内核代码在持有自旋锁的情况下,试图调用一个可能睡眠的函数。调度器的声音突然变得尖锐:

"住手!你在持有自旋锁的时候调用 kmalloc(GFP_KERNEL)?"

"怎么了?"那段代码困惑地问。

"GFP_KERNEL 意味着允许睡眠!如果内存不足,分配器会让当前进程睡眠等待内存回收。但你现在持有自旋锁——在持有自旋锁的时候睡眠,会导致死锁!"

"死锁?"

"对。你持有自旋锁睡眠了,其他 CPU 上的代码如果也想获取这个自旋锁,就会一直自旋等待。而你在睡眠,永远不会释放锁。两个 CPU 互相等待——死锁。"

林小源把这条法则记在心里:在中断上下文中、在持有自旋锁时,不能睡眠。只能使用不会睡眠的函数——比如 kmalloc(GFP_ATOMIC)

法则四:内核是不可抢占的(默认配置下)。

"这也是一条重要的法则,"调度器说,"在默认配置下,内核代码一旦开始执行,就不会被抢占。除非它主动调用 或进入睡眠。"

"那如果一个内核函数执行了太久呢?"

"那其他进程就只能等着。用户态进程会感觉系统'卡住'了。这就是为什么内核函数必须'快速完成'——不能在里面做耗时的操作。"

法则五:锁的顺序决定了生死。

"最后一条,也是最致命的一条,"调度器的声音变得严肃,"如果进程 A 持有锁 1 并等待锁 2,而进程 B 持有锁 2 并等待锁 1——死锁。"

"怎么避免?"

"规定锁的获取顺序。所有代码必须按照相同的顺序获取锁。先锁 1,再锁 2——永远如此。违反这个顺序的人,死。"

林小源把这些法则一一记下。它们不是建议,不是最佳实践——它们是天道法则。违反它们,轻则数据损坏,重则系统 panic。


道藏笔记

内核启示

天道法则是内核世界的"物理定律"。

上下文切换是调度的基本单位。它的开销包括:寄存器保存/恢复(~10ns)、TLB 刷新(~100ns)、缓存冷启动(~1-10us)。调度器(CFS)通过维护 来决定何时切换、切换到哪个进程。

进程状态是进程生命周期的体现。 表示可运行, 表示睡眠, 表示已退出但未被回收。状态之间的转换由事件驱动:I/O 完成、信号到达、进程退出等。

内核的不可抢占性(默认配置下)是一个重要的设计决策。它简化了内核编程——在单 CPU 上,如果一个内核函数没有主动睡眠,它就不需要担心被其他内核代码抢占。但在多核系统上,每个 CPU 都在独立执行内核代码,所以仍然需要锁来保护共享数据。

天道法则不是约束,而是秩序。没有它们,内核就是一盘散沙。


c
/*
 * 上下文切换(context switch)是调度的核心操作。
 * 它把当前进程的寄存器状态保存到它的 task_struct 中,
 * 然后从下一个进程的 task_struct 中恢复寄存器状态。
 */

struct context {
    unsigned long ra;    /* 返回地址 */
    unsigned long sp;    /* 栈指针 */
    unsigned long s[12]; /* s0-s11 寄存器 */
};

struct task {
    int pid;
    char comm[16];
    struct context cpu_context;
};

void switch_to(struct task *prev, struct task *next) {
    printf("[switch_to] 保存 PID %d (%s) 的上下文\n",
           prev->pid, prev->comm);
    printf("  保存 ra=0x%lX, sp=0x%lX\n",
           prev->cpu_context.ra, prev->cpu_context.sp);

    printf("[switch_to] 恢复 PID %d (%s) 的上下文\n",
           next->pid, next->comm);
    printf("  恢复 ra=0x%lX, sp=0x%lX\n",
           next->cpu_context.ra, next->cpu_context.sp);
}

struct task task_a = {
    .pid = 100,
    .cpu_context = { .ra = 0x1000, .sp = 0x7FFF0000 },
};
strncpy(task_a.comm, "shell", sizeof(task_a.comm));

struct task task_b = {
    .pid = 200,
    .cpu_context = { .ra = 0x2000, .sp = 0x7FFE0000 },
};
strncpy(task_b.comm, "compiler", sizeof(task_b.comm));

printf("=== 上下文切换演示 ===\n\n");

printf("当前运行: PID %d (%s)\n", task_a.pid, task_a.comm);
printf("调度器决定切换到: PID %d (%s)\n\n", task_b.pid, task_b.comm);

switch_to(&task_a, &task_b);

printf("\n切换后: PID %d (%s) 正在运行\n", task_b.pid, task_b.comm);

printf("\n--- 上下文切换的开销 ---\n");
printf("1. 保存/恢复寄存器: ~10ns\n");
printf("2. 刷新 TLB: ~100ns(如果没有 PCID)\n");
printf("3. 缓存冷启动: ~1us - 10us\n");
printf("总计: 约 1-10 微秒\n");
c
/*
 * Linux 进程状态(include/linux/sched.h):
 * TASK_RUNNING            0  可运行或正在运行
 * TASK_INTERRUPTIBLE      1  可中断睡眠
 * TASK_UNINTERRUPTIBLE    2  不可中断睡眠
 * __TASK_STOPPED          4  已停止
 * __TASK_TRACED           8  被追踪
 * EXIT_ZOMBIE            16  僵尸进程
 * EXIT_DEAD              32  已死亡
 */

enum task_state {
    TASK_RUNNING           = 0,
    TASK_INTERRUPTIBLE     = 1,
    TASK_UNINTERRUPTIBLE   = 2,
    __TASK_STOPPED         = 4,
    __TASK_TRACED          = 8,
    EXIT_ZOMBIE            = 16,
    EXIT_DEAD              = 32,
};

const char *state_name(int state) {
    switch (state) {
    case TASK_RUNNING:         return "RUNNING(可运行)";
    case TASK_INTERRUPTIBLE:   return "INTERRUPTIBLE(可中断睡眠)";
    case TASK_UNINTERRUPTIBLE: return "UNINTERRUPTIBLE(不可中断睡眠)";
    case __TASK_STOPPED:       return "STOPPED(已停止)";
    case __TASK_TRACED:        return "TRACED(被追踪)";
    case EXIT_ZOMBIE:          return "ZOMBIE(僵尸)";
    case EXIT_DEAD:            return "DEAD(已死亡)";
    default:                   return "UNKNOWN";
    }
}

printf("=== 进程状态转换图 ===\n\n");

printf("状态列表:\n");
int states[] = {0, 1, 2, 4, 8, 16, 32};
for (int i = 0; i < 7; i++) {
    printf("  [%2d] %s\n", states[i], state_name(states[i]));
}

printf("\n--- 状态转换 ---\n");
printf("RUNNING → INTERRUPTIBLE:  进程等待 I/O\n");
printf("RUNNING → UNINTERRUPTIBLE: 进程等待关键资源\n");
printf("INTERRUPTIBLE → RUNNING:   I/O 完成,被唤醒\n");
printf("RUNNING → STOPPED:         收到 SIGSTOP\n");
printf("STOPPED → RUNNING:         收到 SIGCONT\n");
printf("RUNNING → ZOMBIE:          进程退出,等待父进程回收\n");
printf("ZOMBIE → DEAD:             父进程调用 wait()\n");

printf("\n--- idle 进程的状态 ---\n");
printf("idle 进程通常处于 RUNNING 状态\n");
printf("但它只在没有其他进程可运行时才被调度\n");
printf("它执行 WFI 指令让 CPU 进入低功耗等待\n");

破关试炼

法则之试

本章讲到进入自旋锁保护的临界区时,代码路径会调用哪个加锁接口?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道