这一章我们聚焦到进程上。先把 “进程到底是什么”、以及它手里握着哪些内存(地址空间)讲清楚,再看它从生到死会经历哪些状态;之后是 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 | [高地址] |
它们分别做什么?
- Text Segment:存储程序的代码,也就是CPU要执行的指令。例如你写一段代码:
1 | int add(int a, int b) { |
编译之后对应的机器码,就属于 Text Segment。
- Data Segment:存储全局变量和常量的地方。如果你在程序中定义:
1 | int global_count = 10; |
这种变量的生命周期通常和整个进程接近,不是某个函数调用完之后就被回收。因此,我们会将其存在这样一个特殊的位置。
- Heap:这里存储动态分配并使用的内存。
这个可能有点难理解,不过假如你在 C 里面写 malloc(),或者在 C++ 里写 new ...,这样动态申请到的内存,通常就存在堆里。
堆里面存储的信息一般都是 程序运行过程中主动申请,主动管理的动态空间。
- Stack:存储局部变量,函数形参,返回值,或者其他临时需要持续使用的内容。
例如:
1 | int foo(int x) { |
那么这次函数调用里的 x、y,以及 return 需要用到的一堆信息,都会跟 stack 扯上关系。
* stack 是从高地址往下长,heap 从低地址往上长。不过目前我们重点不在这里,知道就好。
一句话总结一下,确实挺有用:
1 | Text |
进程状态
在 OS 眼中,一个进程就是 “正在运行的应用程序” 的完整运行时抽象,这一点我们前面说过。
具象化来说,看起来是这样的:
1 | Process |
其中 Process State 就是我们接下来要关注的进程状态。
一个进程从创建到结束,不会永远待在一个状态里。这些状态标识能帮 OS 的调度器决定接下来该调度谁。
课上给了五个状态:new、ready、running、blocked 和 terminated。先从最关键的一个开始看:
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 | Child: |
PCB
既然 OS 需要存这些信息,那该存在哪?
进程控制块 (Process Control Block, PCB) 就是存放这份进程档案的数据结构。它是 OS 为了管理这个进程必须记住的元数据,也是之后用来恢复进程的上下文信息。
PCB 里面的内容巨多无比,比如 Linux 的 PCB,一个 struct 就大概有 800 多行。不过总体而言,里面存的是四大类数据:
- 你是什么进程?
1 | PID |
PID 全称 Process ID,也就是这个进程的唯一 ID。parent pointer 和 child pointer 维护着这个进程的父进程、子进程结构。关于进程树,一会会讲到。
- 现在执行到哪了?
1 | Program Counter (PC) |
这些是进程上一次离开 running 时,CPU 寄存器全部内容的一次快照。OS 就是靠这些信息来做进程切换和恢复的。
即便正在运行的进程 A 被切走,之后 OS 想重新运行 A 时也能恢复,因为我们把 A 的快照存进了 PCB。
- 你目前是什么状态?调度器该怎么理解你这个进程?
1 | current process state |
state 我们前面说过,这里的优先级和队列指针,则决定在同级情况下调度器先挑谁、后挑谁。
- 你拥有什么?
1 | Credentials |
例如 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 | Ready List |
如果调度器想要换人,比如现在跑的是 B,那就先把 B 的状态改回 Ready,放回 Ready List;再从 Ready List 里挑一个,比如 A,把它的状态设成 Running,于是 Current 就变成 A 了。
进程操作
进程之间不是一群互不相关的孤岛:它们之间有上下级、父子关系;同时 OS 还要决定谁先谁后。
所以 OS 需要提供一组操作进程的基本手段,比如创建 / 销毁 / 暂停继续 / 改变优先级等等,才管得过来。
创建进程
一个进程是怎么创建出来的?
一个进程能够创建另一个进程,其中,创建者叫父进程,新创建的叫子进程。而子进程也能自己创建自己的子进程。因此,我们会自然形成一个 进程树 (Process Tree) :
1 | Parent |
还记得之前 PCB 里的父指针和子指针吗?就是用在这了。
现代 OS 里如果 parent 先终止,child 通常不一定跟着一起死,而是可以继续独立运行。但如果父进程已经结束,就没人再管这些子进程的回收了,所以它们往往会被系统的根进程收养,由它来清理。
至于什么是僵尸进程,一会会说。
创建一个进程,在 OS 底层要做什么?答案是,OS 一般要准备一整套运行环境。课件上写的是:
1 | Allocate memory |
本质上我们可以理解为这样一个流程的构造:Program Execution Context + Address space + PCB + Resources + Scheduling state。
在 Unix 中,有一对很经典的组合:fork() 和 exec(),它们都是 system call。
fork() 会在调用时复制一份调用它的进程,从而造出一个新进程:
1 | Parent Process |
也就是说,子进程一开始就是调用者的一份拷贝。
但很多时候,我们造子进程并不是为了让它一直跑同一份程序。于是就有了 exec():OS 会把当前进程的镜像替换成另一个程序的镜像,简单说就是把这个进程正在跑的程序整个换掉。在 Unix 类系统中,一个很常见的玩法就是:
1 | Parent |
终止进程
最常见的情况是进程自己执行完,告诉 OS 我跑完了。但进程也不一定都是自愿结束的:比如父进程送来停止信号,程序出了问题,或者卡了 bug,都会让它跑不下去。
一个子进程结束之后,会给父进程留下终止状态。父进程有时会想知道 “我这个子进程到底跑通没有?”,这时它就可以通过 wait() 或 waitpid() 之类的调用来获取这些信息。
僵尸进程
在 Unix 中,一个进程进入 terminated 状态后,就可以被称为僵尸进程。虽然它已经退出,但 OS 依旧保留着它的 PCB,档案还没销毁,好让父进程之后还能抓取相关信息。
1 | Child: |
当父进程决定清理僵尸进程时,就可以通过 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 | kill() |
其中 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 | void handler(int sig) { |
然后通过 signal() 来告诉 OS,如果以后这个信号来了,就调用这个函数。
当然,程序也可以选择 Ignore:忽略信号,告诉 OS 我不想处理它。
而 Mask 更像是先把信号挡住存起来,之后再一并处理。
几乎每个信号进程都能处理,但有两个例外:SIGKILL 和 SIGSTOP 不能被 catch / block / ignore。试想一下,如果进程可以随便忽略 SIGKILL:
OS:“结束。”
进程:“不要。”
这对吗?
PCB 与信号处理
PCB 中包含一个指向 信号处理方法矩阵 (Signal Handler Vector) 的指针。这个矩阵按信号编号组织,每一行对应的就是这个信号的处理方法。
1 | PCB |
OS 收到某个信号后,就能按编号找到对应的处理方法。但如果这个进程之后调用了 exec()、换成了新的程序镜像,这个信号处理方法矩阵也会被重置——毕竟没道理把上一个程序的实现带到新程序里来。
至此,这章就结束啦。