mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-31 11:05:36 +00:00
Update 计算机操作系统 - 进程管理.md
补充多进程与多线程的选型、协程调度、线程共享与独有的资源、虚拟线程的开销与限制、Go 的 GMP、共享内存和 PCID,增加配图和实测附录。
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
+115
-7
@@ -28,6 +28,9 @@
|
||||
* [5. 共享存储](#5-共享存储)
|
||||
* [6. 套接字](#6-套接字)
|
||||
* [附录:实测](#附录实测)
|
||||
* [A1 上下文切换](#a1-上下文切换)
|
||||
* [A2 平台线程与虚拟线程的开销](#a2-平台线程与虚拟线程的开销)
|
||||
* [A3 POSIX 共享内存](#a3-posix-共享内存)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -54,24 +57,45 @@ QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 H
|
||||
|
||||
<div align="center"> <img src="pics/process-threads.png" width="740px"/> </div><br>
|
||||
|
||||
同一进程中的线程共享地址空间(代码、全局变量、堆)、打开的文件描述符、信号的处理方式和当前工作目录等;每个线程独有的是线程 ID、程序计数器和寄存器、栈、信号屏蔽字、errno 和线程局部存储(见 [pthreads(7)](https://man7.org/linux/man-pages/man7/pthreads.7.html))。
|
||||
|
||||
需要注意的是,各线程的栈只是分开使用,并没有隔离:拿到另一个线程栈上变量的地址,同样可以读写它。
|
||||
|
||||
### 3. 区别
|
||||
|
||||
- 拥有资源:进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源;
|
||||
- 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换;
|
||||
- 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小;
|
||||
- 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
|
||||
- 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC;
|
||||
- 健壮性:进程之间相互隔离,一个进程崩溃不影响其它进程,而一个线程访问非法内存触发段错误时,整个进程连同其它线程都会被终止。
|
||||
|
||||
因此选择多进程还是多线程,主要看更需要隔离还是共享:
|
||||
|
||||
- 任务之间需要隔离、一个出错不能拖垮其它任务时用多进程,例如 Chrome 把网页放在单独的渲染进程中,一个网页崩溃不影响浏览器和其它标签页;执行不受信任的代码(例如 Agent 运行模型生成的脚本)也应该放在单独的进程甚至容器中;
|
||||
- 任务之间要频繁共享数据、对创建和切换的开销敏感时用多线程,例如 Web 服务器用线程池处理请求,各线程直接读写同一份本地缓存。
|
||||
|
||||
### 4. 协程
|
||||
|
||||
协程(Coroutine)是在用户态实现的轻量级线程,由程序自己调度,而不是由操作系统内核调度。协程在等待 I/O 时主动让出执行权,运行时再把其它协程调度到同一个线程上执行。
|
||||
|
||||
协程的调度由语言的运行时完成,内核只看到运行时创建的少量线程(通常与 CPU 核数相当),这种把大量协程映射到少量线程上的方式称为 M:N 调度。大多数协程是协作式调度的,只在让出点切换,例如等待 I/O、等待锁、sleep;一个协程长时间计算而不让出,就会一直占住所在的线程。
|
||||
|
||||
Go 的 GMP 模型是一个典型实现:G 是 goroutine,M 是系统线程,P 是执行 Go 代码需要的处理器,数量默认等于可用的 CPU 核数。每个 P 有自己的就绪队列,M 绑定一个 P 后从队列中取 G 执行,队列空了就从其它 P 的队列中偷取一半。Go 1.14 起运行时还会用信号打断长时间运行的 goroutine(异步抢占),避免没有函数调用的循环一直占住线程。
|
||||
|
||||
与线程相比:
|
||||
|
||||
- 切换在用户态完成,只需要保存少量寄存器,不需要陷入内核,开销小;
|
||||
- 占用的内存少,可以同时存在大量协程,而每个线程都要预留独立的栈,例如 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))基本消除了这个限制。
|
||||
Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))就是 JVM 实现的协程:虚拟线程由 JVM 调度到少量的平台线程上运行,执行阻塞 I/O 时从平台线程上卸载,让平台线程去执行其它虚拟线程。调度器是一个工作窃取的 ForkJoinPool,平台线程数默认等于 CPU 核数,JDK 21 不按时间片抢占虚拟线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。
|
||||
|
||||
例如在 JDK 21 中启动 1 万个阻塞在同一个 CountDownLatch 上的线程(Ubuntu 24.04 容器实测,见[附录 A2](#a2-平台线程与虚拟线程的开销)):
|
||||
|
||||
- 平台线程用时约 1.4 秒,进程的常驻内存增加约 290 MB,平均每个约 30 KB,这是因为 2 MB(aarch64)的栈只是预留的虚拟地址,用到的页才占用物理内存;
|
||||
- 虚拟线程用时约 30 毫秒,每个只在堆中占用约 2 KB;启动 100 万个虚拟线程用时约 0.5 秒,堆中共占用约 690 MB。
|
||||
|
||||
协程也不能无限创建:每个协程的栈和状态都要占内存,例如把最大堆设为 256 MB 时,启动 100 万个阻塞的虚拟线程会抛出 OutOfMemoryError。协程适合大量任务都在等待 I/O 的场景,例如每个请求都要调用下游接口、查询数据库;CPU 密集的任务没有等待可以让出,开再多协程也只能由少量线程轮流执行,不会更快。
|
||||
|
||||
### 5. 上下文切换
|
||||
|
||||
@@ -83,9 +107,9 @@ Java 21 正式引入的虚拟线程([JEP 444](https://openjdk.org/jeps/444))
|
||||
|
||||
需要注意的是,系统调用只是从用户态进入内核态,执行完后通常回到同一个线程,不一定发生上下文切换。
|
||||
|
||||
同一进程中的线程切换不需要换地址空间;切换到另一个进程时还要切换页表,TLB 和 CPU 缓存中原来的地址映射和数据对新进程没有用,要重新加载,这部分间接开销通常比保存寄存器更大。
|
||||
同一进程中的线程切换不需要换地址空间;切换到另一个进程时还要切换页表,TLB 和 CPU 缓存中原来的地址映射和数据对新进程没有用,要重新加载,这部分间接开销通常比保存寄存器更大。x86 的 PCID 可以给 TLB 表项标上所属的地址空间,切换页表时不必清空整个 TLB,没有 PCID 时每次切换页表都会清空 TLB。
|
||||
|
||||
例如把两个执行流固定在同一个 CPU 上,通过两根管道来回传 1 个字节,让它们交替运行,两个线程之间、两个进程之间每次切换都约 1.1 微秒,包含一次 read 或 write 系统调用(Linux 实测,见[附录:实测](#附录实测))。数据量很小时两者几乎没有差别,进程切换的额外开销主要在工作集较大时体现出来。线程过多、频繁阻塞时,CPU 会把相当一部分时间花在切换上,可以用 vmstat 的 cs 列或 pidstat -w 观察每秒的切换次数。
|
||||
例如把两个执行流固定在同一个 CPU 上,通过两根管道来回传 1 个字节,让它们交替运行,两个线程之间、两个进程之间每次切换都约 1.1 微秒,包含一次 read 或 write 系统调用(Linux 实测,见[附录 A1](#a1-上下文切换))。数据量很小时两者几乎没有差别,进程切换的额外开销主要在工作集较大时体现出来。线程过多、频繁阻塞时,CPU 会把相当一部分时间花在切换上,可以用 vmstat 的 cs 列或 pidstat -w 观察每秒的切换次数。
|
||||
|
||||
## 进程状态的切换
|
||||
|
||||
@@ -608,9 +632,43 @@ FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户
|
||||
|
||||
允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种 IPC。
|
||||
|
||||
需要使用信号量用来同步对共享存储的访问。
|
||||
管道每传一次数据要复制两次:write 把数据从写进程复制到内核缓冲区,read 再复制到读进程。共享内存把同一个物理页框映射到两个进程的地址空间,写进程写入后,读进程直接就能读到。
|
||||
|
||||
多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存不是使用文件,而是使用内存的匿名段。
|
||||
<div align="center"> <img src="pics/ipc-shm.png" width="740px"/> </div><br>
|
||||
|
||||
共享内存本身不提供同步,读进程不知道数据什么时候写完,多个进程同时写也会互相覆盖,因此需要配合信号量使用。
|
||||
|
||||
例如用 POSIX 共享内存把一条 1 KB 以内的消息交给另一个进程,依次调用 shm_open 创建共享内存对象、ftruncate 设置大小、mmap 映射到地址空间,读写完后 munmap 解除映射、shm_unlink 删除对象。信号量放在共享内存中,sem_init 的第二个参数为 1 表示在进程间共享(Linux 上编译运行,见[附录 A3](#a3-posix-共享内存)):
|
||||
|
||||
```c
|
||||
struct msg {
|
||||
sem_t ready; // 写完后 up,读进程 down 等待
|
||||
char data[1024];
|
||||
};
|
||||
|
||||
int main(void) {
|
||||
int fd = shm_open("/demo_shm", O_CREAT | O_RDWR, 0600);
|
||||
ftruncate(fd, sizeof(struct msg));
|
||||
struct msg *m = mmap(NULL, sizeof(struct msg), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
sem_init(&m->ready, 1, 0);
|
||||
|
||||
if (fork() == 0) { // 写进程
|
||||
snprintf(m->data, sizeof m->data, "order 1024 paid"); // 直接写共享内存
|
||||
sem_post(&m->ready);
|
||||
return 0;
|
||||
}
|
||||
sem_wait(&m->ready); // 读进程等待写完
|
||||
printf("读到:%s\n", m->data);
|
||||
wait(NULL);
|
||||
munmap(m, sizeof(struct msg));
|
||||
shm_unlink("/demo_shm");
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
这里为了简短用 fork 创建写进程,没有亲缘关系的进程只要用同一个名字调用 shm_open,就能映射到同一块共享内存。
|
||||
|
||||
多个进程也可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存(shmget、shmat)不是使用文件,而是使用内存的匿名段。
|
||||
|
||||
### 6. 套接字
|
||||
|
||||
@@ -618,7 +676,9 @@ FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户
|
||||
|
||||
## 附录:实测
|
||||
|
||||
上下文切换:Docker Desktop 中的 Linux 容器(Apple M1 Pro),gcc -O2 编译。两个执行流通过两根管道互相传 1 个字节,每个来回包含 2 次切换,各执行 20 万个来回:
|
||||
### A1 上下文切换
|
||||
|
||||
Docker Desktop 中的 Linux 容器(Apple M1 Pro),gcc -O2 编译。两个执行流通过两根管道互相传 1 个字节,每个来回包含 2 次切换,各执行 20 万个来回:
|
||||
|
||||
```c
|
||||
static void *peer(void *arg) {
|
||||
@@ -644,9 +704,57 @@ process 每次切换(含一次 read 或 write)约 15.49 us
|
||||
|
||||
不限制 CPU 时两个执行流在不同的 CPU 上,每次都要跨核唤醒对方,反而慢得多。
|
||||
|
||||
### A2 平台线程与虚拟线程的开销
|
||||
|
||||
Docker Desktop 中的 Ubuntu 24.04 容器(Apple M1 Pro,aarch64,10 核,内存 7.8 GB),Temurin JDK 21.0.13,-Xmx4g。启动 n 个阻塞在同一个 CountDownLatch 上的线程,统计启动耗时、进程 RSS(/proc/self/status 的 VmRSS)和 System.gc() 之后堆内存的增加量:
|
||||
|
||||
```java
|
||||
CountDownLatch gate = new CountDownLatch(1);
|
||||
Runnable task = () -> { try { gate.await(); } catch (InterruptedException e) { } };
|
||||
Thread.Builder builder = virtual ? Thread.ofVirtual() : Thread.ofPlatform();
|
||||
long rss0 = rssKb(), heap0 = heapUsed(), t0 = System.nanoTime();
|
||||
for (int i = 0; i < n; i++) threads.add(builder.start(task));
|
||||
long ms = (System.nanoTime() - t0) / 1_000_000;
|
||||
Thread.sleep(500);
|
||||
long rss1 = rssKb(), heap1 = heapUsed();
|
||||
```
|
||||
|
||||
```text
|
||||
platform n=10,000 启动耗时 1424 ms,RSS 增加 287 MB(每个 29.5 KB),堆增加 3 MB(每个 0.33 KB)
|
||||
virtual n=10,000 启动耗时 29 ms,RSS 增加 41 MB(每个 4.2 KB),堆增加 19 MB(每个 2.04 KB)
|
||||
virtual n=1,000,000 启动耗时 426 ms,RSS 增加 918 MB(每个 0.9 KB),堆增加 684 MB(每个 0.70 KB)
|
||||
platform n=10,000 启动耗时 1457 ms,RSS 增加 290 MB(每个 29.7 KB),堆增加 3 MB(每个 0.33 KB)
|
||||
virtual n=10,000 启动耗时 27 ms,RSS 增加 37 MB(每个 3.9 KB),堆增加 19 MB(每个 2.00 KB)
|
||||
virtual n=1,000,000 启动耗时 520 ms,RSS 增加 900 MB(每个 0.9 KB),堆增加 688 MB(每个 0.70 KB)
|
||||
$ java -XX:+PrintFlagsFinal -version | grep -w ThreadStackSize
|
||||
intx ThreadStackSize = 2040
|
||||
```
|
||||
|
||||
把最大堆改为 256 MB 后启动 100 万个虚拟线程:
|
||||
|
||||
```text
|
||||
$ java -Xmx256m VtCost virtual 1000000
|
||||
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
|
||||
at java.base/java.lang.ThreadBuilders.newVirtualThread(ThreadBuilders.java:408)
|
||||
```
|
||||
|
||||
### A3 POSIX 共享内存
|
||||
|
||||
Docker Desktop 中的 Ubuntu 24.04 容器(aarch64),gcc 13.3,用正文中的代码(补上头文件 fcntl.h、semaphore.h、stdio.h、sys/mman.h、sys/wait.h、unistd.h):
|
||||
|
||||
```text
|
||||
$ gcc -O2 -o shm shm.c && ./shm
|
||||
读到:order 1024 paid
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 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)
|
||||
- [pthreads(7) — Linux manual page](https://man7.org/linux/man-pages/man7/pthreads.7.html)
|
||||
- [shm_overview(7) — Linux manual page](https://man7.org/linux/man-pages/man7/shm_overview.7.html)
|
||||
- [JEP 444: Virtual Threads](https://openjdk.org/jeps/444)
|
||||
- [Go 1.14 Release Notes](https://go.dev/doc/go1.14)
|
||||
- [Multi-process Architecture — The Chromium Projects](https://www.chromium.org/developers/design-documents/multi-process-architecture/)
|
||||
|
||||
Reference in New Issue
Block a user