| 本篇位置 | 虚拟化 CPU 的第一篇。先说清「一个进程是什么」,第 4 篇才讲「怎么切换」 |
| 运行环境 | 容器里的 Linux(见总览页),Python 3 |
一、程序和进程不是一回事
你机器上装着一个 python3。它是磁盘上的一个文件,大概二十多兆。
现在你开三个终端,每个都跑 python3。磁盘上还是那一个文件,但机器上有了三个互不相干的东西——你在第一个里定义的变量,另外两个看不见。
所以"程序"和"进程"是两个词:
程序是磁盘上的一坨字节,是死的。 进程是这坨字节正在被执行的一次,是活的。
一个程序可以同时有很多个进程,就像一份菜谱可以同时有很多人照着做。
打个比方
程序是菜谱,进程是"某个人此刻正在照着这份菜谱做菜"这件事。
这个比方比听起来有用,因为它顺手回答了好几个问题:
- 同一份菜谱能不能好几个人同时做? 能,各做各的。他们共用菜谱(程序),但各有各的锅、案板、半成品(进程状态)。
- 做到一半能不能停下来? 能。但你得记住"我做到第几步了、锅里现在是什么"——这堆记录就是进程的核心。
- 等水烧开的时候你干什么? 你会去切菜。这件小事,正是这一篇最后要讲的重点。
二、一个进程由什么构成
要回答"进程是什么",问一个更实际的问题:如果我现在要把一个进程冻住,过一会儿原样恢复,我得存下哪些东西?
答案就是进程的定义。存下来的这些东西,OSTEP 里叫机器状态(machine state):
一、地址空间。 它的代码、全局变量、堆、栈,全在这里。这是它"以为自己独占"的那块内存(第 1 篇的储物柜 3 号)。这块最大,第二部分整整八篇都在讲它。
二、寄存器。 尤其是三个:
- 程序计数器(PC)——下一条要执行的指令在哪。就是菜谱上那个"我做到第几步了"。
- 栈指针(SP)——当前函数调用栈的顶在哪。
- 通用寄存器——手头正在算的中间值。锅里现在是什么。
三、它打开了哪些东西。 文件描述符表:0 号是标准输入、1 号是标准输出、2 号是标准错误,还有它自己 open 过的文件、连上的网络套接字。
⭐ 把这三样存下来,这个进程就能在任何时刻被冻住、再恢复。这就是"把一个 CPU 变成很多个"的全部本钱——切换的时候把 A 的这三样收好,把 B 的这三样铺开,CPU 就"变成"了 B 的。
三、跑一遍看看:进程是有状态的
上面说的是"能被冻住"。但进程被冻住的原因不止一种,所以内核给每个进程记了一个状态。
Linux 把它写在 /proc/<pid>/stat 里,一个字符。我们造两个进程——一个死命算,一个一直睡——然后去问内核它们各自是什么状态:
# state.py
import os, sys, time
def state_of(pid):
d = open(f"/proc/{pid}/stat").read()
return d[d.rindex(")") + 2] # comm 里可能有空格,从最后一个 ) 之后数
kids = {}
pid = os.fork()
if pid == 0:
x = 0
for i in range(500_000_000): x += i
os._exit(0)
kids["一直在算"] = pid
pid = os.fork()
if pid == 0:
time.sleep(30)
os._exit(0)
kids["一直在睡"] = pid
time.sleep(0.3)
for _ in range(4):
print(" ".join(f"{n} pid={p} 状态={state_of(p)}" for n, p in kids.items()), flush=True)
time.sleep(0.5)
for p in kids.values():
os.kill(p, 9); os.waitpid(p, 0)
一直在算 pid=7 状态=R 一直在睡 pid=8 状态=S
一直在算 pid=7 状态=R 一直在睡 pid=8 状态=S
一直在算 pid=7 状态=R 一直在睡 pid=8 状态=S
一直在算 pid=7 状态=R 一直在睡 pid=8 状态=S
这不是我判断的,是内核报的。 R 是 running/runnable,S 是 sleeping。
两个进程都"活着",但活着的方式完全不同:
R的那个想要 CPU。给它就用。S的那个不想要 CPU。你现在把整台机器都给它,它也用不上——它在等一件还没发生的事。
四、三种状态,和那条最关键的边
课本上的三态图,说的就是这件事:
被调度器选中
就绪 ─────────────────▶ 运行
▲ │
│ 时间片用完 / 被抢占 │
└───────────────────────┘
▲ │
│ │ 发起 I/O、等锁、sleep
│ 事件完成了 ▼
└──────────────────── 阻塞
- 运行(running):正在某个核上执行。
- 就绪(ready):随时能跑,只是还没轮到。
- 阻塞(blocked):在等一件事——磁盘读完、网络包到达、锁被释放。在这件事发生之前,给它 CPU 是浪费。
⚠️ 一个常见的误解:以为进程"暂停"就是被调度器抢走了。不是。上面那两条向下的边完全不同:
- 时间片用完 → 掉回就绪。它还想跑,只是被打断了。
- 发起 I/O → 掉进阻塞。它自己主动不跑了。
分清这两条边很重要,因为第二条边才是整件事划算的原因。
⭐ 阻塞才是虚拟化成立的理由
假设机器上所有程序都是纯计算,永不阻塞。那分时有什么意义?总的活儿不会变少,来回切换反而白白多花时间——你只会更慢。
但真实的程序不是这样。它们大部分时间在等:等你敲键盘、等磁盘、等网络。一次磁盘读要几毫秒,同样的时间 CPU 能执行上千万条指令。
如果不切换,这上千万条指令的时间就白白烧掉了。
回到菜谱那个比方:分时之所以划算,正是因为水烧开要五分钟,而你在这五分钟里可以去切菜。要是做菜的每一步都得你死盯着,同时做两道菜就只会更慢——你会一直在两个灶台之间跑来跑去。
五、内核那边存着什么
对每个进程,内核要维护上面说的那一整套。这份记录课本上叫 PCB(进程控制块),Linux 里的实现叫 task_struct。
你能直接看到它的一部分——/proc/<pid>/ 这个目录里的每一项,都是内核把 task_struct 的某个字段翻译成文件给你看:
| 你看到的 | 内核那边是什么 |
|---|---|
/proc/<pid>/stat |
状态、优先级、用了多少 CPU 时间 |
/proc/<pid>/maps |
地址空间的布局(第 8 篇会盯着它看很久) |
/proc/<pid>/fd/ |
打开的文件描述符表 |
/proc/<pid>/status |
上面这些的人话版 |
⭐ /proc 不是一个真的目录,磁盘上没有它。你 cat 它的时候,内核现场把数据结构格式化成文本给你。这门课后面会反复用它——它是我们能让内核自己说话的主要途径。
六、代价与取舍
这套设计换来了什么: 你写程序时可以完全不考虑机器上还有谁。第 1 篇说的"不用认识另外五个租客",具体就是靠这一层。
它花了什么:
- 每个进程都要占一份内核内存存
task_struct和页表。所以进程不是免费的——你不能开十万个。第 16 篇的线程,一半动机就是想让这份开销小一点。 - 切换要花时间。 存三样、取三样,还要清缓存。这笔账第 4 篇细算。
它放弃了什么: 进程之间默认什么都不共享。这是安全,但也是麻烦——两个进程想一起干活,就得走管道、共享内存、套接字这些额外的路。这个取舍会一直跟到第三部分。
七、小结
- 程序是磁盘上的字节,进程是它正在被执行的一次。 一个程序可以有很多个进程。
- 一个进程 = 地址空间 + 寄存器(尤其是 PC 和 SP)+ 打开的文件。存下这三样就能冻住它、再恢复它——虚拟化 CPU 的全部本钱在此。
- 进程有状态:运行 / 就绪 / 阻塞。“时间片用完掉回就绪"和"等 I/O 掉进阻塞"是两条完全不同的边。
- ⭐ 阻塞才是分时划算的原因。 如果没有程序会停下来等,切换只会让总时间更长。
/proc/<pid>/是内核数据结构的一个文本视图,磁盘上没有它。这门课会一直用它当证人。
思考题
- 第三节的演示里,“一直在睡"的那个进程状态是
S。如果把它改成读一个非常慢的磁盘文件,状态会变成D(不可中断睡眠)。为什么内核要把这两种"睡"分开?(提示:想想kill -9对它们分别有没有用。) - 如果一台机器上所有进程都永不阻塞,操作系统还应不应该做分时?说出至少一个"仍然应该"的理由。
- 第五节说
/proc是内核现场生成的。那么读/proc/<pid>/stat这个动作本身,会不会改变这个进程的状态? - 菜谱这个比方里,“地址空间"对应锅和案板,“程序计数器"对应做到第几步。那么文件描述符表对应什么?