这一章我们聚焦到进程上。先把 “进程到底是什么”、以及它手里握着哪些内存(地址空间)讲清楚,再看它从生到死会经历哪些状态;之后是 OS 用来记录一个进程的数据结构,也就是 PCB、进程表和进程队列;最后展开进程操作,以及进程之间互相通知用的信号。


什么是进程?

我们上一章讲过了,Process = A program in execution。程序本身是无生命的,只是放在硬盘里的一堆命令和数据。进程就是这些内容真正运行起来的实例。

但在这里我们要把它拆开来看:一个进程不只是一段正在执行的代码。具体来说,一个进程是一个能够被分配给 CPU 并执行的实例,由三个部分组成:

  • 一段 Instruction Sequence 的执行:真正在跑的代码
  • 一个 Execution State:代码执行到什么程度
  • 一组 Associated System Resources:这个执行过程分配到的资源

Process = Program Code + 执行位置 + 执行状态 + CPU寄存器内容 + 正在使用的内存 + 正在使用的IO + 其他系统资源


为什么要存这些内容?设想 OS 决定暂停一个进程,过后还能不能让它从原来的位置接着跑?如果 OS 只记了一句 “这个程序跑过”,那对恢复进程来说一点用都没有。

至少,OS 得知道下面这些信息:

  • 下一条指令在哪?
  • 刚才CPU寄存器里面是什么?
  • Stack Pointer 在哪?
  • 目前分配给我了哪里的内存位置?
  • 目前进程是什么State?

说白了,这就是给进程打了个存档。那么存档里该放什么,才能让进程这场游戏继续下去?这个一会再说,我们先来看地址空间。


地址空间

地址空间 (Address Space),是进程对自己分配到的内存的视图。也就是当前进程的 “整个可操作内存空间”。

一个典型的进程地址空间由下面的结构组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
[高地址]
┌──────────────┐
│ Stack │
│ ↓ │
│ │
│ ↑ │
│ Heap │
├──────────────┤
│ Data Segment │
├──────────────┤
│ Text Segment │
└──────────────┘
[低地址]

它们分别做什么?

  • Text Segment:存储程序的代码,也就是CPU要执行的指令。例如你写一段代码:
1
2
3
int add(int a, int b) {
return a + b;
}

编译之后对应的机器码,就属于 Text Segment。

  • Data Segment:存储全局变量和常量的地方。如果你在程序中定义:
1
2
int global_count = 10;
static int x = 5;

这种变量的生命周期通常和整个进程接近,不是某个函数调用完之后就被回收。因此,我们会将其存在这样一个特殊的位置。

  • Heap:这里存储动态分配并使用的内存。

这个可能有点难理解,不过假如你在 C 里面写 malloc(),或者在 C++ 里写 new ...,这样动态申请到的内存,通常就存在堆里。

堆里面存储的信息一般都是 程序运行过程中主动申请,主动管理的动态空间

  • Stack:存储局部变量,函数形参,返回值,或者其他临时需要持续使用的内容。

例如:

1
2
3
4
int foo(int x) {
int y = x + 1;
return y;
}

那么这次函数调用里的 x、y,以及 return 需要用到的一堆信息,都会跟 stack 扯上关系。

* stack 是从高地址往下长,heap 从低地址往上长。不过目前我们重点不在这里,知道就好。


一句话总结一下,确实挺有用:

1
2
3
4
5
6
7
8
9
10
11
Text
→ 代码

Data
→ global/static data

Heap
→ dynamic memory

Stack
→ 当前函数调用相关内容

进程状态

在 OS 眼中,一个进程就是 “正在运行的应用程序” 的完整运行时抽象,这一点我们前面说过。

具象化来说,看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Process

├─ Program code
├─ Address space
│ ├─ Text
│ ├─ Data
│ ├─ Heap
│ └─ Stack

├─ CPU execution context
│ ├─ Program Counter
│ ├─ Stack Pointer
│ └─ Registers

├─ Current process state

└─ Resources in use
├─ Memory
├─ I/O
└─ ...

