Update 计算机操作系统 - 进程管理.md

重新绘制哲学家就餐问题的配图,调整调度算法的标题层级,更新 CFS 和 EEVDF 的描述,增加上下文切换的实测附录。
This commit is contained in:
CyC2018
2026-10-28 03:19:08 +08:00
parent a88f40e466
commit 256480c489
2 changed files with 63 additions and 41 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

+63 -41
View File
@@ -27,6 +27,7 @@
* [4. 信号量](#4-信号量)
* [5. 共享存储](#5-共享存储)
* [6. 套接字](#6-套接字)
* [附录:实测](#附录实测)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -37,7 +38,7 @@
进程是资源分配的基本单位。
进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。
进程控制块(Process Control Block, PCB)描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。
下图显示了 4 个进程在一个 CPU 上并发地执行。
@@ -55,21 +56,10 @@ QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 H
### 3. 区别
Ⅰ 拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
Ⅱ 调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
Ⅲ 系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
Ⅳ 通信方面
线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
- 拥有资源:进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源;
- 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换;
- 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小;
- 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
### 4. 协程
@@ -78,7 +68,7 @@ QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 H
与线程相比:
- 切换在用户态完成,只需要保存少量寄存器,不需要陷入内核,开销小;
- 占用的内存少,可以同时存在大量协程,而每个线程都要预留独立的栈,例如 64 位 Linux 上 Java 线程的栈默认为 1 MB,线程数量有限;
- 占用的内存少,可以同时存在大量协程,而每个线程都要预留独立的栈,例如 Linux x64 上 Java 线程的栈默认为 1 MB(ARM64 的 Linux 和 macOS 上为 2 MB),线程数量有限;
- 同一个线程上的协程不能并行执行,要利用多核仍然需要多个线程;协程中如果执行了阻塞的系统调用,所在的线程会被阻塞,所以协程的运行时通常把 I/O 改为非阻塞的方式。
Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))就是 JVM 实现的协程:虚拟线程由 JVM 调度到少量的平台线程上运行,执行阻塞 I/O 时从平台线程上卸载,让平台线程去执行其它虚拟线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。
@@ -95,19 +85,19 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
同一进程中的线程切换不需要换地址空间;切换到另一个进程时还要切换页表,TLB 和 CPU 缓存中原来的地址映射和数据对新进程没有用,要重新加载,这部分间接开销通常比保存寄存器更大。
在 Linux 容器中用 taskset 把两个执行流固定在同一个 CPU 上,通过两根管道来回传 1 个字节,让它们交替运行:两个线程之间、两个进程之间每次切换都约 1.1 微秒(包含一次 read 或 write 系统调用)。数据量很小时两者几乎没有差别,进程切换的额外开销主要在工作集较大时体现出来。线程过多、频繁阻塞时,CPU 会把相当一部分时间花在切换上,可以用 vmstat 的 cs 列或 pidstat -w 观察每秒的切换次数。
例如把两个执行流固定在同一个 CPU 上,通过两根管道来回传 1 个字节,让它们交替运行,两个线程之间、两个进程之间每次切换都约 1.1 微秒,包含一次 read 或 write 系统调用(Linux 实测,见[附录:实测](#附录实测))。数据量很小时两者几乎没有差别,进程切换的额外开销主要在工作集较大时体现出来。线程过多、频繁阻塞时,CPU 会把相当一部分时间花在切换上,可以用 vmstat 的 cs 列或 pidstat -w 观察每秒的切换次数。
## 进程状态的切换
<div align="center"> <img src="pics/process-states.png" width="720px"/> </div><br>
- 就绪状态(ready):等待被调度
- 运行状态(running)
- 阻塞状态(waiting):等待资源
- 就绪状态(ready):等待被调度;
- 运行状态(running):正在 CPU 上执行;
- 阻塞状态(waiting):等待资源。
应该注意以下内容:
- 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
- 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度;
- 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。
## 进程调度算法
@@ -118,48 +108,48 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。
**1.1 先来先服务 first-come first-served(FCFS)**
##### 1.1 先来先服务(First-Come First-Served, FCFS)
非抢占式的调度算法,按照请求的顺序进行调度。
有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。
**1.2 短作业优先 shortest job first(SJF)**
##### 1.2 短作业优先(Shortest Job First, SJF)
非抢占式的调度算法,按估计运行时间最短的顺序进行调度。
长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
**1.3 最短剩余时间优先 shortest remaining time next(SRTN)**
##### 1.3 最短剩余时间优先(Shortest Remaining Time Next, SRTN)
最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
### 2. 交互式系统
交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。
**2.1 时间片轮转**
##### 2.1 时间片轮转
将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。
时间片轮转算法的效率和时间片的大小有很大关系:
- 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
- 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间;
- 而如果时间片过长,那么实时性就不能得到保证。
<div align="center"> <img src="pics/process-rr.png" width="740px"/> </div><br>
**2.2 优先级调度**
##### 2.2 优先级调度
为每个进程分配一个优先级,按优先级进行调度。
为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
**2.3 多级反馈队列**
##### 2.3 多级反馈队列
一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1、2、4、8……进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次(1 + 2 + … + 64 = 127 ≥ 100)。
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
@@ -173,6 +163,8 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。
实际的操作系统会组合使用这些思想。例如 Linux 2.6.23 起的完全公平调度器(CFS)给每个进程记录按优先级加权后的虚拟运行时间,总是选择虚拟运行时间最小的进程运行,让各进程按权重分享 CPU;Linux 6.6 起开始改用 EEVDF 调度器,在公平分享的基础上考虑每个进程的截止时间,让对延迟敏感的进程更快得到运行。实时进程使用单独的 SCHED_FIFO、SCHED_RR 策略,优先于普通进程。
## 进程同步
### 1. 临界区
@@ -181,7 +173,7 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
```html
```c
// entry section
// critical section;
// exit section
@@ -189,19 +181,19 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
### 2. 同步与互斥
- 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。
- 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系;
- 互斥:多个进程在同一时刻只有一个进程能进入临界区。
### 3. 信号量
信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。
- **down** : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
- **up** :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。
- down:如果信号量大于 0,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
- up:对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。
down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。
如果信号量的取值只能为 0 或者 1,那么就成为了 **互斥量(Mutex)** ,0 表示临界区已经加锁,1 表示临界区解锁。
如果信号量的取值只能为 0 或者 1,那么就成为了互斥量(Mutex),0 表示临界区已经加锁,1 表示临界区解锁。
```c
typedef int semaphore;
@@ -219,7 +211,7 @@ void P2() {
}
```
\<font size=3\> **使用信号量实现生产者-消费者问题** \</font\> \</br\>
使用信号量实现生产者-消费者问题:
问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
@@ -263,7 +255,7 @@ void consumer() {
使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
c 语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove() 方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
C 语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove() 方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
```pascal
monitor ProducerConsumer
@@ -284,9 +276,9 @@ end monitor;
管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。
管程引入了 **条件变量** 以及相关的操作:**wait()** 和 **signal()** 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。
管程引入了条件变量以及相关的操作 wait() 和 signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。
<font size=3> **使用管程实现生产者-消费者问题** </font><br>
使用管程实现生产者-消费者问题:
```pascal
// 管程
@@ -519,7 +511,7 @@ Semaphore serviceQueue; // FAIRNESS: preserves ordering of requests (sig
void writer()
{
down(&serviceQueue); // wait in line to be servicexs
down(&serviceQueue); // wait in line to be serviced
// <ENTER>
down(&resourceAccess); // request exclusive access to resource
// </ENTER>
@@ -624,7 +616,37 @@ FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户
与其它通信机制不同的是,它可用于不同机器间的进程通信。
## 附录:实测
上下文切换:Docker Desktop 中的 Linux 容器(Apple M1 Pro),gcc -O2 编译。两个执行流通过两根管道互相传 1 个字节,每个来回包含 2 次切换,各执行 20 万个来回:
```c
static void *peer(void *arg) {
char c;
for (long i = 0; i < n; i++) { read(a[0], &c, 1); write(b[1], &c, 1); }
return NULL;
}
// main 中:thread 模式用 pthread_create 运行 peer,process 模式用 fork
for (long i = 0; i < n; i++) { write(a[1], &c, 1); read(b[0], &c, 1); }
printf("%-7s 每次切换(含一次 read 或 write)约 %.2f us\n", argv[1], ns / n / 2 / 1000);
```
```text
$ for m in thread process thread process; do taskset -c 0 ./pp $m 200000; done
thread 每次切换(含一次 read 或 write)约 1.10 us
process 每次切换(含一次 read 或 write)约 1.12 us
thread 每次切换(含一次 read 或 write)约 1.08 us
process 每次切换(含一次 read 或 write)约 1.12 us
$ for m in thread process; do ./pp $m 200000; done # 不限制 CPU
thread 每次切换(含一次 read 或 write)约 16.55 us
process 每次切换(含一次 read 或 write)约 15.49 us
```
不限制 CPU 时两个执行流在不同的 CPU 上,每次都要跨核唤醒对方,反而慢得多。
## 参考资料
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.
- [pipe(7) — Linux manual page](https://man7.org/linux/man-pages/man7/pipe.7.html)
- [CFS Scheduler — The Linux Kernel documentation](https://docs.kernel.org/scheduler/sched-design-CFS.html)
- [EEVDF Scheduler — The Linux Kernel documentation](https://docs.kernel.org/scheduler/sched-eevdf.html)