From de2d1b763878673f2e05005f9ace42871e9f8b3b Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Thu, 29 Oct 2026 04:55:49 +0800 Subject: [PATCH] =?UTF-8?q?Update=20=E8=AE=A1=E7=AE=97=E6=9C=BA=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=B3=BB=E7=BB=9F=20-=20=E8=BF=9B=E7=A8=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 Linux 中进程与线程的创建方式、有栈与无栈协程,附录 A4。 --- notes/计算机操作系统 - 进程管理.md | 50 +++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/notes/计算机操作系统 - 进程管理.md b/notes/计算机操作系统 - 进程管理.md index dce7888e..736701e0 100644 --- a/notes/计算机操作系统 - 进程管理.md +++ b/notes/计算机操作系统 - 进程管理.md @@ -31,6 +31,7 @@ * [A1 上下文切换](#a1-上下文切换) * [A2 平台线程与虚拟线程的开销](#a2-平台线程与虚拟线程的开销) * [A3 POSIX 共享内存](#a3-posix-共享内存) + * [A4 fork 与 pthread_create 的 clone 标志](#a4-fork-与-pthread_create-的-clone-标志) * [参考资料](#参考资料) @@ -69,6 +70,15 @@ QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 H - 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC; - 健壮性:进程之间相互隔离,一个进程崩溃不影响其它进程,而一个线程访问非法内存触发段错误时,整个进程连同其它线程都会被终止。 +在 Linux 内核中,进程和线程都用 task_struct 表示,都是调度的对象,区别只在于和创建者共享了多少资源。fork 和 pthread_create 最终都调用 clone 系统调用,只是传入的标志不同(strace 实测,见[附录 A4](#a4-fork-与-pthread_create-的-clone-标志)): + +```text +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 中看到的就是它。 + 因此选择多进程还是多线程,主要看更需要隔离还是共享: - 任务之间需要隔离、一个出错不能拖垮其它任务时用多进程,例如 Chrome 把网页放在单独的渲染进程中,一个网页崩溃不影响浏览器和其它标签页;执行不受信任的代码(例如 Agent 运行模型生成的脚本)也应该放在单独的进程甚至容器中; @@ -102,7 +112,9 @@ Go 的 GMP 模型是一个典型实现:G 是 goroutine,M 是系统线程,P - 占用的内存少,可以同时存在大量协程,而每个线程都要预留独立的栈,例如 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 时从平台线程上卸载,让平台线程去执行其它虚拟线程。调度器是一个工作窃取的 ForkJoinPool,平台线程数默认等于 CPU 核数,JDK 21 不按时间片抢占虚拟线程。虚拟线程的创建成本很低,不需要用线程池复用,需要限制并发数时使用信号量。在 JDK 21 中,虚拟线程在 synchronized 块内阻塞时无法卸载,JDK 24 起([JEP 491](https://openjdk.org/jeps/491))基本消除了这个限制。 +协程按是否有自己的调用栈分为两类。有栈协程(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))基本消除了这个限制。 例如在 JDK 21 中启动 1 万个阻塞在同一个 CountDownLatch 上的线程(Ubuntu 24.04 容器实测,见[附录 A2](#a2-平台线程与虚拟线程的开销)): @@ -640,6 +652,41 @@ $ gcc -O2 -o shm shm.c && ./shm 读到:order 1024 paid ``` +### A4 fork 与 pthread_create 的 clone 标志 + +Docker Desktop 中的 Ubuntu 24.04 容器(aarch64),gcc 13.3,strace 6.8。程序先 fork 一个子进程,子进程把全局变量 counter 加 1;再用 pthread_create 创建一个线程,线程也把 counter 加 1: + +```c +int counter = 0; +static void *worker(void *arg) { + counter++; + printf("线程 pid=%d tid=%d\n", getpid(), gettid()); + return NULL; +} +// main 中: +pid_t c = fork(); +if (c == 0) { counter++; printf("子进程 pid=%d tid=%d\n", getpid(), gettid()); _exit(0); } +waitpid(c, NULL, 0); +printf("fork 之后 counter=%d\n", counter); +pthread_create(&t, NULL, worker, NULL); +pthread_join(t, NULL); +printf("线程结束后 counter=%d\n", counter); +``` + +```text +$ strace -f -e trace=clone,clone3 ./pt +主线程 pid=14 tid=14 +clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0xffff8716dfd0) = 15 +子进程 pid=15 tid=15 +fork 之后 counter=0 +clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, ..., stack_size=0x80ea60, ...}, 88) = -1 ENOSYS (Function not implemented) +clone(child_stack=0xffff86e0ea60, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, ...) = 16 +线程 pid=14 tid=16 +线程结束后 counter=1 +``` + +Docker 默认的 seccomp 规则不允许 clone3,glibc 收到 ENOSYS 后改用 clone,标志相同。线程的栈大小 0x80ea60 约 8 MB,来自 ulimit -s 的默认值。 + ## 参考资料 - Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014. @@ -651,3 +698,4 @@ $ gcc -O2 -o shm shm.c && ./shm - [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/) +- [clone(2) — Linux manual page](https://man7.org/linux/man-pages/man2/clone.2.html)