其中 Process State 就是我们接下来要关注的进程状态。

一个进程从创建到结束,不会永远待在一个状态里。这些状态标识能帮 OS 的调度器决定接下来该调度谁。

课上给了五个状态:newreadyrunningblockedterminated。先从最关键的一个开始看:

  • running 表示这个进程 此刻真的在 CPU 上执行
  • ready 表示 万事俱备,只差 CPU。要跑的代码有了,内存也分配好了,需要的基本 state 也都有了,只是 CPU 现在还在处理别的任务,这就是 ready 的含义。
  • blocked 就完全不一样了,就算现在白送你一个 CPU,你也跑不下去。如果进程 A 需要从硬盘读取一个文件,但 I/O 还没完成,而 A 后面的代码又依赖 I/O 的结果,那这时给 A 一个 CPU 也没用。所以 blocked 一般表示进程正在等一个它自己无法推进的事件。
  • terminated 表示这个进程已经结束运行。

一个新进程被创建出来、相关结构准备好之后,state 会从 new 变成 ready,这时调度器就可以选它了。

如果调度器选中了这个进程,ready 就变成 running。但如果这时 OS 决定 CPU 该让给别人,running 会退回 ready,也就是把这个进程放回 ready 的书架上,明明还能继续算,但 OS 说:“你的 CPU 时间到了,先回去排队。”

如果 running 的进程碰到了必须等的东西,running 就会变成 blocked。这个进程会被放进 blocked 队列,等它要的条件满足之后再回到 ready

至于为什么不是 blocked 直接变成 running?因为 I/O 结束代表的是 “你现在有资格跑了”,并不代表 CPU 马上就归你。所以要先回到 ready 队列,让调度器来定。

关于 terminated,值得多说两句:进程都跑完了,为什么不直接删掉?因为 terminated 之后,父进程还要能检查这个子进程的返回值,看看它是不是正常结束。

1
2
3
4
5
6
7
8
9
10
11
Child:
“我执行完了,exit code = 0。”

OS:
“好,我先保留你的结果。”

Parent:
“我的child执行成功没?”

OS:
“0,成功。”

PCB

既然 OS 需要存这些信息,那该存在哪?

进程控制块 (Process Control Block, PCB) 就是存放这份进程档案的数据结构。它是 OS 为了管理这个进程必须记住的元数据,也是之后用来恢复进程的上下文信息。

PCB 里面的内容巨多无比,比如 Linux 的 PCB,一个 struct 就大概有 800 多行。不过总体而言,里面存的是四大类数据:

  1. 你是什么进程?
1
2
3
PID
parent pointer
child pointers

PID 全称 Process ID,也就是这个进程的唯一 ID。parent pointer 和 child pointer 维护着这个进程的父进程、子进程结构。关于进程树,一会会讲到。


  1. 现在执行到哪了?
1
2
Program Counter (PC)
Register Context

这些是进程上一次离开 running 时,CPU 寄存器全部内容的一次快照。OS 就是靠这些信息来做进程切换和恢复的。

即便正在运行的进程 A 被切走,之后 OS 想重新运行 A 时也能恢复,因为我们把 A 的快照存进了 PCB。


  1. 你目前是什么状态?调度器该怎么理解你这个进程?
1
2
3
current process state
priority
scheduling queue pointers

state 我们前面说过,这里的优先级和队列指针,则决定在同级情况下调度器先挑谁、后挑谁。


  1. 你拥有什么?
1
2
3
4
Credentials
Memory management information
Accounting information
Allocated resources

例如 Credentials 决定这个进程有权访问哪些资源;Memory Management Information 记录它的内存区域;Accounting Information 记录 CPU 使用情况、执行时间上限之类的统计信息。


总之你会发现,OS 对进程的抽象基本就落在 PCB 这一块。


进程表

OS 运行时可能有上千个进程,每个进程都有一个 PCB。总不能让 OS 每次找一个具体的 PID 都从头往后扫一遍吧。

