进程控制

上一节讲了进程有哪些状态、状态之间怎么转换。这一节回答的是:这些转换具体是谁、用什么手段做出来的。

答案是原语。而这一节真正的考点,几乎全都藏在”为什么这些操作必须做成原语”这一个问题里。

机制

为什么必须是原语

进程控制要做的事情,本质上都是同时修改好几处相互关联的数据。以”阻塞一个进程”为例,至少要做三件事:

  1. 把 PCB 里的状态字段从”运行”改成”阻塞”
  2. 把该 PCB 从运行指针摘下来,挂到某个事件的等待队列上
  3. 保存现场,然后转去调度程序选下一个进程

现在设想第 1 步做完、第 2 步还没做的那一瞬间,来了个中断。此刻系统的状态是自相矛盾的:PCB 说自己是阻塞态,但它还挂在运行指针上,任何一个正在遍历队列的内核代码看到它都会困惑——一个”阻塞态”的进程凭什么在运行队列里?

这种状态字段和队列归属不一致的中间态,就是必须被杜绝的。解决办法就是把整段操作变成不可分割的一步:原语。

原语的实现手段是关中断 / 开中断。进入原语先关中断,做完全部修改再开中断。中断被屏蔽期间 CPU 不会响应任何外部打断,自然也就不会有人观测到那个中间态。

这也解释了为什么原语必须短。关中断期间时钟中断也进不来,关太久会影响整个系统的响应性和计时精度。

顺带澄清一个常见误解:原语的本质是”不可分割”,不是”快”。 快只是它必须满足的工程约束(因为要关中断),不是它的定义。

四个基本原语

原语干什么状态变化
创建原语申请空白 PCB → 分配资源 → 初始化 PCB → 插入就绪队列无 → 创建 → 就绪
撤销原语找到 PCB → 回收资源 → 删除 PCB任意 → 结束 → 无
阻塞原语保存现场 → 改状态 → 挂入等待队列 → 转调度运行 → 阻塞
唤醒原语从等待队列摘下 → 改状态 → 挂入就绪队列阻塞 → 就绪

注意阻塞原语和唤醒原语必须成对使用:谁把进程阻塞在哪个事件上,就得由负责那个事件的执行流来唤醒它。如果一个进程被阻塞后没人唤醒,它就永远醒不过来——这正是后面死锁和饥饿问题的源头。

再次强调 2.1.3 里那个不对称:阻塞原语是进程自己调用的(主动),唤醒原语是别人替它调用的(被动)。 因为阻塞之后它一条指令都执行不了。

进程创建的四类来源

教材列了四种引起进程创建的事件,这个清单看起来很抽象,逐个落地就清楚了。

疑问点:作业调度程序是常驻的,为何说它会创建进程

教材原文:“在操作系统中,终端用户登录系统、作业调度、系统提供服务、用户程序的应用请求等都会引起进程的创建。”

这四类来源分别对应哪些具体场景?其中”作业调度”一项存在明显矛盾——作业调度程序本身是常驻的,为何还说它会引起进程创建?

1. 终端用户登录系统

用户通过 SSH 连接一台 Linux 主机,认证通过后系统为其建立一个会话,并创建一个 shell 进程(bash / zsh)。该 bash 正是因”用户登录”这一事件而被创建。

2. 作业调度

这一项的矛盾感来自教材的省略,而它恰恰是关键:作业调度程序确实是常驻的,但被创建的不是它。

教材这句话的主语被省略了,补全之后是:“作业调度程序把某个后备作业选中投入运行时,为这个作业创建进程。”

具体过程是:批处理系统里,一批作业先躺在外存的后备作业队列上。此刻它们只是作业,还不是进程——没有 PCB,没占内存,OS 也不调度它们。作业调度程序(高级调度)从中挑一个,为它分配内存、建立 PCB,这时它才变成进程进入就绪队列。

所以:常驻的是”发起者”,被创建的是”被选中的那个作业”。 这两者别混。这也是高级调度与低级调度的分界点——高级调度决定”谁能变成进程”,低级调度决定”哪个进程能上 CPU”。

