本篇位置 虚拟化 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>/ 是内核数据结构的一个文本视图,磁盘上没有它。这门课会一直用它当证人。

思考题

  1. 第三节的演示里,“一直在睡"的那个进程状态是 S。如果把它改成读一个非常慢的磁盘文件,状态会变成 D(不可中断睡眠)。为什么内核要把这两种"睡"分开?(提示:想想 kill -9 对它们分别有没有用。)
  2. 如果一台机器上所有进程都永不阻塞,操作系统还应不应该做分时?说出至少一个"仍然应该"的理由。
  3. 第五节说 /proc 是内核现场生成的。那么读 /proc/<pid>/stat 这个动作本身,会不会改变这个进程的状态?
  4. 菜谱这个比方里,“地址空间"对应锅和案板,“程序计数器"对应做到第几步。那么文件描述符表对应什么?

延伸