所以 OS 需要一个能快速找到 PCB 的结构,这就是 进程表 (Process Table)。一般来说进程表都是用哈希表维护的,能让 OS 指哪打哪。


进程队列

前面提到过,会有很多进程同时处于 ready / blocked 状态,那这些进程是不是得维护起来?

要的。OS 会维护 Ready List、Blocked List 之类的队列,里面存的是指向各个进程 PCB 的指针。

所以现在,画个图吧:

1
2
3
4
5
6
7
8
9
10
11
              Ready List
┌→ PCB_A
├→ PCB_C
└→ PCB_F

CPU Core 0
Current → PCB_B

Blocked List
┌→ PCB_D
└→ PCB_E

如果调度器想要换人,比如现在跑的是 B,那就先把 B 的状态改回 Ready,放回 Ready List;再从 Ready List 里挑一个,比如 A,把它的状态设成 Running,于是 Current 就变成 A 了。


进程操作

进程之间不是一群互不相关的孤岛:它们之间有上下级、父子关系;同时 OS 还要决定谁先谁后。

所以 OS 需要提供一组操作进程的基本手段,比如创建 / 销毁 / 暂停继续 / 改变优先级等等,才管得过来。

创建进程

一个进程是怎么创建出来的?

一个进程能够创建另一个进程,其中,创建者叫父进程,新创建的叫子进程。而子进程也能自己创建自己的子进程。因此,我们会自然形成一个 进程树 (Process Tree)

1
2
3
4
5
Parent
├── Child A
│ ├── Child A1
│ └── Child A2
└── Child B

还记得之前 PCB 里的父指针和子指针吗?就是用在这了。

现代 OS 里如果 parent 先终止,child 通常不一定跟着一起死,而是可以继续独立运行。但如果父进程已经结束,就没人再管这些子进程的回收了,所以它们往往会被系统的根进程收养,由它来清理。

至于什么是僵尸进程,一会会说。


创建一个进程,在 OS 底层要做什么?答案是,OS 一般要准备一整套运行环境。课件上写的是:

1
2
3
4
5
6
7
8
9
10
11
Allocate memory
Allocate PCB
Initialize PCB
Assign PID
Store PID / parent ID
Set program counter
Set stack pointer
Create other structures
Memory / files / accounting
Set state = Ready
Put into Ready Queue

本质上我们可以理解为这样一个流程的构造:Program Execution Context + Address space + PCB + Resources + Scheduling state。


在 Unix 中,有一对很经典的组合:fork()exec(),它们都是 system call。

fork() 会在调用时复制一份调用它的进程,从而造出一个新进程:

1
2
3
Parent Process
↓ fork()
Parent + Child

也就是说,子进程一开始就是调用者的一份拷贝。

但很多时候,我们造子进程并不是为了让它一直跑同一份程序。于是就有了 exec():OS 会把当前进程的镜像替换成另一个程序的镜像,简单说就是把这个进程正在跑的程序整个换掉。在 Unix 类系统中,一个很常见的玩法就是:

1
2
3
4
5
Parent
↓ fork()
Child
↓ exec(new program)
Child开始执行另一个program

终止进程

最常见的情况是进程自己执行完,告诉 OS 我跑完了。但进程也不一定都是自愿结束的:比如父进程送来停止信号,程序出了问题,或者卡了 bug,都会让它跑不下去。

一个子进程结束之后,会给父进程留下终止状态。父进程有时会想知道 “我这个子进程到底跑通没有?”,这时它就可以通过 wait()waitpid() 之类的调用来获取这些信息。


僵尸进程

在 Unix 中,一个进程进入 terminated 状态后,就可以被称为僵尸进程。虽然它已经退出,但 OS 依旧保留着它的 PCB,档案还没销毁,好让父进程之后还能抓取相关信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Child:
“我执行完了,exit status = 0。”

OS:
“好,我先留着你的PCB。”

Parent:
waitpid(...)

OS:
“这是child的exit status。”


现在没用了

彻底删除PCB

当父进程决定清理僵尸进程时,就可以通过 waitpid() 之类的接口通知 OS:可以把这个僵尸子进程彻底移除了。


