0%

Virtualization

CPU Intro:进程与程序

进程与程序

程序本身是 lifeless(无生命) 的,它只是静静地躺在磁盘上,是一堆等待被执行的指令和静态数据。

进程(Process) 是一个 “running program”。操作系统负责把那些静止的字节跑起来,让它们变成有用的东西。

在操作系统眼中,每个进程都有自己独立的 机器状态(machine state)

  • 内存:用于存储指令和数据。
  • 寄存器:比如程序计数器(PC),记录当前运行到哪一条指令了。

但是我们知道,随便打开几个软件会有好几个进程,但是物理上的 CPU 是有限个。为了服务这些进程,我们需要一项被称为 CPU 虚拟化 (Virtualizing the CPU) 的技术。为了实现这样的目标,操作系统采用了一个非常经典的方法,叫做 “分时共享”

这样的分时共享可以用下面的例子来理解:

♟️ 形象理解:分时共享与国际象棋大师 (点击展开)

我们可以想象一个超级快速的 国际象棋大师 同时在和 10 个人下棋:

  1. 大师走到第 1 个棋盘前,思考一下,走了一步。
  2. 然后他迅速跑到第 2 个棋盘前,走了一步。
  3. 接着是第 3 个……

只要大师的速度足够快,对于每一个对手来说,感觉就像大师一直在专心陪自己下棋一样。

代价: 分时共享的潜在代价是性能。如果 CPU 必须在多个进程之间共享,那么每个进程分到的时间片就会减少,运行起来自然会感觉到慢一些。


进程的生命周期

进程在运行过程中主要有三种状态:

  1. 运行 (Running):进程正在处理器上执行指令。
  2. 就绪 (Ready):进程其实已经准备好可以跑了,但是操作系统出于某些原因,决定暂时先不运行它。
  3. 阻塞 (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 哲学:forkexec 的分离

🤔 思考:为什么不直接搞一个 spawn() 函数?

你可能会问:

“为什么我们不直接搞一个 spawn(program_name) 函数,一步到位创建并运行新的程序,而非要先复制自身,然后再自杀??这不是浪费资源吗?”

这种将 fork() 和 exec() 分离的设计,正是 Unix Shell 如此强大的秘密所在。

fork() 之后,但在 exec() 之前,Shell 有机会在子进程里偷偷运行一小段代码,用于改变子进程的环境,从而实现类似 重定向 和 管道 等功能。

🪄 魔法揭秘:Shell 输出重定向是如何实现的?

如果在 Shell 里输入命令 wc p3.c > newfile.txt,Shell 是如何做到不修改 wc 程序的代码,却能神奇地把 wc 的输出结果从“屏幕”转移到“文件”里的呢?

Shell 通过进程创建、文件描述符重定向和标准输出重定向实现这一功能,整过程无需修改 wc 程序的代码。

关键机制

  1. 进程创建与标准流

    • Shell 解析命令后,通过 fork() 创建子进程
    • 每个进程默认打开三个标准流:
      • 标准输入(stdin,文件描述符 0)
      • 标准输出(stdout,文件描述符 1)
      • 标准错误(stderr,文件描述符 2)
    • 默认情况下,标准输出指向终端设备(屏幕)
  2. 重定向操作步骤

    • Shell 在执行 wc 前识别到 > 重定向符
    • 打开(或创建)newfile.txt,获得新文件描述符
    • 使用 dup2() 系统调用,将新文件的描述符复制到标准输出(文件描符 1)的位置
    • 文件描述符 1 现在指向 newfile.txt,而非屏幕
  3. 程序执行

    • wc 程序启动,它只负责向文件描述符 1 写入数据
    • 程序不知道也不关心描述符 1 指向哪里
    • 由于 Shell 已提前重定向,输出自然写入文件

技术原理

1
2
3
4
5
6
7
8
// Shell 内部执行类似以下操作:
pid = fork(); // 创建子进程
if (pid == 0) { // 子进程中
int fd = open("newfile.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO); // 将标准输出重定向到文件
close(fd); // 关闭多余的文件描述符
execvp("wc", args); // 执行 wc 程序
}

设计哲学

  • 关注点分离:程序只负责"写什么",Shell 负责"写到哪里"
  • 通用性:所有遵循 Unix 标准(读写文件描述符 0/1/2)的程序都支持重向
  • 透明性:程序无需为支持重定向做特殊编码

扩展应用

  • 输入重定向 <:类似原理,重定向标准输入(文件描述符 0)
  • 错误重定向 2>:重定向标准错误(文件描述符 2)
  • 管道 |:将一个程序的标准输出连接到另一个程序的标准输入

正是 Unix/Linux 的文件描述符机制和 Shell 的进程管理能力,使得这种"神奇"的重定向成为可能。