CPU Intro:进程与程序
进程与程序
程序本身是 lifeless(无生命) 的,它只是静静地躺在磁盘上,是一堆等待被执行的指令和静态数据。
进程(Process) 是一个 “running program”。操作系统负责把那些静止的字节跑起来,让它们变成有用的东西。
在操作系统眼中,每个进程都有自己独立的 机器状态(machine state):
- 内存:用于存储指令和数据。
- 寄存器:比如程序计数器(PC),记录当前运行到哪一条指令了。
但是我们知道,随便打开几个软件会有好几个进程,但是物理上的 CPU 是有限个。为了服务这些进程,我们需要一项被称为 CPU 虚拟化 (Virtualizing the CPU) 的技术。为了实现这样的目标,操作系统采用了一个非常经典的方法,叫做 “分时共享”。
这样的分时共享可以用下面的例子来理解:
♟️ 形象理解:分时共享与国际象棋大师 (点击展开)
我们可以想象一个超级快速的 国际象棋大师 同时在和 10 个人下棋:
- 大师走到第 1 个棋盘前,思考一下,走了一步。
- 然后他迅速跑到第 2 个棋盘前,走了一步。
- 接着是第 3 个……
只要大师的速度足够快,对于每一个对手来说,感觉就像大师一直在专心陪自己下棋一样。
代价: 分时共享的潜在代价是性能。如果 CPU 必须在多个进程之间共享,那么每个进程分到的时间片就会减少,运行起来自然会感觉到慢一些。
进程的生命周期
进程在运行过程中主要有三种状态:
- 运行 (Running):进程正在处理器上执行指令。
- 就绪 (Ready):进程其实已经准备好可以跑了,但是操作系统出于某些原因,决定暂时先不运行它。
- 阻塞 (Blocked):进程执行了某种操作(比如 I/O 请求),在等待结果期间它暂时无法运行。
当当前程序被操作系统设置为阻塞状态时,调度器会查看就绪列表,挑选另外的进程上台运行。
数据结构与上下文
给出下面一个情景:
想象一下,进程 A 正在计算
10 + 20,刚算到一半(比如把 10 读进来了),突然被操作系统叫停(切换到进程 B)。等轮到进程 A 再次运行时,它必须从 完全一样 的状态继续,不能把之前的计算忘了,也不能算错。
为了做到这一点,在切换 之前,操作系统必须把进程 A 的一些“关键现场信息”保存起来。为了让程序“无缝”恢复,操作系统必须保存 CPU 寄存器里的内容。
我们把这种“保存当前进程状态,恢复另一个进程状态”的过程称为 上下文切换 (Context Switch)。
为了管理成百上千个进程,操作系统会把所有的 struct proc 穿成一个长长的列表,叫做 进程列表。这样,操作系统只需要扫描这个列表,就知道谁在跑,谁在睡,谁在等。
在 xv6 操作系统的代码示例中,有一个用于记录进程状态的枚举变量:
1 | enum proc_state { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE }; |
其中的 ZOMBIE 用于保存进程结束之后的信息(例如 return code 之类)。它最后调用 wait 函数查看子进程是完成任务还是出错了,在确认之后由操作系统进行清理。
CPU-API
fork()
fork() 就像细胞分裂。当一个程序调用 fork() 时,操作系统会瞬间创建一个全新的进程,我们称之为子进程,相应的原来的进程我们称之为父进程。
它们有相同的代码,相同的数据,甚至此时此刻执行到了同一行。
但我们需要注意:一次调用,两次返回。
在父进程中,fork() 返回创建的子进程的 PID。
在子进程中,fork() 返回 0。
正是通过检查这个返回值,我们可以知道程序到底是“本体”还是“克隆体”。
有个有趣的问题是,当 fork() 完成的一瞬间,我们无法确定是父进程先执行下一行代码,还是子进程先执行下一行代码,这取决于 CPU 调度器。这带来了一个麻烦:如果我们希望父进程必须等子进程把活干完,这种随机性就是灾难。
这时候,我们需要用到第二个关键函数。
wait()
wait() 函数的作用就和它的名字一样简单:让父进程停下来等待。
当父进程调用 wait() 时,它会把自己 “挂起”,不再占用 CPU。只有当子进程运行结束后,wait() 才会返回,父进程才会继续往下运行。
这样就解决了谁先执行的问题,保证了父进程一定在子进程之后执行。
exec()
exec() 并没有创建新的进程,而是直接替换了当前进程的内容。
操作系统会从磁盘加载新的程序,用它的代码段、静态数据、堆栈完全覆盖掉原来的程序。但它会保留 PID 和资源等,只是替换了运行代码。
一旦 exec() 成功执行,它就永远不会返回了,源程序的后续代码会直接被“抹杀”。
Unix 哲学:fork 与 exec 的分离
🤔 思考:为什么不直接搞一个 spawn() 函数?
你可能会问:
“为什么我们不直接搞一个 spawn(program_name) 函数,一步到位创建并运行新的程序,而非要先复制自身,然后再自杀??这不是浪费资源吗?”
在 fork() 之后,但在 exec() 之前,Shell 有机会在子进程里偷偷运行一小段代码,用于改变子进程的环境,从而实现类似 重定向 和 管道 等功能。
🪄 魔法揭秘:Shell 输出重定向是如何实现的?
如果在 Shell 里输入命令 wc p3.c > newfile.txt,Shell 是如何做到不修改 wc 程序的代码,却能神奇地把 wc 的输出结果从“屏幕”转移到“文件”里的呢?
Shell 通过进程创建、文件描述符重定向和标准输出重定向实现这一功能,整过程无需修改 wc 程序的代码。
关键机制
-
进程创建与标准流
- Shell 解析命令后,通过
fork()创建子进程 - 每个进程默认打开三个标准流:
- 标准输入(stdin,文件描述符 0)
- 标准输出(stdout,文件描述符 1)
- 标准错误(stderr,文件描述符 2)
- 默认情况下,标准输出指向终端设备(屏幕)
- Shell 解析命令后,通过
-
重定向操作步骤
- Shell 在执行
wc前识别到>重定向符 - 打开(或创建)
newfile.txt,获得新文件描述符 - 使用
dup2()系统调用,将新文件的描述符复制到标准输出(文件描符 1)的位置 - 文件描述符 1 现在指向
newfile.txt,而非屏幕
- Shell 在执行
-
程序执行
wc程序启动,它只负责向文件描述符 1 写入数据- 程序不知道也不关心描述符 1 指向哪里
- 由于 Shell 已提前重定向,输出自然写入文件
技术原理
1 | // Shell 内部执行类似以下操作: |
设计哲学
- 关注点分离:程序只负责"写什么",Shell 负责"写到哪里"
- 通用性:所有遵循 Unix 标准(读写文件描述符 0/1/2)的程序都支持重向
- 透明性:程序无需为支持重定向做特殊编码
扩展应用
- 输入重定向
<:类似原理,重定向标准输入(文件描述符 0) - 错误重定向
2>:重定向标准错误(文件描述符 2) - 管道
|:将一个程序的标准输出连接到另一个程序的标准输入
正是 Unix/Linux 的文件描述符机制和 Shell 的进程管理能力,使得这种"神奇"的重定向成为可能。