挂起进程

suspend 是另一个状态,用来表示外部如何暂时冻结一个进程。

当一个进程被 suspend 之后,调度器就暂时不考虑调度它。这个和之前的 blocked 很像,但有一些区别。

blocked 是进程自己被内部活动卡住,现在没条件继续;而 suspend 是外部把它停掉的——它本身其实还跑得动。

为什么要 suspend 有很多理由,比如用户的要求、父进程的要求,或者 OS 想腾内存,于是挑一个 blocked 进程挂起,把它占的内存让给另一个 ready 进程。


要知道的是,挂起状态可以叠在 ready 和 blocked 之上,于是就有了 Suspend Ready 和 Suspended Blocked。

Suspend Ready 本来逻辑上已经 ready、可以运行,只是因为被 suspend,所以 scheduler 不会让它跑。

Suspended Blocked 则是进程本来就在等事件或资源,同时又被人 suspend 了,也就是 “我本来就跑不了,而且还被外部冻结了”。


一个挂起的进程,只能由别的进程或 OS 来决定它什么时候离开挂起状态。如果它自己就能决定 “我解除挂起了”,那这就坏菜了:一个被外部冻结的进程,本来就不该有自己解冻的能力。


信号

信号 (Signals) 是 UNIX 系统用来通知一个进程 “某个事件已经发生了” 的机制,有时候也被叫做 Software interrupt。

课上列了一些跟信号相关的 system call,例如:

1
2
3
4
5
6
kill()
signal()
sigaction()
raise()
pause()
sigsuspend()

其中 kill() 本身并不是一个 “把进程干掉” 的函数,它只是发出一个通知。真正让进程消失的,是收到这个通知的某个部件做出的默认行为。

每个通知都带编号,比如 SIGINT 的 value = 2,会在 Ctrl-C 时产生;SIGCHLD 的 value = 17,会在 child process 结束时产生。不同的 signal 代表不同的事件。


信号一共有两类:

Synchronous Signal

同步信号 (Synchronous Signal) 由进程自己正在执行的指令触发,并由 OS 立即送回给这个进程。例如非法内存访问,或者除以零这些自找的问题。

也就是说,A 进程自己执行了一条有问题的指令,硬件或 OS 发现有异常,随后 OS 给 A 一个对应的信号。


Asynchronous Signal

异步信号 (Asynchronous Signal) 就不是自己导致的了,是由外部事件或活动产生的。

例如最经典的一种:父进程决定终止子进程。

Synchronous = “我自己刚刚干了件事,于是触发”,Asynchronous = “外面突然有件事通知到我”。


处理信号

那一个进程收到信号之后,该怎么处理?

如果一个进程提前注册了某个通知的处理方法,这就叫做 Catch。例如,进程注册了一个:

1
2
3
void handler(int sig) {
...
}

然后通过 signal() 来告诉 OS,如果以后这个信号来了,就调用这个函数。

当然,程序也可以选择 Ignore:忽略信号,告诉 OS 我不想处理它。

Mask 更像是先把信号挡住存起来,之后再一并处理。


几乎每个信号进程都能处理,但有两个例外:SIGKILLSIGSTOP 不能被 catch / block / ignore。试想一下,如果进程可以随便忽略 SIGKILL:

OS:“结束。”

进程:“不要。”

这对吗?


PCB 与信号处理

PCB 中包含一个指向 信号处理方法矩阵 (Signal Handler Vector) 的指针。这个矩阵按信号编号组织,每一行对应的就是这个信号的处理方法。

1
2
3
4
5
6
7
8
9
PCB

Signal Handler Vector

SIGINT → handler_A
SIGTERM → default
SIGUSR1 → handler_B
SIGCHLD → handler_C
...

OS 收到某个信号后,就能按编号找到对应的处理方法。但如果这个进程之后调用了 exec()、换成了新的程序镜像,这个信号处理方法矩阵也会被重置——毕竟没道理把上一个程序的实现带到新程序里来。


至此,这章就结束啦。