diff --git a/notes/pics/pipe.png b/notes/pics/pipe.png
index 5bc7d457..f3169491 100644
Binary files a/notes/pics/pipe.png and b/notes/pics/pipe.png differ
diff --git a/notes/pics/process-mlfq.png b/notes/pics/process-mlfq.png
index ca8112ef..86db9fc4 100644
Binary files a/notes/pics/process-mlfq.png and b/notes/pics/process-mlfq.png differ
diff --git a/notes/pics/process-threads.png b/notes/pics/process-threads.png
index 262660d6..f4eee10f 100644
Binary files a/notes/pics/process-threads.png and b/notes/pics/process-threads.png differ
diff --git a/notes/pics/virtual-thread-unmount.png b/notes/pics/virtual-thread-unmount.png
index 00b2cf6e..18e44c3e 100644
Binary files a/notes/pics/virtual-thread-unmount.png and b/notes/pics/virtual-thread-unmount.png differ
diff --git a/notes/计算机操作系统 - 进程管理.md b/notes/计算机操作系统 - 进程管理.md
index fd42e5c5..c1ef3c4f 100644
--- a/notes/计算机操作系统 - 进程管理.md
+++ b/notes/计算机操作系统 - 进程管理.md
@@ -32,6 +32,8 @@
* [A2 平台线程与虚拟线程的开销](#a2-平台线程与虚拟线程的开销)
* [A3 POSIX 共享内存](#a3-posix-共享内存)
* [A4 fork 与 pthread_create 的 clone 标志](#a4-fork-与-pthread_create-的-clone-标志)
+ * [A5 线程数的上限](#a5-线程数的上限)
+ * [A6 协程的切换开销](#a6-协程的切换开销)
* [参考资料](#参考资料)
@@ -42,7 +44,9 @@
进程是资源分配的基本单位。
-进程控制块(Process Control Block, PCB)描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。
+进程控制块(Process Control Block, PCB)描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。PCB 中记录了进程 ID、状态、换下 CPU 时保存的寄存器、页表和打开的文件等。
+
+程序是磁盘上的可执行文件,进程是程序的一次运行,同一个程序启动两次就是两个进程。
下图显示了 4 个进程在一个 CPU 上并发地执行。
@@ -66,7 +70,7 @@ QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 H
- 拥有资源:进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源;
- 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换;
-- 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小;
+- 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,两者切换时都要保存和恢复寄存器,进程切换还要切换地址空间,开销更大(见[上下文切换](#4-上下文切换));
- 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC;
- 健壮性:进程之间相互隔离,一个进程崩溃不影响其它进程,而一个线程访问非法内存触发段错误时,整个进程连同其它线程都会被终止。
@@ -77,7 +81,9 @@ fork() clone(flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD)
pthread_create() clone(flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|...)
```
-CLONE_VM 表示共享地址空间,CLONE_FILES 表示共享文件描述符表,CLONE_THREAD 表示加入创建者的线程组。没有这些标志时,子进程得到一份地址空间的副本(写时复制),修改全局变量不影响父进程;有这些标志时,新线程修改的就是同一个变量。getpid() 返回的是线程组 ID,同一进程中的线程都相同,gettid() 返回每个线程自己的 ID,top -H、ps -L 中看到的就是它。
+CLONE_VM 表示共享地址空间,CLONE_FILES 表示共享文件描述符表,CLONE_THREAD 表示加入创建者的线程组。没有这些标志时,子进程得到一份地址空间的副本([写时复制](计算机操作系统%20-%20内存管理.md#分页系统地址映射)),修改全局变量不影响父进程;有这些标志时,新线程修改的就是同一个变量。getpid() 返回的是线程组 ID,同一进程中的线程都相同,gettid() 返回每个线程自己的 ID,top -H、ps -L 中看到的就是它。
+
+fork 调用一次、返回两次:父进程中返回子进程的 PID,子进程中返回 0。多线程的进程 fork 后,子进程中只有调用 fork 的那个线程。
@@ -96,27 +102,33 @@ CLONE_VM 表示共享地址空间,CLONE_FILES 表示共享文件描述符表
需要注意的是,系统调用只是从用户态进入内核态,执行完后通常回到同一个线程,不一定发生上下文切换。
-同一进程中的线程切换不需要换地址空间;切换到另一个进程时还要切换页表,TLB 和 CPU 缓存中原来的地址映射和数据对新进程没有用,要重新加载,这部分间接开销通常比保存寄存器更大。x86 的 PCID 可以给 TLB 表项标上所属的地址空间,切换页表时不必清空整个 TLB,没有 PCID 时每次切换页表都会清空 TLB。
+同一进程中的线程切换不需要换地址空间;切换到另一个进程时还要切换页表,TLB 和 CPU 缓存中原来的地址映射和数据大多用不上,要重新加载,这部分间接开销通常比保存寄存器更大。x86 的 PCID 可以给 TLB 表项标上所属的地址空间,切换页表时不必清空整个 TLB,没有 PCID 时,每次切换页表都会清空 TLB 中除全局页(内核映射)以外的表项。
-例如把两个执行流固定在同一个 CPU 上,通过两根管道来回传 1 个字节,让它们交替运行,两个线程之间、两个进程之间每次切换都约 1.1 微秒,包含一次 read 或 write 系统调用(Linux 实测,见[附录 A1](#a1-上下文切换))。数据量很小时两者几乎没有差别,进程切换的额外开销主要在工作集较大时体现出来。线程过多、频繁阻塞时,CPU 会把相当一部分时间花在切换上,可以用 vmstat 的 cs 列或 pidstat -w 观察每秒的切换次数。
+例如把两个执行流固定在同一个 CPU 上,通过两根管道来回传 1 个字节,让它们交替运行,两个线程之间、两个进程之间每次切换都约 1.1 微秒,其中还包含一次 write 和一次 read 系统调用(Linux 实测,见[附录 A1](#a1-上下文切换))。数据量很小时两者几乎没有差别,进程切换的额外开销主要在工作集较大时体现出来。线程过多、频繁阻塞时,CPU 会把相当一部分时间花在切换上,可以用 vmstat 的 cs 列或 pidstat -w 观察每秒的切换次数。
+
+线程也不能无限创建:Linux 上受 ulimit -u、/proc/sys/kernel/threads-max 和 pid_max 限制,超出时 Java 抛出 OutOfMemoryError: unable to create native thread(见[附录 A5](#a5-线程数的上限))。
### 5. 协程 🔥🔥
协程(Coroutine)是在用户态实现的轻量级线程,由程序自己调度,而不是由操作系统内核调度。协程在等待 I/O 时主动让出执行权,运行时再把其它协程调度到同一个线程上执行。
-协程的调度由语言的运行时完成,内核只看到运行时创建的少量线程(通常与 CPU 核数相当),这种把大量协程映射到少量线程上的方式称为 M:N 调度。大多数协程是协作式调度的,只在让出点切换,例如等待 I/O、等待锁、sleep;一个协程长时间计算而不让出,就会一直占住所在的线程。
+协程要解决的是高并发 I/O 的写法问题:用 [epoll](Socket.md#epoll) 时少量线程就能处理几万个连接,但处理逻辑要拆成一段段回调;协程让代码仍按阻塞的方式顺序写,运行时在底层改用非阻塞 I/O 和 epoll 这类多路复用接口,等待时挂起协程,就绪后再恢复。
+
+Go 和 Java 虚拟线程把大量协程映射到运行时创建的少量线程(通常与 CPU 核数相当)上,称为 M:N 调度;Python 的 asyncio 则在一个线程的事件循环中轮流执行所有协程。大多数协程是协作式调度的,只在让出点切换,例如等待 I/O、等待锁、sleep;一个协程长时间计算而不让出,就会一直占住所在的线程。
Go 的 GMP 模型是一个典型实现:G 是 goroutine,M 是系统线程,P 是执行 Go 代码需要的处理器,数量默认等于可用的 CPU 核数。每个 P 有自己的就绪队列,M 绑定一个 P 后从队列中取 G 执行,队列空了就从其它 P 的队列中偷取一半。Go 1.14 起运行时还会用信号打断长时间运行的 goroutine(异步抢占),避免没有函数调用的循环一直占住线程。
与线程相比:
-- 切换在用户态完成,只需要保存少量寄存器,不需要陷入内核,开销小;
+- 切换在用户态完成,只需要保存少量寄存器,不需要陷入内核,开销小,例如 goroutine 每次切换约 0.1~0.2 微秒,虚拟线程约 0.3 微秒,线程约 1.1 微秒(测法不同,只比较数量级,见[附录 A6](#a6-协程的切换开销)、[附录 A1](#a1-上下文切换));
- 占用的内存少,可以同时存在大量协程,而每个线程都要预留独立的栈,例如 Linux x64 上 Java 线程的栈默认为 1 MB(ARM64 的 Linux 和 macOS 上为 2 MB),线程数量有限;
-- 同一个线程上的协程不能并行执行,要利用多核仍然需要多个线程;协程中如果执行了阻塞的系统调用,所在的线程会被阻塞,所以协程的运行时通常把 I/O 改为非阻塞的方式。
+- 同一个线程上的协程不能并行执行,要利用多核仍然需要多个线程;协程中如果执行了运行时没有改写的阻塞系统调用(例如读本地文件),所在的线程仍会被阻塞。
协程按是否有自己的调用栈分为两类。有栈协程(stackful)可以在任意深度的函数调用中挂起,挂起时保存整个调用栈,例如 goroutine 和 Java 的虚拟线程;无栈协程(stackless)由编译器把 async 函数改写成状态机,只能在 await 处挂起,局部变量保存在状态对象中,例如 Python 的 async/await 和 Kotlin 协程。无栈协程更省内存,但 async 会沿调用链传染,调用它的函数也要写成 async。
-Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))就是 JVM 实现的协程:虚拟线程由 JVM 调度到少量的平台线程上运行,执行阻塞 I/O 时从平台线程上卸载,让平台线程去执行其它虚拟线程。调度器是一个工作窃取的 ForkJoinPool,平台线程数默认等于 CPU 核数,JDK 21 不按时间片抢占虚拟线程。虚拟线程卸载时,它的栈帧以栈块对象(stack chunk)的形式保存在堆中,等 I/O 就绪后再挂载到某个平台线程上继续执行,所以阻塞的虚拟线程只占用与调用深度相当的堆内存,不占用平台线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。
+Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))就是 JVM 实现的协程:虚拟线程由 JVM 调度到少量的平台线程(称为载体线程,carrier thread)上运行,执行阻塞 I/O 时从平台线程上卸载,让平台线程去执行其它虚拟线程。调度器是一个工作窃取的 ForkJoinPool,平台线程数默认等于 CPU 核数,JDK 21 不按时间片抢占虚拟线程。
+
+虚拟线程卸载时,它的栈帧以栈块对象(stack chunk)的形式保存在堆中,等 I/O 就绪后再挂载到某个平台线程上继续执行,所以阻塞的虚拟线程只占用与调用深度相当的堆内存,不占用平台线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,会固定(pinning)在载体线程上,所以锁内有阻塞操作时改用 ReentrantLock(见 [Java 并发](Java%20并发.md#使用选择));JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。
@@ -131,15 +143,19 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
+- 新建状态(new):正在创建,PCB 已建立但还没有进入就绪队列;
- 就绪状态(ready):等待被调度;
- 运行状态(running):正在 CPU 上执行;
-- 阻塞状态(waiting):等待资源。
+- 阻塞状态(waiting):等待资源;
+- 终止状态(terminated):已经退出,等待父进程回收。
应该注意以下内容:
- 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度;
- 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。
+Linux 的进程状态和僵尸进程、孤儿进程见 [Linux](Linux.md#进程状态),Java 线程的 6 种状态见 [Java 并发](Java%20并发.md#六线程状态)。
+
## 进程调度算法
不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。
@@ -160,6 +176,8 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
+例如 A、B、C 同时到达,分别需要运行 8、4、1 个单位时间:FCFS 按 A、B、C 执行,平均等待 (0 + 8 + 12) / 3 ≈ 6.7;SJF 按 C、B、A 执行,平均等待 (0 + 1 + 5) / 3 = 2。所有作业同时到达时,SJF 的平均等待时间最短。
+
##### 1.3 最短剩余时间优先(Shortest Remaining Time Next, SRTN)
最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
@@ -187,13 +205,13 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
##### 2.3 多级反馈队列
-一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
+一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要被调度 100 次。
-多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1、2、4、8……进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次(1 + 2 + … + 64 = 127 ≥ 100)。
+多级反馈队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1、2、4、8……进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要被调度 7 次(1 + 2 + … + 64 = 127 ≥ 100)。
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
-可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
+可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。交互式进程经常没用完时间片就因等待 I/O 让出 CPU,一直留在高优先级队列,响应快;计算密集的进程则逐级下沉。
@@ -209,7 +227,19 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
### 1. 临界区
-对临界资源进行访问的那段代码称为临界区。
+临界资源指的是一次只允许一个进程使用的资源,例如共享变量、打印机;对临界资源进行访问的那段代码称为临界区。
+
+例如两个进程对共享变量 count 执行 count++,这条语句分为读到寄存器、加 1、写回内存三步。count 为 5 时按下面的顺序交错执行,结果是 6 而不是 7:
+
+```text
+时刻 进程 A 进程 B 内存中的 count
+1 读 count,得到 5 5
+2 读 count,得到 5 5
+3 加 1,得到 6 5
+4 写回 6 6
+5 加 1,得到 6 6
+6 写回 6 6
+```
为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
@@ -231,9 +261,9 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
- down:如果信号量大于 0,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
- up:对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。
-down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。
+down 和 up 操作需要被设计成原语,不可分割。单处理器上通常在执行这些操作时屏蔽中断;多处理器上屏蔽中断只对本 CPU 有效,还要用 TSL、CAS 这类原子指令保护信号量。
-如果信号量的取值只能为 0 或者 1,那么就成为了互斥量(Mutex),0 表示临界区已经加锁,1 表示临界区解锁。
+如果信号量的取值只能为 0 或者 1,就称为二值信号量,可以当作互斥量(Mutex)使用,0 表示临界区已经加锁,1 表示临界区解锁。需要注意的是,信号量可以由另一个进程执行 up,而 pthread_mutex、ReentrantLock 这类互斥锁只能由加锁的线程释放,所以后面读者-写者问题中由第一个读者 down、最后一个读者 up 的 data_mutex 不能换成互斥锁。
```c
typedef int semaphore;
@@ -259,7 +289,7 @@ void P2() {
为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。
-注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。
+需要注意的是,down(empty) 必须放在 down(mutex) 之前。如果顺序反过来,缓冲区满时生产者先拿到 mutex,再在 down(empty) 上睡眠;消费者取物品之前要先拿 mutex,也被挡住,于是没有进程能执行 up(empty),两者永远等待下去。
```c
#define N 100
@@ -325,7 +355,6 @@ end monitor;
monitor ProducerConsumer
condition full, empty;
integer count := 0;
- condition c;
procedure insert(item: integer);
begin
@@ -365,6 +394,8 @@ begin
end;
```
+上面的伪代码用 if 检查条件,前提是被 signal 唤醒的进程立即运行。Java 的每个对象都可以当作管程,synchronized 保证互斥,wait()、notify() 相当于条件变量的 wait 和 signal,但被唤醒的线程要重新竞争锁,条件可能已经变了,所以要用 while 检查(见 [Java 并发](Java%20并发.md#wait-notify-notifyall))。
+
## 经典同步问题
生产者和消费者问题前面已经讨论过了。
@@ -426,7 +457,7 @@ void take_two(int i) {
down(&s[i]); // 只有收到通知之后才可以开始吃,否则会一直等下去
}
-void put_two(i) {
+void put_two(int i) {
down(&mutex);
state[i] = THINKING;
check(LEFT); // 尝试通知左右邻居,自己吃完了,你们可以开始吃了
@@ -434,14 +465,8 @@ void put_two(i) {
up(&mutex);
}
-void eat(int i) {
- down(&mutex);
- state[i] = EATING;
- up(&mutex);
-}
-
// 检查两个邻居是否都没有用餐,如果是的话,就 up(&s[i]),使得 down(&s[i]) 能够得到通知并继续执行
-void check(i) {
+void check(int i) {
if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
state[i] = EATING;
up(&s[i]);
@@ -453,7 +478,7 @@ void check(i) {
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。
-一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。
+一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个二值信号量 data_mutex 用于对读写的数据加锁。
```c
typedef int semaphore;
@@ -493,7 +518,17 @@ void writer() {
- 进程同步:控制多个进程按一定顺序执行;
- 进程通信:进程间传输信息。
-进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。
+进程通信是一种手段,而进程同步是一种目的:为了让进程按顺序执行,需要通过通信传递同步所需的信息。
+
+| 方式 | 传递的内容 | 特点 | 例子 |
+| --- | --- | --- | --- |
+| 管道 | 字节流 | 单向,一般用于有亲缘关系的进程 | shell 中的 ps aux \| grep java |
+| FIFO | 字节流 | 有路径名,不相关的进程也能使用 | 本机的客户进程和服务器进程 |
+| 消息队列 | 带类型的消息 | 保留消息边界,可以按类型读取 | 多个进程向同一个队列投递任务 |
+| 共享存储 | 直接读写同一块内存 | 最快,需要自己同步 | 大量数据的交换 |
+| 信号量 | 不传数据 | 计数器,用于同步和互斥 | 配合共享存储 |
+| 信号 | 信号编号(sigqueue 可附带一个整数) | 异步通知 | kill、[SIGCHLD](Linux.md#sigchld) |
+| 套接字 | 字节流或数据报 | 可以跨机器 | 网络服务、Unix 域套接字 |
### 1. 管道
@@ -506,14 +541,16 @@ int pipe(int fd[2]);
它具有以下限制:
-- 只支持半双工通信(单向交替传输);
-- 只能在父子进程或者兄弟进程中使用。
+- 数据只能单向流动,双向通信要建两根管道;
+- 管道没有名字,一般靠 fork 继承文件描述符,所以通常用于父子、兄弟这类有共同祖先的进程。
+fork 后父子进程要关闭各自不用的一端:只要还有一个写端没关闭,读完数据的 read 就会一直阻塞,所有写端都关闭后 read 才返回 0。管道容量有限(Linux 默认 16 页,页大小为 4 KB 时是 64 KB),写满时 write 也会阻塞。
+
### 2. FIFO
-也称为命名管道,去除了管道只能在父子进程中使用的限制。
+也称为命名管道,它有一个路径名,不相关的进程打开同一个路径就能通信。
```c
#include
@@ -527,15 +564,17 @@ FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户
### 3. 消息队列
-相比于 FIFO,消息队列具有以下优点:
+消息队列是保存在内核中的消息链表。相比于 FIFO,消息队列具有以下优点:
-- 消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;
-- 避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;
-- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。
+- 消息队列可以独立于读写进程存在,写进程退出后消息仍然保留,而 FIFO 的读写端都关闭后,未读的数据就丢弃了;
+- 按消息读写,保留消息的边界,而 FIFO 是字节流,接收方要自己拆分;
+- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能按顺序接收。
+
+每条消息都要在进程和内核之间复制两次,不适合传输大量数据。
### 4. 信号量
-它是一个计数器,用于为多个进程提供对共享数据对象的访问。
+它是一个计数器,用于为多个进程提供对共享数据对象的访问,P、V 操作与[信号量](#3-信号量)一节相同。
### 5. 共享存储
@@ -583,6 +622,8 @@ int main(void) {
与其它通信机制不同的是,它可用于不同机器间的进程通信。
+同一台机器上还可以用 Unix 域套接字(AF_UNIX),它以文件路径为地址,不经过网络协议栈,例如 Docker 客户端通过 /var/run/docker.sock 访问守护进程。
+
## 附录:实测
### A1 上下文切换
@@ -611,7 +652,7 @@ thread 每次切换(含一次 read 或 write)约 16.55 us
process 每次切换(含一次 read 或 write)约 15.49 us
```
-不限制 CPU 时两个执行流在不同的 CPU 上,每次都要跨核唤醒对方,反而慢得多。
+每个来回有 2 次 write、2 次 read 和 2 次切换,所以“每次切换”的耗时里包含一次 write 和一次 read,输出中的“一次 read 或 write”写少了。不限制 CPU 时两个执行流在不同的 CPU 上,每次都要跨核唤醒对方,反而慢得多。
### A2 平台线程与虚拟线程的开销
@@ -691,6 +732,53 @@ clone(child_stack=0xffff86e0ea60, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGH
Docker 默认的 seccomp 规则不允许 clone3,glibc 收到 ENOSYS 后改用 clone,标志相同。线程的栈大小 0x80ea60 约 8 MB,来自 ulimit -s 的默认值。
+### A5 线程数的上限
+
+macOS 15.8(Apple M1 Pro),Temurin JDK 21.0.13。不断创建阻塞在同一个 CountDownLatch 上的线程,直到失败:
+
+```java
+for (;; n++) new Thread(() -> { try { gate.await(); } catch (InterruptedException e) { } }).start();
+```
+
+```text
+$ sysctl kern.num_taskthreads
+kern.num_taskthreads: 8192
+$ java ManyThreads.java
+[3.588s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
+创建了 8163 个线程后:java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
+```
+
+macOS 限制每个进程最多 8192 个线程,其余约 30 个是 JVM 自己的线程。Linux 上的限制见 [pthread_create(3)](https://man7.org/linux/man-pages/man3/pthread_create.3.html) 的 EAGAIN 一项。
+
+### A6 协程的切换开销
+
+macOS 15.8(Apple M1 Pro),Go 1.27.3、Temurin JDK 21.0.13。与附录 A1 相同,两个执行流来回传递数据,只用一个线程执行:Go 用两个无缓冲 channel,GOMAXPROCS=1(同时执行 Go 代码的线程只有一个,没有固定 CPU);Java 用两个 SynchronousQueue,调度器只有一个平台线程。各执行 200 万个来回:
+
+```go
+go func() { for i := 0; i < n; i++ { b <- <-a } }()
+for i := 0; i < n; i++ { a <- 1; <-b }
+fmt.Printf("goroutine 每次切换约 %.0f ns\n", ns/n/2)
+```
+
+```java
+Thread peer = Thread.ofVirtual().start(() -> { for (int i = 0; i < n; i++) b.put(a.take()); });
+// 另一个虚拟线程中(省略了 InterruptedException 的处理):
+for (int i = 0; i < n; i++) { a.put(1); b.take(); }
+```
+
+```text
+$ for i in 1 2 3; do GOMAXPROCS=1 go run pingpong.go; done
+goroutine 每次切换约 113 ns
+goroutine 每次切换约 117 ns
+goroutine 每次切换约 115 ns
+$ java -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 VtPingPong
+virtual 每次切换约 274 ns
+virtual 每次切换约 252 ns
+virtual 每次切换约 271 ns
+```
+
+机器负载较高时 goroutine 测到 130~190 ns,虚拟线程 240~380 ns。耗时中包含 channel 或队列本身的操作;与附录 A1 的操作系统不同(A1 是 Linux 容器),只能比较数量级。
+
## 参考资料
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.