一、Review & Comments
好,今天我们正式进入操作系统课程的内容。在正片开始之前,我再回顾一下——我今天给大家带了一个玩具,叫 Book 8088,想不起来花了多少巨资购买的。上次讲 Firmware 的时候提到过,这是一个 16 位的计算机。你会看到这是 Firmware 启动时打印的一些日志信息,它启动真的需要挺长时间的。这是一个有真正 8088 CPU 的计算机。
当然它的外设全部都是用更现代的方式来实现的,但大家千万不要小看这样的系统——这个系统是真的可以编程的。比如说我刚才还测试了一下,你甚至可以直接打开一个有点像 IDE 的编辑器。你可以想象如果那个时候就有 Claude Code 的话,就真的可以实现任何东西。比如说我现在可以试一下,只要用一条命令,比如 LINE 1,1,100,就可以画出一条线段。
然后我还有肌肉记忆,我可以用 F5 来运行它,它会进入另外一个图形显示的模式,然后真的画出一条线来。你可以想象在那个时候就可以做几乎任何事情——比如说我还可以让它播放声音。IBM PC 是一个玩具的计算机,但它是一个非常完整的系统。我可以上比如说 100 赫兹一份,然后上 1000 赫兹再来一份,两条命令就能听到两个不同频率的声音。你会发现它和今天我们的计算机系统真的没有任何本质的区别。
我今天也是几乎所有的事情都在终端里面完成的,而且你在终端里面开 Claude Code 就可以搞定。这是一个玩具,然后我有一些小的 clarification。比如有同学问我上课到底用的是什么模型——如果你问它,它应该会告诉你它是 Anthropic 的 4.6,但如果你看它详细的日志的话,你会发现它调用的模型实际上是豆包。我上一次课是第一次用这个豆包。
因为我试了几家以后,[听不清] 的模型其实还是不错的,挺好用的。但我觉得最快的、能够让我上课有最好体验的还是豆包。不过在我后续给它更多任务的时候,我发现它相比于 Anthropic 或者 Codex 这样的模型,智力是非常低的——它各种不听指令。但即便是这样一个不听指令的模型,你看它都可以帮我"逃课"操作系统实验。所以这个世界进步得很快,大家可能都要花一点时间去赶上这个世界的变化。
好,回到操作系统课的正片。刚才我们给大家看了一个更古老的 CPU,这个 CPU 是 16 位的,甚至没有保护模式——没有分页,没有 MMU 这样的机关都没有。在我们的计算机世界里面,比如说这样一台电脑,它就是一个状态机(State Machine)。状态机的状态就是 Memory 和 Register——寄存器和内存。当然它还和物理世界有一些接口,这是一个 VGA……不,这是 RS-232,这可能是一个并行口,很老的接口,以前我小时候还用过。
每次它就可以执行一条指令。从 CPU Reset 开始,我们的手册上规定好了 CPU Reset 以后每一个寄存器的值,然后从 Firmware 它就开始执行。在这个基础上我们有了操作系统——操作系统实际上一开始是存储在磁盘上面的一段代码,然后它被 Firmware 加载到内存里,也是一个普通的程序在运行。然后操作系统上面又长出了一整个应用生态,很多的 a.out 这样的应用程序,它本质上也是一个状态机的模型。
前面花了一些时间,一方面给大家建立这个概念,另一方面我试图传达一个想法:所有的不管是硬件也好还是程序也好,它都有一个非常明确的数学模型。虽然你说要做到每一个 bit 的行为你都非常清楚——今天你们不需要做这件事了,AI 知道。但你背后要知道,所有的事情都是有答案的,计算机世界里是没有魔法的。我们从那个最小的、直接用 LD 的工具链生成一个最小的 _start,你可以控制程序从第一条指令执行的控制流——但它会 crash。开始一点一点,你可以解开这个计算机系统里面所有的谜题。
所以就进入我们现在正式的知识——Virtualization。我们有 "Three Easy Pieces":Virtualization、Concurrency 和 Persistence。第一个讲操作系统怎么把这样的一个计算机模型变成好像有好多个应用程序同时在执行,到底提供了怎样的抽象。Concurrency 是在我们的操作系统有了虚拟化的能力——也就是每个程序好像都独占了一台计算机——以后自然而然带来的一个问题。最后 Persistence 是操作系统和外部的交互。
也就是教科书上的三个部分。虚拟化的部分讲的是:one of the most fundamental abstractions that OS provides to users — the process。就讲进程是什么,怎么把一个物理计算机抽象成很多个虚拟计算机。从现在开始,每一讲我都会讲一些操作系统的 API——它为什么要这样设计,以及设计完了以后我们可以用它来干什么。我希望大家每一次课都能感受到一点能力边界的扩展,因为以前你们如果只是在应用编程的话,有些 Hacking 是做不了的,但学了操作系统以后,有些有意思的 Hack 就可以做了。
二、CrazyOS
好,那我们先来讲程序和进程。我试图再把时间倒回到一九八几年——假设我们只有一台这样的计算机,没有 MMU,所以没有分页,没有中断,什么都没有。即便它什么都没有,我们有没有可能在这样一台计算机上——或者说我们的一个普通的 C 程序——做这样一件事:
假设我还有两个函数叫 P1_main 和 P2_main。如果你直接这样运行的话,它会等到 P1 执行完再执行 P2。那我有没有可能让 P1 是一个死循环、P2 也是一个死循环,然后但我执行 main 函数的效果就是它们两个好像是同时在执行的?因为我们的 C 源程序就是顺序执行的,假设没有操作系统,你的 C 程序是直接运行在计算机上的——像 Firmware 一样,或者运行在一个没有 MMU 的机器上,这件事情能办到吗?
马上你会觉得这件事情有可能是可以办到的。你还记得那个 Tower of Hanoi 的例子吗?如果我们要模拟一个递归的程序用非递归实现,我还是可以把它写成一个解释器的形式。那个非递归的 Hanoi 就是取指令、取语句,执行。如果这个语句是 call 或者 return 的话,它对应的是把函数调用栈稍微做一个非常小的操作。所以理论上说,我们是不是也可以实现一个这样的模拟器,只是我取的语句可以从 P1 里面取一个、P2 里面取一个——每次取一条语句执行,因为 P1 和 P2 本质上也是状态机,是两个不同的程序,它们各自有自己的函数调用栈和全局变量这样的状态。
所以理论上说这件事是可以办到的。我们有点像做了一个虚拟机,但又有点像做了一个操作系统,所以我叫它 CrazyOS。CrazyOS 做的事是一个死循环:每次假设它可以加载任意多个程序 P1、P2、P3,然后每次随便选一个程序拎出来,往前执行一条语句,执行完以后它的内部状态就会变——没关系就让它执行。如果这条语句是 system call,我可以把这个系统调用执行了。比如说我可以为 P1 和 P2 做一个叫 putchar 的系统调用,每次执行到 putchar,操作系统都会把这个字符打印出来。我还可以做一个 exit 的系统调用,如果执行了那这个程序以后就再也不会被调度了,我就可以把内存释放掉。
这就是操作系统。只是操作系统做的不是模拟解释——不是取语句执行取语句执行——它是因为每一个程序都是一个二进制文件,那个二进制文件也是指令序列,它可以直接把一个程序当前的状态加载到 CPU 上。这个过程叫上下文切换。当然这门课不讲细节,我就给大家一个概念:它可以把 P1 先放到 CPU 上运行一会儿,然后发生中断以后,再把 P2 放到 CPU 上运行一会儿——P1、P2、P1、P2——就实现了时间片的轮转。
所以本质上你可以用这个模拟器来理解操作系统是怎样实现虚拟化——也就是说怎么把多个程序同时装入到内存,并且给它们一个并发执行的假象。如果在一个比较长的时间尺度上,你看它打印出来的字符序列的话,它真的就是好像在交替打印。
然后可怕的事情就发生了——什么可怕的事情呢?我就给了 AI 这样一个 prompt,CrazyOS 就被实现出来了。我说:我们希望模拟一个操作系统,有一个 crazyos.c,它可以加载好几个 RISC-V 的二进制文件。还记得我们刚开始的时候有一个 RISC-V 的模拟器——mini-rv32ima 模拟器——它可以模拟执行一条 RISC-V 的指令。这不就非常完美地符合我们的模型吗?我可以为每一个程序(一个二进制文件)加载到里面,一个 struct cpu_state,为它分配一份内存,然后把程序加载到内存里——它不就是一个状态机吗?然后我每次用模拟器去 single step 模拟执行一条指令,那不就搞定了吗?然后它就真的直接搞定了。
我们来看一下。我们的小操作系统上可以执行真正的程序,你们回去也可以自己玩。比如说我有 p1.c,当然我的实现比较简单,不支持全局变量——如果你想要支持的话会让代码膨胀,但我不想让代码膨胀,所以我选了一个最简单的实现方式。我有一个局部变量 x = 0,P1 每次是 x = x + 10,然后把这个 x 打印出来;P2 每次是 x + 1,然后把它打印出来。打印是一个比较长的函数——就是我们上次说的那个 printf。
也就是说我可以写任意的合法的 C 代码,前提是不用全局状态。然后我这里有两个比较重要的函数:第一个是放在头上的 _start,在这个 _start 里会调用 main,相当于是 libc 的入口。整个程序初始的时候第一条指令就落在这个位置,它会调用到 main。接下来我还有一个很特别的函数——我真的实现了一个系统调用叫 putchar。这些代码也都是 AI 帮我实现的。它把系统调用的参数——也就是要打印的字符——放到 a0 里,然后系统调用的编号放到 a7 里(42 是我定义的编号)。
这是一个应用程序。我的应用程序怎么编译的呢?这也是 AI 帮我写的——直接用 RISC-V 的 cc(相当于 gcc),编译成 RISC-V,带上一系列编译选项。用 cc 把它编译成一个二进制文件,然后再用大家熟悉的 objcopy——在计算机系统基础课上面讲过——把里面的指令序列提取出来,变成一个 binary。
现在可以编译它——编译好了。它还贴心地把中间生成的 ELF 结果给删掉了——我还不想删呢。但 Makefile 里面没有显式的 remove,Makefile 只有在 make clean 的时候才有。这是怎么回事呢?我选择求助 AI,它找到原因了:在这个 Makefile 里面,ELF 文件是中间文件,只作为 .binary 的依赖而存在。在 GNU Make 当中,中间文件构建完成后会被自动删除。它还可以告诉我怎么修复。
我们可以用 objdump 来看这个 P1。你会看到这就是一段 RISC-V 的指令序列,它从 _start 开始,加载位置是 0x8000,然后它会跳转到 main。我的系统调用实现也在这——它会把字符放到 a0 里,然后把 42 放到 a7 里,然后执行一个 ecall。看不懂不要紧,你可以让 AI 来解释。
那我们干了一件什么事呢?我让 AI 实现了这样一个 CrazyOS。CrazyOS 的 main 函数跟我刚才说的一样——是一个死循环。它首先从命令行参数里面读取几个 binary 文件,然后为每一个 binary 文件都做一个 proc_init。proc_init 相当于是创建一个这样的结构体。
什么是操作系统呢?如果我要真的实现一个这样的操作系统,里面有 P1 和 P2,我就在操作系统里面为 P1 创建一个 state。这个 state 有两个部分:第一个部分是它所需要的 memory 和 register——因为它是一个二进制文件,要运行就必须要有寄存器和内存。有一个 cpu_state,有一份 memory,然后把程序加载到内存里,它不就是一个状态机吗?此外操作系统内部还有一些状态,比如进程的编号。我做了一个小 trick——putchar 的实现不是立即把字符打印出来,而是为每一个程序分配一个属于它的 buffer。比如打 hello 的话,它会一个一个字符先打到这个 buffer 里,直到 buffer 要满或者碰到一个换行的时候,才会真的把 buffer 打印到输出。P1 有一个 buffer,P2 也有一个。
剩下的就是符合这个模型了。一个死循环,每次选择一个程序——我有一个数组 procs,代表每一个进程。每次循环结束以后我都会把指针加 1,所以这就叫 Round Robin 策略——按照 1、2、3、1、2、3 这样的顺序公平地让每一个程序都往前执行一条指令。在这里就是用 rv32ima_step 模拟器单步执行一条指令。如果发现是系统调用的话,我会跳到 handle_ecall,看 a7 的值——a7 代表系统调用的编号。如果编号是 42 的话,就去调用 sys_putchar。理论上你可以在这个框架上面增加很多代码,比如模拟 exit 系统调用,甚至可以在上面实现一个半虚拟化的优化虚拟机。如果你穿越回 20 年前,你就可以做一个很不错的操作系统领域的工作。
很有趣的一个 crazy idea。我可以运行 CrazyOS,比如 p1.binary——P1 每次加 10:10、20、30、40、50、60。如果我运行 p2.binary,可以看到 1、2、3、4、5、6、7、8、9、10,程序真的正常运行了,system call 被正确调用了。然后我也可以运行两个 p2.binary——你真的看到 1、1、2、2、3、3、4、4、5、5、6、6。以及我还可以交替运行 P1 和 P2——P2 先打出 x = 1,然后 P1 打出 x = 10,P2 打出 x = 2……
你们会发现谁运行得更快?P2 运行得更快。为什么呢?因为我的模拟器每次都执行一条指令,而 P1 打的数字更大,它那个 printf 执行的时间更长。所以到后面你会发现它们俩速度逐渐拉开差距——P2 已经打到 69 了,但 P1 只有 61。符合我们的预期。
这就是操作系统。理论上说你们可以在这个 CrazyOS 的基础上实现出整个 Linux 上面所有的东西。
三、程序与进程
所以这就很清楚了。你们已经理解了——所谓的 P1,比如说 p1.bin,这叫一个程序。程序描述了一个状态机,但你们更直观的理解就是它像一个配方、一套步骤指令:你就从这个地方开始,然后做 A、B、C、D、E,该调用系统调用的时候调用系统调用,该计算的时候计算。
而程序在操作系统上,比如说不管是二进制的 ELF 文件也好还是文本文件,它本身就是一串字节序列。但当它们加载到操作系统上真正运行起来的时候,它就变成了一个进程(process)。这个词也很直观——所谓的进程,"进"就是运行,前进中的程序。所以你看到刚才我是可以同一份程序 P1 有两个进程的——我可以 run 很多份。
每当一个静态的描述(程序是状态机的静态描述)运行起来,操作系统会给它分配资源。所谓的分配资源就是它必须要有它的内存——每一个字节的值,然后它的寄存器的值。每一个运行的进程,它的值都是一份额外的拷贝。在这个基础上,操作系统还会给它分配一些其他的操作系统内部的状态数据,比如 buffer 是什么等等。
接下来操作系统就是一个无情的执行指令的机器——只不过它会做进程的时分复用。每次选一个进程在 CPU 上运行一会儿,运行完了再选一个运行一会儿,如果碰到系统调用就执行这个系统调用的契约。这就是进程。
大家也就知道了,操作系统里面会保存一些程序运行所需要的状态。那你们想一想,操作系统会保存哪些和进程相关的状态呢?用我的 CrazyOS 打比方——我 gdb crazyos,我可以 start,可以 info registers 看到所有寄存器和内存的值,可以打印任何一个变量。
除了寄存器和内存之外,一个真实的操作系统还会给进程保存哪些数据?你马上可以想到,进程要有一个编号。如果你们在 Windows 的任务管理器里 Ctrl+Shift+Esc 叫出任务管理器,左边都有一个编号。你会想象如果在 CrazyOS 里面实现的话,很直观——就是在这个 proc 里面加一个 int process_id,在 proc_init 的时候给它一个唯一的 ID。
那一个真实的操作系统到底会在进程里面维护哪些状态呢?因为我们说是 AI 的时代,我让 Anthropic 的最新模型帮我写了这样一段代码。然后在我读完这个代码以后,我就觉得一阵窒息的感觉——它以非常好的编程直觉、非常规范地给我写了一个非常 comprehensive 的:操作系统到底有哪些、一个进程到底有哪些状态。甚至还有 showPersonality 这样的东西,又是"奇怪的知识增加了"。
对比之下,我也让豆包用完全相同的 prompt 生成了一份。豆包生成的其实不差,但跟一线模型相比还是有很大差距。这意味着什么?因为我们是一个动态变化的世界,模型只会越来越强。你现在一个大号 600 币的模型能做的事情,未来 30 币模型就能做。你看现在千问 9 币的模型就比 GPT-3.5 还要强——GPT-3.5 是一个 170 币的模型,这是一件多么可怕的事情。豆包总有一天它会赶上的。
我们可以运行它。一个真实的操作系统里面进程所持有的资源或信息,可能比你想象的要多一些。我们基本上会花一整个学期的时间来讲其中的一部分,不会涉及到全部。
首先你们能想到会有 PID(进程编号)。实际上它还有好多其他编号——比如 PPID 是 Parent PID,还有 PGID(Process Group ID),这都是历史遗留问题。还有 UID、GID、EGID 等等。我们的进程还会存放当前的命令行调用方式。比如你现在看到的是 ./proc_info,我是用这个方式来运行它的。因为"点"代表当前目录,所以 ./././proc_info 还是可以正确启动这个程序。不过如果你用不同方式启动这个程序的话,你会看到它的 Process Identity 部分就会变——它知道你调用的 command line 是什么。
看起来你启动程序以后那些信息就丢了?其实没有丢,操作系统都给你偷偷藏起来了。除了你能看到的 Memory 和 Register 以外,进程几乎所有的信息操作系统都知道。比如说你运行了多少时间、消耗了多少内存、打开了多少文件。这边有 Scheduling Information——奇怪的整数没关系,你问 AI 就可以了。它打开的文件,所谓的打开的文件就是持有的操作系统对象的指针,我们也会在后面的课上讲到。它的内存我下节课就会讲。
正确的打开方式就是——你看这已经是豆包了——让它读一下运行结果,然后读一下源代码再读一下运行结果,帮我解释一下。你就可以跟它对话——真的可以跟它对话一下午,就可以把整个操作系统的内存到底为我们的进程提供了什么给弄清楚了。
如果你回去看豆包给你的解释,你可以实际上问一个更深入的问题:这些状态它都不在进程的内存和寄存器里,如果一个进程想要知道这些状态,它是通过什么呢?一定是系统调用——因为程序本身只能看到自己的内存和寄存器,一切和它自身(C 语言状态机)之外的事情都必须通过系统调用这个接口来完成。
比如说 getpid、prctl(process control)等等。还有一个有意思的——操作系统从 UNIX 时代就有一个很有趣的发明:因为 UNIX Philosophy 是把很多小工具以文本接口连接起来(跟今天的大模型很像),所以操作系统里面提供了一个叫 procfs 的文件系统。
这个目录就在 /proc。比如说如果是我这个进程自己的话,专门有个目录叫 /proc/self,里面就有所有的信息。比如我现在用 ps 查看一下 PID,假设我现在的 Shell 是 12984,我就可以进入到 /proc/12984,然后可以看 cmdline——它就知道我当前 Shell 的命令是用什么样的命令行参数启动的。
以前我觉得大家探索 procfs 会非常困难——你需要去看 kernel documentation,要么通过博客,但唯一权威的途径就是内核文档,除此之外所有东西都不靠谱。但今天我们有了一个介于中间的东西——AI 既非常权威、基本可信,同时又非常全面,不像博客那种只告诉你最基本的东西。
所以这一页上要说的内容总结起来就是一句话:你需要产生一个正确的问题——我的进程通过什么样的系统调用来获取自身的信息——然后一步一步追问就可以得到所有的信息。
基本上开局只要一个 CrazyOS,大家应该今天对这段代码留下了非常深刻的印象。CrazyOS——这就是一个进程,这就是一个操作系统。整个操作系统课所有的内容都可以通过三十到五十个问题来组织。好,这是我讲的第一个很重要的概念——程序和进程,希望我已经把这些重要的概念解释清楚了。
四、进程管理 API
那现在我们就可以进入系统调用的世界了。刚才那个 CrazyOS 有一个局限——我运行它的时候所有的进程都是确定的,都是在命令行参数里面写死的。这个进程既不能交互也不能退出。但在实际的操作系统里面不完全是这样的。
比如说如果我们调用一个叫 pstree 的命令,你会看到操作系统里面的进程都有一个共同的根叫 systemd(当然这个说法不是特别严谨——Linux 系统加载的第一个程序其实不是 systemd,但有一天它会变成 systemd)。所有运行的进程都有一个共同的祖先叫 systemd,然后一级一级一级地孵化。比如说我有好多个 Fish,Fish 是在一个叫 foot 的进程底下,foot 是我的终端模拟器。
所以在真实的操作系统里,它启动以后只有一个 process——一号进程,也叫 init。你操作系统上面的整个世界——比如你可以启动一个 VS Code、启动一个浏览器、启动任何东西——都是由这个 process 一号孵化出来的。所以操作系统给我们提供了一整套进程管理的系统调用,帮助我们创建和销毁进程,就这么简单。
4.1 Spawn vs Fork
开始的时候只有一个 init。开发者会有创建新进程的需求——这时候就有一个问题:我们应该提供什么样的 syscall?
如果用状态机的视角来看,大家能够想到的是:如果我要创建一个进程,就做一个叫 spawn 的系统调用,有两个参数——一个是 path(你想要执行的二进制文件的路径),还有一个是它的参数。执行完这个系统调用以后,操作系统里面实际上就会多出一个新的进程,有一个父子关系。
只要有这样的 spawn system call,我就可以在操作系统里面创建进程了。像比如说 Windows API 就有这样的 spawn API,允许你创建一个进程——指定一个 .exe 文件,并且给 main 函数传一些参数,那个 exe 文件就启动起来了。这跟你们在 VS Code 里写一个 C 程序、点运行按钮、后台编译成 .exe、然后黑窗口弹出来是一样的——实际上就执行了这个 spawn。
这当然是一个合理的设计,但是我们的 UNIX 给了一个不一样的答案。
4.2 Fork
我觉得这是一个很有趣的抽象。因为我们说程序是状态机,程序运行的时候这就是整个程序的状态。所以 UNIX 给我们实现了一个非常有趣的 system call 叫 fork。
Fork 和 spawn 不一样。本质上 spawn 做的事情是在系统里面创建一个新的状态机,从那个程序的初始状态开始执行。但是 UNIX 发现——既然已经有一个状态机了,如果我执行一个叫 fork 的系统调用,就把这个状态机的状态原封不动地复制一份。所谓的原封不动的复制,你就理解成在 CrazyOS 里把所有这些东西直接一个字节一个字节原封不动地拷贝一份——内存的每一个字节被拷贝了,寄存器的每一个字节被拷贝了,包括操作系统内部状态的每一个字节也被拷贝了。
比如说如果进程 A 现在持有一个指针指向终端 TTY,那么 fork 之后两个进程会以共享的方式持有这个 TTY。这就是为什么你在一个终端里面创建子进程后,子进程默认也是输出到同一个终端——因为子进程会继承父进程所有的资源,一份状态机完整的复制,包括内存和寄存器现场。
CrazyOS 是理解这个过程最清楚的地方——就是把所有的状态(不包括数据结构的状态,只是整个进程本身所有连带操作系统内的状态)做一个完整的拷贝。
为了区分父进程和子进程——因为如果真的做一个完整的、一个字节都不差的拷贝,你就区分不出来了。为了让程序员能够区分出谁是父亲谁是孩子,fork 的返回值不同:父进程会返回子进程的 PID(比如 100),而子进程返回的是 0。这就是唯一可以区分两个进程的方式。
所以如果你想要使用 fork,你就会写这样一段代码:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(linepid_t pid = fork();if (pid == -1) { // error handling} else if (pid == 0) { // child} else { // parent}
虽然做了一个状态的完整复制,但你还是可以在下面通过返回值走不同的分支——父子进程两边的逻辑依然是可以区分的。
4.3 进程树与托孤
不管是 fork 还是 spawn,创建出来的进程都有一个父子关系——因为总有人是执行 fork 或者 spawn 的,它创建出来的就是它的孩子。这样就形成了一个树状的进程关系。比如 A fork 出 B,A 再 fork 出 C,B 和 C 又可以再 fork……整个进程树就是用一个系统调用就可以创建出来。
这里有一个问题:如果 B 的父进程是 A,C 的父进程是 B,那如果 B 退出了,C 的父亲是谁呢?因为进程编号是有限的,过一段时间万一有一个新进程复用了 B 的编号,"我的父亲"不就变了吗?这件事情不对。在 UNIX 里有一个功能——因为 1 号进程永远不会终止,所以如果没有父亲的进程,都会挂到 1 号进程上,变成 1 号进程的孩子,这就叫托孤。
这样确保每一个进程的 PPID 总是合法的,不会有一个空的或者错误的 PPID。当然如果你要给父亲发信息,发给 1 号进程的话,1 号进程都会忽略掉,这是一个广泛使用的 workaround。现在 Linux 里面 PID 上限应该是 100 万(可以配得更大),还有一个专门的子系统来做 unique PID 的管理。
比如我让 AI 帮我创建一个五层的进程树,随机退出其中一些进程,然后观察父子进程的关系——还可以画出一个 Mermaid 的树状关系图。确实可以看到托孤的行为——有些进程的父亲被杀掉以后,它们变成了 1 号进程的孩子。
4.4 Fork 的应用
然后我在课间的时候花了一点时间让豆包在 CrazyOS 里实现了一个 fork。在真实的操作系统包括 UNIX 里,fork 真的就是这样实现的——就这么几行:先找一个没有使用的数据结构(相当于 new 一个新的 struct proc),然后直接把旧的所有信息一把拷过来。只是父进程的返回值是子进程的编号,子进程的返回值是 0。就这么简单——真的就是一个完整的拷贝。
Fork Bomb
为了帮助大家记住 fork,可以先介绍一个东西叫 Fork Bomb。Fork Bomb 的工作原理非常类似于核弹的链式反应——释放出更多的中子撞击更多的铀原子核,不断以指数级增长。同样地我可以写一个这样的程序——while(1) fork()。会发生什么呢?fork 以后变成两个,两个都会同时执行下一轮 fork 又各自变成两个——一个变两个、两个变四个、四个变八个。指数级增长,瞬间系统里面所有资源就会耗尽。
当然今天 Linux kernel 有 out of memory 的机制——如果整个系统没有资源或没有内存,它会杀掉一部分进程,能知道是这个进程组的问题,所以可以把 Fork Bomb 杀掉。
用 Shell 语言写的 Fork Bomb 是那个看起来像一个倒过来的人的表情符号——它实际上定义了一个用冒号命名的函数,函数体就是调用自身两次并用管道连起来在后台执行。在一个老式的 Linux 系统上,一行代码就可以把系统炸掉。
Fork 的实用场景
Fork 可以干很多花活。比如你为了利用计算机上的多个处理器,写了一个并行程序,把一个很大的计算分成 16 份。如果每个进程都有一个公共部分——比如先要算一个质数表——你可以先写一个进程把质数表算出来,算出来以后再 fork。因为 fork 是状态机的完整复制,fork 出来的父子进程都可以直接访问这个共享的质数表。而且今天 Linux 系统上的 fork 实现是非常高效的——它没有像 CrazyOS 那样逐个字节拷贝。如果质数表是只读共享的,无论你创建一千个还是一万个子进程,那个大的质数表就只有一个副本,非常高效。
你们今天用的安卓手机,每一个应用在启动的时候都用了一个叫 Zygote Process 的机制。Android 的应用是用 Java 开发的——这是一个传奇故事:Android 能够在苹果围剿下立足的一个很大原因,就是因为市场上有大量的 Java 程序员。但 Java 有一个问题——你的 main 函数想要执行,可能要加载成百上千个类,每个类都需要初始化、verify bytecode,流程非常长。而第一台安卓手机基本上就几百兆赫兹的主频,如果有一个非常长的初始化序列,打开 App 就会非常卡。
Android 做的事情就是把公共部分——库的加载、Android 自己应用的初始化——全部做到一个叫 Zygote 的进程里。Zygote 是受精卵的意思,相当于干细胞。当你点击启动某个应用的时候,在 Linux 层面就会执行一个 fork,fork 完了以后 Zygote 不变,它可以不断分裂变成其他的应用——就像干细胞分化成心脏、骨骼一样,有的应用变成原神,有的变成计算器。这样就解决了应用启动很慢的问题。这是 Linux 给我们的遗产。
Fork-based DFS
还有一些比较酷的应用,比如基于 fork 的 DFS。你们都知道可以用 DFS 做搜索——比如探索迷宫。一个标准的 DFS 探索迷宫就是:走到 (x, y),探索上下左右四个方向,对每个方向递归做一次 DFS,在做之前把格子标记为走过,backtrack 回来后再标记成空。
如果你想并行化这个搜索算法,可以在每次探索时执行一次 fork。fork 完以后状态被完整继承——在一个 fork 里走这条路线,在另外一个 fork 里走那条路线,继续递归 DFS。这样你可以把一个 DFS 在几乎不做任何修改的情况下变成并行的。因为 fork 本质上就是状态的直接快照、完整复制。
如果你有一个 128 核的服务器,想要在什么问题求解课上跑一个跑不出来的搜索,就可以让 AI 帮你实现一个 fork-based DFS。
甚至我们还可以用 fork 做更疯狂的事情。比如你们实现了一个 HTTP 服务器,但有 bug——你可以每隔几秒钟做一个快照,像 Zygote 一样在那停着不提供服务,用你的实际进程提供服务。但如果程序出 bug crash 了,你再把那个快照偷偷拿过来提供服务,这样就算在响应某一个请求时出了 bug,整个系统服务也没有崩溃。这就是全量内存和完整程序状态快照给我们带来的 hack 空间。
4.5 Fork 的陷阱
当然 fork 也带来一些小麻烦。比如以前每年期中测验的时候都会考的题目——fork 会让你理解程序的行为变得比较困难。
比如说 for(int i = 0; i < 2; i++) fork(); printf("hello\n");,编译运行后一共打出了 6 个 hello。我们来分析一下为什么:
用状态机的视角。把循环展开:i=0, fork(); printf(); i=1, fork(); printf()。程序初始状态 pc=1,执行 i=0。pc=2 执行 fork——状态机复制,变成两个,都是 pc=3, i=0。执行第 3 步 printf——两个进程各打一个 hello(2 个)。然后 pc=4 执行 i=1。pc=5 又 fork——每个进程再复制一份,变成 4 个。执行第 6 步 printf——4 个 hello。2 + 4 = 6,和运行结果一致。
但是——如果你把输出重定向到管道(| wc),结果就不是 6 了!为什么呢?
这就涉及到 libc 的缓冲区。我在 CrazyOS 里的 putchar 是先打到 buffer 里的,libc 也一样——在终端的时候默认是 line buffer(碰到换行就 write),但如果标准输出是文件或管道,它会更激进地缓冲(比如 4KB 或 8KB 才写入一次)。
你们同学有没有过这样的经历:写一份代码,printf 之后 segmentation fault——如果你忘了打换行,你什么也看不到。你 1000% 确信 printf 执行了,但看不到输出。这是因为 libc 里面有一个缓冲区,没有 flush。
回到 fork 的问题——当 fork 复制状态的时候,libc 的缓冲区也被复制了。如果 hello 还在缓冲区里没有 flush,fork 的时候缓冲区被复制了两份,后面再各 flush 一次,hello 的数量就会翻倍。所以计算机世界里面是没有任何魔法的——虽然没有魔法但它有复杂的一面:你必须知道整个系统里面每一个部分的精确行为才能做出正确的判断。
从某种意义上来说,有百万上下文的 AI 在这方面超越人类是毫无疑问的——因为我们也是花了很长时间才训练出了这种解决系统问题的直觉,但 AI 只要能看到所有代码,且注意力足够好,它就知道这个地方其实是把 buffer 复制了一份。
4.6 Execve
Fork 解决了创建进程的问题,但没有解决我们一开始提出的"想要管理进程"那个原本的问题——我其实想要的是原生启动。如果系统里面只有 fork 一个系统调用,那只能 init fork 以后得到一个 init,再 fork 又得到一个 init……这个世界里永远只能有一个程序,因为 fork 是状态的完整复制,它根本实现不了 spawn——比如我 spawn 一个原神.exe,它做不到。
所以 UNIX 给了一个非常有意思的答案——除了 fork 之外,它还提供了另外一个系统调用叫 execve(execute,V 代表参数向量 vector,E 代表环境变量 environment)。
它的行为是什么呢?程序是一个状态机,C 语言程序的初始状态是 main 函数的 argc 和 argv 被调用。execve 做的事情是:如果任何一个进程执行 execve("a.out", ...),那么这个进程的状态就会被就地全部销毁,变成另外一个程序的初始状态。
所以从状态机的角度来理解,就是状态机的复位。程序开始跑,fork 以后状态复制了一份,到 execve 的时候就直接被替换成另外一个程序——复位成了另外一个程序。并且你可以给另外一个程序的 main 函数传递参数,还可以传递环境变量。
Main 函数的完全体
所以你们今天学操作系统以后会发现,main 函数的完全体是这样的:
ounter(lineint main(int argc, char *argv[], char *envp[])
argc 是 argument count,argv 是 argument vector。argv[0] 总是你的命令行调用——比如 ./a.out 1 2 3 的时候 argv[1] 就是 "1",argv[2] 就是 "2"。除此之外还有一个很重要的参数叫环境变量——也是在 main 函数里可以获取的。当然你不用这个参数也完全 OK。此外还有一个全局变量 environ,通过它也可以访问当前的环境变量。
在整个 UNIX 世界里面,execve 就是唯一能够执行程序的系统调用。所以你们也解开了一个困惑——如果你看 strace 任何一个程序,看到的第一条系统调用都是 execve。不管是 strace ls、strace true,还是自己链接的一个 minimum 程序,第一条系统调用都是 execve。
比如 strace ./a.out 1 2 3 4 hello,执行的第一条系统调用就是 execve("./a.out", ["./a.out", "1", "2", "3", "4", "hello"], ...)。后面还有环境变量——比如当前有 43 个环境变量。如果我加一个 A=B,环境变量就多了一个;再加 X=Y 就有 45 个。
环境变量
同样你可以用 env 命令看到当前所有的环境变量。环境变量会默认在 fork 的时候继承——execve 的时候你必须传一个环境变量参数。默认情况下如果你创建、复位一个状态机,操作系统内的资源其实是继承的(后面讲文件描述符的时候会讲)。环境变量也是默认继承的——比如你用 execl 这样的库函数来执行程序的话,它会继承环境变量。如果你的 OpenAI API key 在环境变量里,它就会泄露给子进程。所以把 API key 放在环境变量里是有一定风险的。
现在也有更好的管理 API key 的方式,不过很多人最早都是按教程在 .bashrc 里面 export OPENAI_API_KEY——这样你所有的应用程序都能用,但也比较危险。
环境变量有很多有意思的话题。比如 $PWD 是当前的 working directory,是一个环境变量。$TERM 可以知道当前是什么样的终端。再比如 $PATH 这个环境变量叫路径——你找任何一个可执行文件的时候都会从 PATH 的第一个目录开始找,找到就运行,没找到就找下一个。
比如我现在可以 gcc --version,但如果我把 PATH 变成 .,就找不到 gcc 了。如果我设置一个正确的 PATH,比如 /usr/bin,gcc 又可以正确工作了。
所以你看到,又回到最早上课时讲的 CrazyOS 的例子——操作系统会维护很多进程的状态。在进程自己看到的是 memory 和 register,但操作系统会维护 PID、环境变量等各种各样的信息。我们的应用程序就是通过系统调用等方式来访问整个操作系统里面的状态。所有应用的行为都是由这些状态定义的——计算机世界里是没有任何魔法的。
比如 gcc 的 strace——它真的就会按照 PATH 环境变量分隔的顺序,一个一个目录去试。先试第一个目录下有没有 as,返回 -1 找不到,再试下一个 /usr/local/bin,再试 /usr/local/sbin……一个一个试下来,直到 execve 成功为止。如果我把 PATH 改掉,比如只改成 /usr/bin,刚才那些所有的尝试就都没有了。它真的是从环境变量里读取出 PATH,做了路径分隔的解析,然后一个一个路径去试。
4.7 Exit
有了 fork 和 execve 以后,基本上就可以构造出一个任意进程的世界了——你可以无限复制自己然后启动另外一个程序,真的可以实现原生启动了。除此之外最后一个 API 就是状态机的销毁——exit。
Exit 所做的事情就像你做手术的时候全身麻醉——然后人就对这个世界没有任何感知了。Fork 复制,execve 重置执行,exit 让你从这个世界上消失了。
有这三个系统调用就构建出了整个 UNIX 世界上所有的进程——也就有了我们的 pstree。还有一个小的 demo code,大家可以回去运行一下,让 AI 帮你解释一下。