mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-31 02:53:42 +00:00
Merge pull request #1491 from CyC2018/update-os-notes
Update 计算机操作系统 notes
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
+92
-1
@@ -14,6 +14,9 @@
|
||||
* [段页式](#段页式)
|
||||
* [分页与分段的比较](#分页与分段的比较)
|
||||
* [附录:实测](#附录实测)
|
||||
* [A1 页面置换模拟](#a1-页面置换模拟)
|
||||
* [A2 缺页次数](#a2-缺页次数)
|
||||
* [A3 进程的地址空间布局](#a3-进程的地址空间布局)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -42,6 +45,18 @@
|
||||
|
||||
64 位系统的地址空间很大,如果用一张页表覆盖全部地址,页表本身就会非常大,因此使用多级页表,只为用到的地址分配下一级页表。例如 x86-64 通常使用 4 级页表,48 位虚拟地址分为 4 个 9 位的页表索引和 12 位的页内偏移。
|
||||
|
||||
MMU 查页表时发现页不在内存中,或者这次访问违反了页的权限(例如写只读页),就会触发缺页异常(Page Fault),由操作系统处理:
|
||||
|
||||
1. 保存现场,取得引发缺页的虚拟地址;
|
||||
2. 检查地址是否合法,地址不属于进程的任何区域或者权限不符(例如写代码段)时,向进程发送 SIGSEGV 信号,也就是常见的段错误;
|
||||
3. 按页的来源准备页框:第一次访问的堆、栈等匿名页分配一个页框并清零;代码和 mmap 映射的文件页先在页缓存中找,找到就直接映射,找不到才分配页框并读磁盘;换出到交换区的页从交换区读回;fork 之后写共享的页时复制一份(写时复制);
|
||||
4. 需要页框而内存不足时,先用页面置换算法换出一页,被修改过的页要先写回磁盘;
|
||||
5. 更新页表项,回到用户态重新执行引发缺页的那条指令。
|
||||
|
||||
需要读磁盘的缺页称为主要缺页(Major Fault),进程要阻塞到 I/O 完成,期间 CPU 去运行其它进程;不需要 I/O 的称为次要缺页(Minor Fault),只需分配、清零或复制页框并修改页表。缺页异常属于异常,在执行某条指令的过程中同步产生,处理完后重新执行这条指令;外部中断由设备异步产生,处理完后继续执行下一条指令,见[中断分类](计算机操作系统%20-%20概述.md#中断分类)。
|
||||
|
||||
例如用 mmap 申请 256 MB 的匿名内存后,进程的常驻内存(RSS)几乎不变;逐页写入一遍,触发 65536 次次要缺页(256 MB / 4 KB),RSS 增加到 257 MB,再写一遍就不再缺页。fork 出的子进程把这些页再写一遍,又触发 65536 次缺页,每次复制一页,父进程中的值不受影响(Ubuntu 24.04 实测,见[附录 A2](#a2-缺页次数))。这也说明申请内存只是分配了虚拟地址,真正用到时才占用物理内存。
|
||||
|
||||
## 页面置换算法
|
||||
|
||||
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
|
||||
@@ -62,7 +77,7 @@
|
||||
7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1
|
||||
```
|
||||
|
||||
开始运行时,先将 7、0、1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7 再次被访问的时间最长。按这个规则走完整个序列共缺页 9 次;同样的序列和 3 个物理块,LRU 缺页 12 次,FIFO 缺页 15 次(逐步模拟,见[附录:实测](#附录实测))。
|
||||
开始运行时,先将 7、0、1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7 再次被访问的时间最长。按这个规则走完整个序列共缺页 9 次;同样的序列和 3 个物理块,LRU 缺页 12 次,FIFO 缺页 15 次(逐步模拟,见[附录 A1](#a1-页面置换模拟))。
|
||||
|
||||
### 2. 最近最久未使用
|
||||
|
||||
@@ -134,8 +149,19 @@ FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问
|
||||
|
||||
需要注意的是,x86-64 的 64 位模式下分段基本不再使用,代码段、数据段的基址都按 0 处理(FS、GS 除外,用于线程局部存储),Linux 等系统只用分页来管理内存。
|
||||
|
||||
虽然不用分段,Linux 仍然把进程的地址空间划分成代码、数据、堆、栈等区域,每个区域是一段连续的虚拟地址,区域之间的大片空白不分配页框。下图是 Ubuntu 上一个小程序打印出的各部分地址(实测,见[附录 A3](#a3-进程的地址空间布局)):
|
||||
|
||||
<div align="center"> <img src="pics/proc-vm-layout.png" width="740px"/> </div><br>
|
||||
|
||||
- 代码段、数据段和 BSS 在低地址,大小在程序加载时就确定了,BSS 存放未初始化的全局变量,加载时清零;
|
||||
- 堆从低地址向高地址增长,malloc 申请小块内存时通过 brk 扩大堆,大块内存(glibc 的阈值初始为 128 KB)改用 mmap 单独映射;
|
||||
- 栈从高地址向低地址增长,Ubuntu 默认最大 8 MB(ulimit -s);
|
||||
- 共享库、mmap 映射的文件和大块内存放在堆和栈之间的内存映射区。
|
||||
|
||||
## 附录:实测
|
||||
|
||||
### A1 页面置换模拟
|
||||
|
||||
用 Python 逐步模拟 3 个物理块下的页面置换,引用序列为 OPT 一节中的例子。Clock 的结果与新装入页面的 R 位取 0 还是 1、指针从哪里开始有关,这里装入时置 1:
|
||||
|
||||
```text
|
||||
@@ -146,8 +172,73 @@ FIFO 缺页 15 次
|
||||
Clock 缺页 14 次
|
||||
```
|
||||
|
||||
### A2 缺页次数
|
||||
|
||||
Docker Desktop 中的 Ubuntu 24.04 容器(Apple M1 Pro,aarch64,内核 6.10.14-linuxkit,页大小 4 KB),gcc 13.3。用 mmap 申请 256 MB 匿名内存,用 getrusage 的 ru_minflt、ru_majflt 统计缺页次数,从 /proc/self/status 读 VmRSS:
|
||||
|
||||
```c
|
||||
char *p = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
madvise(p, size, MADV_NOHUGEPAGE);
|
||||
for (size_t i = 0; i < size; i += page) p[i] = 1; // 逐页写入
|
||||
for (size_t i = 0; i < size; i += page) p[i] = 2; // 再写一遍
|
||||
if (fork() == 0) {
|
||||
for (size_t i = 0; i < size; i += page) p[i] = 3; // 子进程写入,触发写时复制
|
||||
_exit(0);
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
页大小 4096 字节,共 65536 页,MADV_NOHUGEPAGE
|
||||
mmap 之后: 次要缺页 +0,RSS 1 MB
|
||||
逐页写入后:次要缺页 +65536,主要缺页 0,RSS 257 MB
|
||||
再写一遍: 次要缺页 +0
|
||||
子进程逐页写入(写时复制):次要缺页 +65536
|
||||
父进程中的值仍为 2
|
||||
```
|
||||
|
||||
容器所在虚拟机的透明大页设置为 always,不调用 madvise 时内核用 2 MB 的大页映射,逐页写入只触发 128 次缺页(256 MB / 2 MB);Ubuntu 默认的设置是 madvise,不主动申请时使用 4 KB 的页,与上面的结果一致。
|
||||
|
||||
### A3 进程的地址空间布局
|
||||
|
||||
同一个容器,gcc 13.3 编译(默认生成位置无关的可执行文件),开启地址空间布局随机化,打印各部分的地址和 /proc/self/maps(节选):
|
||||
|
||||
```c
|
||||
int inited = 1024; // 数据段
|
||||
int uninited; // BSS
|
||||
int main(void) {
|
||||
int local = 0; // 栈
|
||||
char *small = malloc(64); // 堆
|
||||
char *big = malloc(1 << 20); // 超过 mmap 阈值,单独映射
|
||||
printf("代码 main %p\n", (void *)main);
|
||||
// ……依次打印 &inited、&uninited、small、big、printf、&local,再输出 /proc/self/maps
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
代码 main 0xaaaaafbc0a18
|
||||
数据 inited 0xaaaaafbe0010
|
||||
BSS uninited 0xaaaaafbe0018
|
||||
堆 small 0xaaaacdcad2a0
|
||||
映射 big 0xffff9928f010
|
||||
共享库 printf 0xffff993e0590
|
||||
栈 local 0xffffcdcad8fc
|
||||
aaaaafbc0000-aaaaafbc1000 r-xp 00000000 00:40 841313 /tmp/layout
|
||||
aaaaafbe0000-aaaaafbe1000 rw-p 00010000 00:40 841313 /tmp/layout
|
||||
aaaacdcad000-aaaacdcce000 rw-p 00000000 00:00 0 [heap]
|
||||
ffff9928f000-ffff99390000 rw-p 00000000 00:00 0
|
||||
ffff99390000-ffff9952a000 r-xp 00000000 00:40 831743 /usr/lib/aarch64-linux-gnu/libc.so.6
|
||||
ffffcdc8d000-ffffcdcae000 rw-p 00000000 00:00 0 [stack]
|
||||
$ ulimit -s
|
||||
8192
|
||||
```
|
||||
|
||||
再运行一次,各区域的起始地址都变了(例如 main 为 0xaaaabc070a18、local 为 0xffffd6a9311c),上下顺序不变。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.
|
||||
- Silberschatz A, Galvin P B, Gagne G. Operating System Concepts [M]. Wiley, 2018.
|
||||
- [Page Tables — The Linux Kernel documentation](https://docs.kernel.org/mm/page_tables.html)
|
||||
- [getrusage(2) — Linux manual page](https://man7.org/linux/man-pages/man2/getrusage.2.html)
|
||||
- [proc_pid_maps(5) — Linux manual page](https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html)
|
||||
- [mallopt(3) — Linux manual page](https://man7.org/linux/man-pages/man3/mallopt.3.html)
|
||||
|
||||
+97
-10
@@ -19,6 +19,8 @@
|
||||
* [1. 外中断](#1-外中断)
|
||||
* [2. 异常](#2-异常)
|
||||
* [3. 陷入](#3-陷入)
|
||||
* [附录:实测](#附录实测)
|
||||
* [A1 普通函数调用与系统调用的耗时](#a1-普通函数调用与系统调用的耗时)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -49,7 +51,7 @@
|
||||
|
||||
多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。
|
||||
|
||||
虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。
|
||||
虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,产生缺页中断,把该页调入内存;内存已满时,还要用页面置换算法选择一页换出。
|
||||
|
||||
### 4. 异步
|
||||
|
||||
@@ -73,25 +75,65 @@
|
||||
|
||||
完成用户的 I/O 请求,方便用户使用各种设备,并提高设备的利用率。
|
||||
|
||||
主要包括缓冲管理、设备分配、设备处理、虛拟设备等。
|
||||
主要包括缓冲管理、设备分配、设备处理、虚拟设备等。
|
||||
|
||||
## 系统调用
|
||||
|
||||
如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。
|
||||
CPU 通过特权级(privilege level)限制程序能做的事。x86 上用当前特权级(Current Privilege Level, CPL)表示,取值为 0~3,数字越小权限越高。操作系统内核运行在 0 级,称为内核态(kernel mode);应用程序运行在 3 级,称为用户态(user mode)。
|
||||
|
||||
<div align="center"> <img src="pics/os-syscall.png" width="740px"/> </div><br>
|
||||
划分两种状态是为了保护内核和其它进程。如果应用程序能随意修改页表基址寄存器 CR3,就能访问其它进程的内存;如果能直接操作磁盘和网卡,就绕过了文件系统和网络协议栈的管理。因此修改 CR3、加载 GDT 等特权指令只能在 CPL 为 0 时执行,用户程序执行时会产生通用保护异常(General Protection Exception, #GP)。
|
||||
|
||||
应用程序需要内核完成的工作,例如读写文件、创建进程、收发网络数据,只能通过系统调用(system call)请求内核。系统调用是用户态进入内核态的受控入口:陷入时跳转的地址由内核预先设定(x86-64 上由内核写入 IA32_LSTAR 寄存器),程序不能指定跳转的地址,否则就能跳到内核的任意位置。
|
||||
|
||||
Linux 的系统调用主要有以下这些:
|
||||
|
||||
| Task | Commands |
|
||||
| 类别 | 系统调用 |
|
||||
| :---: | --- |
|
||||
| 进程控制 | fork(); exit(); wait(); |
|
||||
| 进程通信 | pipe(); shmget(); mmap(); |
|
||||
| 文件操作 | open(); read(); write(); |
|
||||
| 设备操作 | ioctl(); read(); write(); |
|
||||
| 信息维护 | getpid(); alarm(); sleep(); |
|
||||
| 信息维护 | getpid(); alarm(); nanosleep(); |
|
||||
| 安全 | chmod(); umask(); chown(); |
|
||||
|
||||
以 `read(3, buf, 4096)` 为例(Linux x86-64,见下图):读取文件描述符 3 指向的文件,最多 4096 字节放入用户缓冲区 buf。整个过程分为以下几步:
|
||||
|
||||
1. C 库的 read() 把系统调用号放入 rax(read 在 x86-64 上的编号为 0),把参数放入 rdi、rsi、rdx;
|
||||
2. 执行 syscall 指令,CPU 切换到特权级 0,把下一条指令的地址存入 rcx,把 RFLAGS 存入 r11,然后跳转到内核入口;这条指令不保存栈指针 RSP;
|
||||
3. 入口代码切换到当前进程的内核栈,把用户态的 RIP、RSP、RFLAGS 和其它寄存器保存到内核栈上,再调用 do_syscall_64,按编号查系统调用表,找到 sys_read;
|
||||
4. sys_read 按 fd = 3 找到打开的文件,页缓存中有数据时,把 4096 字节复制到 buf,返回值为 4096;
|
||||
5. 内核恢复寄存器,执行 sysret,CPU 回到特权级 3,从 rcx 保存的地址继续执行,C 库把 rax 中的 4096 作为 read() 的返回值交给调用者。
|
||||
|
||||
第 4 步中,内核不直接使用用户传入的指针,而是通过 copy_to_user 等函数访问用户缓冲区,这些函数会先检查地址。
|
||||
|
||||
<div align="center"> <img src="pics/syscall-read-steps.png" width="720px"/> </div><br>
|
||||
|
||||
切换时需要保存的是返回地址、标志寄存器和用户栈指针,以及内核要使用的通用寄存器;返回时必须原样恢复,程序才能从原来的位置继续执行。x86-64 上 syscall 指令只把 RIP 和 RFLAGS 放入 rcx、r11,RSP 和其它寄存器由入口代码保存。
|
||||
|
||||
进入内核态有三种途径:程序主动执行的系统调用(陷入),指令执行时引起的异常(例如缺页),以及来自 CPU 外部的外中断,见[中断分类](#中断分类)。异常和外中断返回时使用 iret,系统调用返回时使用 sysret。
|
||||
|
||||
陷入和返回本身需要的时间并不少。在 Linux 上实测(arm64,见[附录 A1](#a1-普通函数调用与系统调用的耗时)),一次普通函数调用约 1 ns,glibc 的 getppid() 约 167 ns,直接执行陷入指令约 165 ns,一次系统调用约为普通函数调用的 170 倍。getppid 在内核中的工作很少,测得的时间基本就是进入和退出内核的代价。具体数字与 CPU 和内核配置有关,x86-64 上启用 PTI 时会更高。
|
||||
|
||||
开销由以下几部分组成:
|
||||
|
||||
- 特权级切换,以及寄存器的保存和恢复;
|
||||
- 内核入口和出口的额外工作,例如启用页表隔离(Page Table Isolation, PTI,用于缓解 Meltdown 攻击)时,每次进入和退出内核都要写 CR3,内核文档给出的量级是百余个周期;
|
||||
- 内核执行的工作本身,例如参数检查、查找文件、复制数据。
|
||||
|
||||
需要注意的是,陷入内核不等于切换进程:系统调用执行完通常回到同一个线程,不一定发生上下文切换,见[进程管理](计算机操作系统%20-%20进程管理.md#5-上下文切换)。
|
||||
|
||||
减少系统调用开销的做法主要有以下几种:
|
||||
|
||||
- 批量处理:writev 一次系统调用写入多个缓冲区(writev(2));
|
||||
- 在用户态完成:vDSO(virtual dynamic shared object)是内核自动映射到每个进程中的一小段共享代码,在支持的时钟源上,clock_gettime 等调用不再陷入内核(vdso(7)),实测经 vDSO 约 18 ns,绕过 vDSO 直接陷入约 202 ns;
|
||||
- 内存映射:mmap 把文件映射到用户地址空间,之后直接读写内存,不需要每次调用 read() 和 write(),但缺页时仍会陷入内核(内存映射文件见 [Java IO](Java%20IO.md#内存映射文件))。
|
||||
|
||||
排查系统调用的开销时,可以用 strace -c 统计各系统调用的次数,用 strace -T 查看每次调用的耗时(strace(1))。
|
||||
|
||||
写文件也要经过陷入。例如 `write(fd, buf, 4096)` 陷入内核后,内核把 buf 的内容复制到页缓存(page cache,内存中的文件缓存)并标记为脏页,然后返回 4096。此时数据未必已经写到磁盘,磁盘写入由内核之后异步完成,或由 fsync() 强制完成。write(2) 的手册明确说明,成功返回并不保证数据已经提交到磁盘。
|
||||
|
||||
零拷贝(zero-copy)减少的是数据在用户空间和内核空间之间的复制。sendfile 在内核中把文件内容直接送到 socket,既不经过用户缓冲区,也把 read() 和 write() 两次调用合并为一次(sendfile(2)),Kafka 的 broker 发送消息就是这样做的,见[消息队列](消息队列.md#为什么吞吐量高)。
|
||||
|
||||
## 宏内核和微内核
|
||||
|
||||
### 1. 宏内核
|
||||
@@ -106,25 +148,70 @@ Linux 的系统调用主要有以下这些:
|
||||
|
||||
在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。
|
||||
|
||||
因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。
|
||||
因为需要频繁地在用户态和内核态之间进行切换,所以会有一定的性能损失。
|
||||
|
||||
<div align="center"> <img src="pics/os-kernels.png" width="760px"/> </div><br>
|
||||
|
||||
## 中断分类
|
||||
|
||||
进入内核态的事件按引起的原因分为外中断、异常和陷入三类。外中断和异常通过中断描述符表(Interrupt Descriptor Table, IDT)找到处理程序,特权级切换时,硬件把旧的 SS、RSP、RFLAGS、CS、RIP 压入内核栈(Intel SDM Vol. 3A 第 7 章)。系统调用则由 syscall 指令直接跳转,不经过 IDT。
|
||||
|
||||
### 1. 外中断
|
||||
|
||||
由 CPU 执行指令以外的事件引起,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
|
||||
外中断(external interrupt)由 CPU 外部的事件引起,与当前执行的指令无关,因此是异步的。例如磁盘完成 I/O 后发出 I/O 完成中断,表示设备的输入输出已经完成,处理器可以发送下一个 I/O 请求;时钟中断周期性地到来,是时间片轮转的基础。外中断经由 CPU 引脚或本地 APIC(Advanced Programmable Interrupt Controller)送达。
|
||||
|
||||
### 2. 异常
|
||||
|
||||
由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。
|
||||
异常(exception)由正在执行的指令引起,是同步的,指令执行到哪里,异常就在哪里发生。例如访问未映射的地址会触发缺页异常(page fault),内核分配物理页后,同一条指令可以重新执行(见[虚拟内存](计算机操作系统 - 内存管理.md#虚拟内存));执行除法指令时除数为 0,会触发除法错误(#DE);遇到非法操作码会触发 #UD。
|
||||
|
||||
需要注意的是,x86 上整数加法溢出只设置标志位,不会产生异常,因此这里用除数为 0 作例子。
|
||||
|
||||
### 3. 陷入
|
||||
|
||||
在用户程序中使用系统调用。
|
||||
陷入(trap)由程序主动执行的指令引起,同样是同步的。系统调用就是通过陷入实现的:x86-64 上是 syscall 指令,arm64 上是 svc 指令(syscalls(2))。
|
||||
|
||||
Intel 手册中的 trap 指异常在指令执行之后报告,例如 INT3、INTO;syscall 不属于 IDT 中的异常,因此两者的含义并不完全相同。
|
||||
|
||||
## 附录:实测
|
||||
|
||||
### A1 普通函数调用与系统调用的耗时
|
||||
|
||||
环境:Docker Desktop 的 Linux 虚拟机,Ubuntu 24.04 容器,内核 6.10.14-linuxkit(aarch64,宿主为 Apple M1 Pro),gcc 13.3.0,编译选项 -O2。arm64 上 getppid 的系统调用号为 173,陷入指令为 svc #0。核心代码如下:
|
||||
|
||||
```c
|
||||
static __attribute__((noinline)) long add1(long x) { return x + 1; }
|
||||
|
||||
static long raw_getppid(void) {
|
||||
register long x8 asm("x8") = SYS_getppid; /* 系统调用号放在 x8 */
|
||||
register long x0 asm("x0"); /* 返回值在 x0 */
|
||||
asm volatile("svc #0" : "=r"(x0) : "r"(x8) : "memory", "cc");
|
||||
return x0;
|
||||
}
|
||||
|
||||
/* 每种方式循环 10^7 次,clock_gettime(CLOCK_MONOTONIC) 计时,7 轮取中位数 */
|
||||
for (long i = 0; i < n; i++) x = add1(x); /* function_call */
|
||||
for (long i = 0; i < n; i++) acc += getppid(); /* getppid_libc */
|
||||
for (long i = 0; i < n; i++) acc += raw_getppid(); /* svc_raw_getppid */
|
||||
for (long i = 0; i < n; i++) clock_gettime(CLOCK_MONOTONIC, &ts); /* clock_gettime_vdso */
|
||||
for (long i = 0; i < n; i++) syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &ts); /* clock_gettime_syscall */
|
||||
```
|
||||
|
||||
原始输出(三次独立运行,单位为每次调用的纳秒数):
|
||||
|
||||
```text
|
||||
run function_call getppid_libc svc_raw_getppid clock_gettime_vdso clock_gettime_syscall
|
||||
1 0.94 167.44 165.26 18.01 201.36
|
||||
2 0.94 167.24 165.26 17.94 201.85
|
||||
3 0.95 167.81 165.21 17.94 202.94
|
||||
```
|
||||
|
||||
反汇编显示循环体调用的是 add1(bl add1),直接陷入的路径中是 mov x8, #0xad 和 svc #0x0。glibc 的 getppid 与直接 svc 只相差约 2 ns,说明主要开销在陷入本身,而不是库函数的包装。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.
|
||||
- Silberschatz A, Galvin P B, Gagne G. Operating System Concepts [M]. Wiley, 2018.
|
||||
- Arpaci-Dusseau R H, Arpaci-Dusseau A C. Operating Systems: Three Easy Pieces [M]. Arpaci-Dusseau Books, 2018. 第 6 章 Mechanism: Limited Direct Execution([在线版](https://pages.cs.wisc.edu/~remzi/OSTEP/))。
|
||||
- Intel. Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 2B(SYSCALL、SYSRET)和 Volume 3A(第 6、7 章)[M]. Intel, 2026([SDM 下载页](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html))。
|
||||
- [syscalls(2)](https://man7.org/linux/man-pages/man2/syscalls.2.html)、[syscall(2)](https://man7.org/linux/man-pages/man2/syscall.2.html)、[write(2)](https://man7.org/linux/man-pages/man2/write.2.html)、[writev(2)](https://man7.org/linux/man-pages/man2/writev.2.html)、[sendfile(2)](https://man7.org/linux/man-pages/man2/sendfile.2.html)、[vdso(7)](https://man7.org/linux/man-pages/man7/vdso.7.html)、[strace(1)](https://man7.org/linux/man-pages/man1/strace.1.html)。
|
||||
- Linux 内核源码:[syscall_64.tbl](https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl)、[entry_64.S](https://github.com/torvalds/linux/blob/master/arch/x86/entry/entry_64.S)、[uaccess.h](https://github.com/torvalds/linux/blob/master/include/linux/uaccess.h)、[Page Table Isolation (PTI)](https://github.com/torvalds/linux/blob/master/Documentation/arch/x86/pti.rst)。
|
||||
|
||||
+87
-4
@@ -21,14 +21,21 @@
|
||||
* [1. Java 线程死锁](#1-java-线程死锁)
|
||||
* [2. MySQL 死锁](#2-mysql-死锁)
|
||||
* [附录:实测](#附录实测)
|
||||
* [A1 Java 线程死锁](#a1-java-线程死锁)
|
||||
* [A2 MySQL 死锁](#a2-mysql-死锁)
|
||||
* [A3 先查后插的间隙锁死锁](#a3-先查后插的间隙锁死锁)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
|
||||
## 必要条件
|
||||
|
||||
死锁指的是一组进程中的每个进程都在等待只有组内其它进程才能释放的资源,结果谁都无法继续执行,也不会自己结束。例如下图中 T1 持有 alice 等待 bob,T2 持有 bob 等待 alice。
|
||||
|
||||
<div align="center"> <img src="pics/deadlock-transfer.png" width="740px"/> </div><br>
|
||||
|
||||
只有以下四个条件同时成立时才会发生死锁:
|
||||
|
||||
- 互斥:每个资源要么已经分配给了一个进程,要么就是可用的;
|
||||
- 占有和等待:已经得到了某个资源的进程可以再请求新的资源;
|
||||
- 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放;
|
||||
@@ -110,6 +117,8 @@
|
||||
|
||||
已经占有资源的进程请求新资源得不到满足时,必须释放已经占有的资源,之后需要时再重新申请。
|
||||
|
||||
分布式锁设置过期时间也是破坏这个条件:持有锁的进程崩溃后不会释放锁,到期后锁自动失效,相当于被强制收回。代价是业务还没执行完锁就可能过期,被另一个客户端拿到,因此还需要续期,见 [分布式锁](分布式.md#redis-的-set-nx-指令)。
|
||||
|
||||
### 4. 破坏环路等待
|
||||
|
||||
给资源统一编号,进程只能按编号顺序来请求资源。
|
||||
@@ -194,7 +203,7 @@ synchronized (first) {
|
||||
}
|
||||
```
|
||||
|
||||
两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录:实测](#附录实测))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。
|
||||
两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录 A1](#a1-java-线程死锁))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。
|
||||
|
||||
### 2. MySQL 死锁
|
||||
|
||||
@@ -213,7 +222,26 @@ UPDATE account SET balance = balance + 50 WHERE id = 1;
|
||||
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
|
||||
```
|
||||
|
||||
InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(倾向于回滚修改行数少的事务),另一个事务继续执行。上例中事务 2 被回滚,事务 1 提交后余额为 900 和 1100(MySQL 8.0 实测,见[附录:实测](#附录实测))。执行 SHOW ENGINE INNODB STATUS,在 LATEST DETECTED DEADLOCK 一节中可以看到两个事务各自持有和等待的锁。被回滚的事务需要由应用重试。
|
||||
InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(倾向于回滚修改行数少的事务),另一个事务继续执行。上例中事务 2 被回滚,事务 1 提交后余额为 900 和 1100(MySQL 8.0 实测,见[附录 A2](#a2-mysql-死锁))。执行 SHOW ENGINE INNODB STATUS,在 LATEST DETECTED DEADLOCK 一节中可以看到两个事务各自持有和等待的锁。被回滚的事务需要由应用重试。
|
||||
|
||||
按相同顺序加锁并不能避免所有死锁。可重复读隔离级别下,常见的“先查有没有,没有再插入”也会死锁,例如两个事务分别为订单 1024 和 1025 写支付记录,表中已有 1000 和 2000 两行,两个事务先后执行 SELECT,再先后执行 INSERT:
|
||||
|
||||
```sql
|
||||
-- 事务 1
|
||||
BEGIN;
|
||||
SELECT * FROM payment WHERE order_id = 1024 FOR UPDATE; -- 没有这一行,锁住间隙 (1000, 2000)
|
||||
INSERT INTO payment VALUES (1024, 100); -- 等待事务 2 的间隙锁
|
||||
|
||||
-- 事务 2
|
||||
BEGIN;
|
||||
SELECT * FROM payment WHERE order_id = 1025 FOR UPDATE; -- 同一个间隙,间隙锁之间互不冲突
|
||||
INSERT INTO payment VALUES (1025, 50);
|
||||
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
|
||||
```
|
||||
|
||||
两个事务都拿到了同一个间隙上的间隙锁,插入前要申请的插入意向锁却和对方的间隙锁冲突,于是互相等待(MySQL 8.0 实测,见[附录 A3](#a3-先查后插的间隙锁死锁))。去掉 SELECT ... FOR UPDATE,直接 INSERT 并依靠主键或唯一索引判重即可,重复时会返回 1062 错误;改用提交读隔离级别时不加这种间隙锁,同样的语句不会死锁,但查询也就挡不住并发插入,判重同样要靠唯一索引。间隙锁的加锁范围见 [Next-Key Locks](数据库系统原理.md#六next-key-locks)。
|
||||
|
||||
排查数据库死锁时,SHOW ENGINE INNODB STATUS 只保留最近一次死锁,打开 innodb_print_all_deadlocks 后每次死锁都会写入错误日志。锁等待还没有形成死锁时,可以查询 performance_schema.data_locks 和 data_lock_waits,看到每个事务持有和等待的锁,例如上例中事务 1 的插入意向锁处于 WAITING 状态。
|
||||
|
||||
减少数据库死锁的方法:
|
||||
|
||||
@@ -223,7 +251,9 @@ InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(
|
||||
|
||||
## 附录:实测
|
||||
|
||||
Java 线程死锁:JDK 21,两个线程分别执行 10 万次 transfer(a, b, 1) 和 transfer(b, a, 1),设为守护线程,各等待 3 秒后检查是否结束,各运行 3 次:
|
||||
### A1 Java 线程死锁
|
||||
|
||||
JDK 21,两个线程分别执行 10 万次 transfer(a, b, 1) 和 transfer(b, a, 1),设为守护线程,各等待 3 秒后检查是否结束,各运行 3 次:
|
||||
|
||||
```text
|
||||
原写法:stuck
|
||||
@@ -234,7 +264,9 @@ Java 线程死锁:JDK 21,两个线程分别执行 10 万次 transfer(a, b, 1
|
||||
按 id 顺序加锁:1000 1000
|
||||
```
|
||||
|
||||
MySQL 死锁:MySQL 8.0.44,account 表中 id 为 1、2 的两行余额都是 1000,用两个连接按正文的顺序执行,最后执行 SHOW ENGINE INNODB STATUS(节选):
|
||||
### A2 MySQL 死锁
|
||||
|
||||
MySQL 8.0.44,account 表中 id 为 1、2 的两行余额都是 1000,用两个连接按正文的顺序执行,最后执行 SHOW ENGINE INNODB STATUS(节选):
|
||||
|
||||
```text
|
||||
事务 1 的第二条 UPDATE: OK
|
||||
@@ -252,7 +284,58 @@ UPDATE account SET balance = balance + 50 WHERE id = 1
|
||||
*** WE ROLL BACK TRANSACTION (2)
|
||||
```
|
||||
|
||||
### A3 先查后插的间隙锁死锁
|
||||
|
||||
MySQL 8.0.44,payment 表的主键为 order_id,已有 1000、2000 两行,用两个连接按正文的顺序执行,在事务 1 的 INSERT 等待时查询 performance_schema.data_locks 的事务 ID、锁类型、模式、状态和锁住的记录,最后执行 SHOW ENGINE INNODB STATUS(节选):
|
||||
|
||||
```text
|
||||
== REPEATABLE READ
|
||||
事务 1 SELECT 1024 FOR UPDATE: ()
|
||||
事务 2 SELECT 1025 FOR UPDATE: ()
|
||||
事务 1 INSERT 1024 等待中时的锁:
|
||||
(1150774, 'TABLE', 'IX', 'GRANTED', None)
|
||||
(1150774, 'RECORD', 'X,GAP', 'GRANTED', '2000')
|
||||
(1150774, 'RECORD', 'X,GAP,INSERT_INTENTION', 'WAITING', '2000')
|
||||
(1150775, 'TABLE', 'IX', 'GRANTED', None)
|
||||
(1150775, 'RECORD', 'X,GAP', 'GRANTED', '2000')
|
||||
事务 1 INSERT 1024: OK
|
||||
事务 2 INSERT 1025: OperationalError(1213, 'Deadlock found when trying to get lock; try restarting transaction')
|
||||
提交后: ((1000, 10), (1024, 100), (2000, 20))
|
||||
LATEST DETECTED DEADLOCK
|
||||
*** (1) TRANSACTION:
|
||||
INSERT INTO payment VALUES (1024, 100)
|
||||
*** (1) HOLDS THE LOCK(S):
|
||||
RECORD LOCKS space id 166 page no 4 n bits 72 index PRIMARY of table `demo`.`payment` trx id 1150774 lock_mode X locks gap before rec
|
||||
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
|
||||
RECORD LOCKS space id 166 page no 4 n bits 72 index PRIMARY of table `demo`.`payment` trx id 1150774 lock_mode X locks gap before rec insert intention ...
|
||||
*** (2) TRANSACTION:
|
||||
INSERT INTO payment VALUES (1025, 50)
|
||||
*** WE ROLL BACK TRANSACTION (2)
|
||||
```
|
||||
|
||||
间隙锁记在间隙右边的记录 2000 上(gap before rec)。同样的语句在提交读隔离级别下执行,两个 SELECT ... FOR UPDATE 只加了表级的意向锁,两条 INSERT 都成功:
|
||||
|
||||
```text
|
||||
== READ COMMITTED
|
||||
事务 1 INSERT 1024 等待中时的锁:
|
||||
(1150797, 'TABLE', 'IX', 'GRANTED', None)
|
||||
(1150798, 'TABLE', 'IX', 'GRANTED', None)
|
||||
事务 1 INSERT 1024: OK
|
||||
事务 2 INSERT 1025: OK
|
||||
提交后: ((1000, 10), (1024, 100), (1025, 50), (2000, 20))
|
||||
```
|
||||
|
||||
直接插入重复的主键:
|
||||
|
||||
```text
|
||||
ERROR 1062 (23000) at line 1: Duplicate entry '1024' for key 'payment.PRIMARY'
|
||||
```
|
||||
|
||||
执行 SET GLOBAL innodb_print_all_deadlocks = ON 后重跑可重复读的例子,错误日志中同样记录了两个事务持有的间隙锁和等待的插入意向锁(lock_mode X locks gap before rec insert intention waiting)。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.
|
||||
- [Deadlocks in InnoDB | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html)
|
||||
- [InnoDB Locking | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html)
|
||||
- [The data_locks Table | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html)
|
||||
|
||||
+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