3. 系统提供服务

用户向操作系统请求某项服务,操作系统新建一个进程去完成。典型例子是打印:用户提交打印请求后,系统创建一个打印服务进程(或由常驻的打印守护进程 fork 出子进程)处理该任务,用户自身的进程无须阻塞等待。

4. 用户程序的应用请求

程序自己主动要求创建子进程,通常是为了并行干活。这是四类里唯一由用户程序主动发起的。

  • Shell 里输入 ls,bash 会 fork 出一个子进程去执行 ls,自己继续等下一条命令。
  • 一个 Web 服务器为每个新连接 fork 一个子进程去处理。

四类的区分标准其实就一条:是谁发起的。 前三类是系统因为某个外部事件而创建,第四类是进程自己要求创建。

进程终止的三类事件

疑问点:异常结束的各项分别由谁检测

教材原文:“引起进程终止的事件主要有:①正常结束……②异常结束。表示进程在运行时,发生了某种异常事件,使程序无法继续运行,如存储区越界、保护错、非法指令、特权指令错、运行超时、算术运算错、I/O 故障……

这个清单也是按”谁决定终止”来分的:

① 正常结束——进程自己跑完了,主动调用 exit。

② 异常结束——进程出错,由硬件检测到并报告给 OS,OS 决定终止它。清单里那些项可以按检测者归类:

  • 存储区越界、保护错:由 MMU(内存管理单元)在地址转换时检测出来
  • 非法指令、特权指令错:由 CPU 译码时检测出来(用户态执行了只能内核态执行的指令)
  • 算术运算错(如除零):由 ALU 检测出来
  • 运行超时:由时钟中断配合 OS 的计时统计判定
  • I/O 故障:由设备控制器报告

注意这些绝大多数属于”异常”(内中断),是当前指令执行过程中产生的,与外部设备无关。这一点和中断/异常的分类直接相关,是第 1 章 1.3 节的内容。

③ 外界干预——OS 或其他有权限的进程要求终止它。比如 kill 命令、父进程终止导致子进程被级联终止。

边界

进程切换 ≠ 模式切换

这是整本书最高频的边界之一,必须彻底钉死。

  • 模式切换:同一个进程从用户态进入内核态(或返回)。只需要改变 CPU 的模式位、切换到内核栈,进程还是那个进程。
  • 进程切换:换掉当前在 CPU 上跑的进程。要保存 A 的全部现场、恢复 B 的全部现场,还要切换页表、刷新 TLB。

关系是单向的:进程切换一定伴随模式切换,反过来不成立。

一个进程调用 getpid() 这种系统调用,进内核、取个数、回用户态,全程都是它自己,没有发生任何进程切换。这条边界的实际意义在于开销:模式切换很便宜,进程切换很贵(页表和 TLB 是大头)。

创建原语做的是”建 PCB”,不是”装入程序”

创建原语的核心动作是申请并初始化 PCB。至于程序代码什么时候真正被读进内存,那是内存管理的事——在请求分页系统里,可能等到真正访问才通过缺页中断调入(见第 3 章)。

所以”创建进程”和”程序被装入内存”不是同一件事,这一点在讨论虚拟存储时会再次出现。

对照速查

原语谁调用主动/被动关键动作
创建OS(因四类事件之一)—建 PCB
撤销OS—回收 PCB
阻塞进程自己主动挂入等待队列 + 转调度
唤醒其他执行流被动摘出等待队列 → 就绪队列
创建来源发起者例子
用户登录系统创建 bash
作业调度系统(调度程序常驻,被创建的是作业)后备作业 → 进程
系统提供服务系统打印服务进程
用户程序请求进程自己fork

考点

  • 原语为什么不可中断、用什么实现(关中断)
  • 阻塞原语与唤醒原语必须成对
  • 作业调度创建进程时,常驻的是调度程序,被创建的是作业
  • 进程切换 vs 模式切换(最高频边界)
  • 异常结束的各项分别由谁检测

链接