从零开始构建操作系统听起来像是内核黑客用 C 语言编写的工作。但你可以在一个下午的时间里用 Python 搭建一个简化的模拟器,并且你会很快发现,进程管理的逻辑在高级语言中同样是毫不留情的。我曾为此吃过苦头。我坐下来准备写一个微型操作系统模拟器。目标很朴实:创建几个进程,对它们进行调度,并在它们完成工作时将其标记为已结束。代码很短,逻辑感觉无懈可击。然而当我运行它时,没有任何进程能够结束。

为什么要用 Python 构建微型操作系统?

真正的操作系统需要处理内存分页、文件系统、硬件中断和设备驱动。而模拟器则剥离了这一切,让你专注于核心概念:状态。你定义一个进程,它拥有 PID、burst time(执行时间)和生命周期状态:就绪 (Ready)、运行中 (Running)、已完成 (Finished)。调度器循环会挑选下一个候选进程,推进其状态,模拟一个时间片,然后将其转变为完成状态。

Python 是进行此类实验的绝佳工具,因为它让你不必去处理指针算术和内存对齐。一个字典列表就可以成为你的进程表。一个 while 循环就可以成为你的内核调度器。你只需使用标准库工具,就能实现轮询调度 (round-robin scheduling) 或优先级队列。它感觉触手可及,而这也正是随之而来的那个 Bug 如此令人恼火的原因。

环境搭建

我的模拟器使用了一个名为 process_table 的列表。每个条目都是一个如下结构的字典:

{
    "pid": 1,
    "burst_time": 3,
    "status": "ready"
}

调度器运行一个简单的 while 循环。它扫描表,寻找第一个状态不是 "finished" 的进程。一旦找到,它就会调用辅助函数 execute_tick(p) 来让该进程运行一个模拟周期。在 execute_tick 内部,我将进程状态设置为 "running",减少 burst time,并检查剩余工作是否归零。如果归零,我就会将状态更新为 "finished"。外层循环本应在每个进程都达到 "finished" 状态时终止。

从理论上讲,流程非常清晰:寻找就绪进程 $\rightarrow$ 运行它 $\rightarrow$ 检查是否完成 $\rightarrow$ 重复直到结束。我甚至添加了 print 语句来观察调度器的工作。我能看到进程被选中,循环一直在运转。然而,这些进程似乎进入了一种“永恒的现在时”,永远在运行,永远无法走向终点。

症状

这是最糟糕的一种失败:无声的失败。终端里没有弹出任何堆栈追踪 (stack trace)。没有 IndexErrorKeyError 为我提供任何线索。解释器运行得非常愉快,程序只是单纯地不按预期工作。进程启动了,但它们从未结束。我花了几个小时去回溯流程。

是循环条件错了?也许我在 burst time 计算中出现了差一错误 (off-by-one error)。是进程表在原地更新时被遮蔽或复制了吗?还是我的终止条件检查了错误的键?我添加了更多的 print 语句,审计了每一个布尔表达式。我质疑了一切,唯独没有怀疑那行真正关键的代码。

罪魁祸首

然后我发现了它。在 execute_tick 内部,我写的是:

p["status"] == "running"

两个等号。那是比较运算,而不是赋值运算。修复方法仅差一个字符:

p["status"] = "running"

在 Python 中,p["status"] == "running" 是一个完全有效的表达式。它会求值为 TrueFalse,然后解释器会丢弃结果,因为我从未将其赋值给任何变量。这行代码完全没有起到任何作用。字典条目保持原样,保留了之前的状态,导致进程无法在生命周期中推进。

我把它改成了单个等号。我重新运行了脚本。模拟器重新焕发了生机。进程按照计划准确地在就绪、运行和完成状态之间循环。仅仅一个多余的按键,就让我浪费了几个小时。

为什么这些 Bug 会隐藏起来

之所以这种错误让人如此抓狂,是因为除非语法完全错误,否则 Python 不会将表达式语句标记为错误。这个 Bug 是一个语义上的笔误。程序比较了状态,产生了一个布尔值,然后将其丢弃。因为比较本身可能返回 False,所以进程一直卡在之前的状态,而外层循环也没有理由终止。

你还加上了确认偏误(confirmation bias)。因为你本意就是要进行赋值,所以你会认为自己输入的就是赋值语句。当你第五次阅读代码时,你的大脑会自动纠正那个符号。这就是为什么“小黄鸭调试法”(rubber ducking)有效的原因。它强迫你足够缓慢地逐行阐述代码,从而使写下的内容与你的真实意图之间的差距变得清晰可见。

像这样的小 bug 比剧烈的程序崩溃更难发现。段错误(segfault)或语法错误会立即显现。而一个无声的空操作(no-op)只会悄悄破坏状态,让程序勉强维持运行。故障发生在下游,而你的直觉往往是去调试症状,而不是寻找根本原因。

更好的防御手段

你不能仅仅依靠眼睛。在经历这件事之后,我改变了一些习惯,这些习惯本可以更早地发现错误。

首先,如果你在字典中维护状态,请考虑使用 dataclassenum.Enum 来表示进程状态。将你的状态定义为常量或枚举成员:

from enum import Enum

class ProcessState(Enum):
    READY = "ready"
    RUNNING = "running"
    FINISHED = "finished"

有了显式类型,像 mypy 这样的工具就可以在静态分析期间标记可疑的比较。当类型与预期不符时,原本应该是赋值的地方却误写成了比较,这种情况会变得非常容易察觉。

其次,在编写调度器逻辑之前,先为状态转换编写单元测试。一个简单的测试——创建一个仅有一个 tick 工作量的进程,运行调度器,并断言最终状态为 FINISHED——本可以立即失败。这种失败会将搜索范围缩小到状态更新逻辑,而不是让我不得不在整个循环中漫无目的地寻找。