Linux C++开发者需要深入理解的进程知识

发布时间:2026/6/30 14:51:20
Linux C++开发者需要深入理解的进程知识 为了理解进程相关的概念需要首先弄懂进程在 Linux 内核中是如何表示的。在 Linux 中无论进程还是线程在内核中都叫 task用结构体 task_struct 来表示这个结构比较庞大源码有几百行之多。我摘取了部分字段放在了下面可以对 task_struct 有一个大致的了解。如下图所示task_struct 包含了很多有用的信息比如进程的状态 state、进程 pid、进程打开的文件描述符 files、内存管理结构体指针 mm、进程所属的文件系统信息等。使用 systemtap 可以探究 task_struct 的内部细节一个简单的模板如下%{ #include linux/list.h #include linux/sched.h %} function process_list () %{ struct task_struct *p; for_each_process(p) { _stp_printf(process: %s, pid: %d, p-comm, p-pid); } %} probe begin { process_list(); exit(); }通过 for_each_process 方法可以遍历当前系统所有的 task_struct在 for 循环中就可以获取到 task_struct 的所有内部字段了。进程状态 statetask_struct 结构体中的 state 字段表示进程的状态。struct task_struct { volatile long state; /* -1 unrunnable, 0 runnable, 0 stopped */ ... }完整的 state 定义在include/linux/sched.h头文件中。#define TASK_RUNNING 0 #define TASK_INTERRUPTIBLE 1 #define TASK_UNINTERRUPTIBLE 2 #define __TASK_STOPPED 4 #define __TASK_TRACED 8 /* in tsk-exit_state */ #define EXIT_ZOMBIE 16 #define EXIT_DEAD 32 /* in tsk-state again */ #define TASK_DEAD 64 #define TASK_WAKEKILL 128 #define TASK_WAKING 256 #define TASK_PARKED 512 #define TASK_STATE_MAX 1024这些状态会在后面的小节中再详细介绍。pid 和 tgidtask_struct 中与进程 id 的有关的主要是下面这两个struct task_struct { pid_t pid; pid_t tgid; }那为什么需要两个 id 呢前面提到过进程和线程本质都是 task_struct理论上用 pid 就可以唯一标识一个 task 了。进程中的每个线程的 pid 都不一样但对外表现出来为一个进程整体它们有一个共同的 thread group ID简称 tgid。以下面的代码为例#include stdio.h #include pthread.h #include stdlib.h void *foo(void *args) { sleep(1000); } int main() { pthread_t t[4]; int i; for (i 0; i 4; i) { pthread_create(t[i], NULL, foo, NULL); } for (i 0; i 4; i) { pthread_join(t[i], NULL); } return 0; }编译运行上面这段代码$ gcc pid_tgid_test.c -lpthread使用 ps -T 可以查看进程所有的线程这个例子会有 1 个主线程4 个子线程。$ ps -T -e -o pid,tid,state,command PID TID S COMMAND 13538 13538 S ./a.out 13538 13539 S ./a.out 13538 13540 S ./a.out 13538 13541 S ./a.out 13538 13542 S ./a.outps 输出中的 PID 实际上是 task_struct 中 tgidTID 才是真正 task_struct 中的 pid如下图所示内存管理 mmtask_struct 跟内存管理相关的最重要的字段是 mm。struct task_struct { struct mm_struct *mm; }每个进程都有自己独立的虚拟地址空间使用 mm_struct 结构体来管理内存。这里的 mm 指针指向了 mm_struct 结构体包含了内存资源的页表、内存映射等它的部分源码如下struct mm_struct { struct vm_area_struct * mmap; /* list of VMAs */ struct rb_root mm_rb; ... pgd_t * pgd; ... }mm_struct 结构体包含了串联起 VMA 的单链表 mmap。为了更快地查找和分裂更新这里还有一个红黑树结构表示的 mm_rb。pgd 字段指向 PGD 页表这部分内容在内存管理的章节会有更详细的介绍。文件与文件系统task_struct 中跟文件相关在字段最常用的是下面这两个struct task_struct { struct fs_struct *fs; /* open file information */ struct files_struct *files; }task_struct 中的 fs 字段是一个 fs_struct 结构体指针包含了进程运行的目录信息比如我们在命令行中 cat 一个文件时比如 cat a.txt为什么没有指定 a.txt 的绝对路径也可以打开这个文件呢进程运行的当前目录是保存在 cat 进程的 fs_struct 的 pwd 字段里通过相对路径去访问 a.txt 时我们就知道了 a.txt 文件的完整路径是什么。在 Linux 中一切皆文件打开的文件属于进程的资源。task_struct 中的 files 字段是一个 files_struct 结构体指针files_struct 结构体里最重要的就是打开的文件描述符列表。一个进程启动系统就默认会分配三个文件描述符文件描述符 0 表示 stdin 标准输入文件描述符 1 表示 stdout 标准输出文件描述符 2 表示 stderr 标准错误输出。后面进程打开文件 fd 从 3 开始分配。以下面的 C 代码为例使用 open 系统调用打开当前目录的 a.txt 文件如下所示#include stdio.h #include fcntl.h int main() { int fd open(a.txt, O_RDONLY); printf(fd is %d\n, fd); getchar(); return 0; }运行上面的代码输出结果如下$ gcc test.c; ./a.out fd is 3随后使用pidof a.out查看这个进程的 pid这里为 28200在 linux 的/proc/pid/fd目录里记录了进程打开的所有文件句柄如下所示$ ls -l /proc/28200/fd lrwx------. 1 ya ya 64 Apr 25 21:50 0 - /dev/pts/2 lrwx------. 1 ya ya 64 Apr 25 21:50 1 - /dev/pts/2 lrwx------. 1 ya ya 64 Apr 25 21:50 2 - /dev/pts/2 lr-x------. 1 ya ya 64 Apr 25 21:50 3 - /home/ya/dev/tmp/a.txt可以看到这个进程打开了 4 个文件其中 0~2 指向了 /dev/pts/2 这个伪终端3 号指向了 a.txt 文件。退出码task_struct 中 exit_code 表示进程的退出码struct task_struct { int exit_code; }我们后面会介绍子进程退出时父进程可以通过 waitpid 的方式获取到子进程退出的原因就是通过这个字段来得到的。进程的退出有很多种原因有可能进程正常调用 exit 函数退出也有可能是被信号杀死exit_code 值的含义如下以下面的代码为例#include unistd.h #include stdio.h int main() { pid_t pid; pid fork(); if (pid 0) { printf(child: %d\n, getpid()); exit(7); } else { printf(parent: %d\n, getpid()); getchar(); } return 0; }运行后fork 的子进程通过调用 exit 函数退出变为僵尸进程。我们来用 systemtap 的脚本来查看这个子进程的 exit_code%{ #include linux/list.h #include linux/sched.h %} function process_list () %{ struct task_struct *p; for_each_process(p) { _stp_printf(process: %s, pid: %d, exit_code: 0x%lx\n, p-comm, p-pid, p-exit_code); } %} probe begin { process_list(); exit(); }运行这段 systemtap 的脚本$ sudo stap -g task_dump.stp process: a.out, pid: 2005, exit_code: 0x0 process: a.out, pid: 2006, exit_code: 0x700因为子进程是正常终止符合图中第一种情况高八位是退出状态0x07低八位全是 0所以 exit_code 0x07 8 0x00 0x0700。接下来稍微修改一下上面的测试程序让子进程先不退出#include unistd.h #include stdio.h int main() { pid_t pid; pid fork(); if (pid 0) { printf(child: %d\n, getpid()); getchar(); } else { printf(parent: %d\n, getpid()); getchar(); } return 0; }编译运行上面的程序parent: 2822 child: 2823然后使用 kill -9 杀掉子进程再次使用 systemtap 查看进程 exit_code。$ sudo stap -g task_dump.stp process: a.out, pid: 2822, exit_code: 0x0 process: a.out, pid: 2823, exit_code: 0x9此时的退出码为 0x09这是因为进程为信号所杀死符合图中第二种情况 低七位表示终止信号也就是 0x00 8 0x09 0x09。在 task_struct 结构体里有一个 state 变量标识了进程的状态值完整的状态值见内核源码include/linux/sched.h头文件。常见的有下面这些状态TASK_RUNNINGTASK_INTERRUPTIBLETASK_UNINTERRUPTIBLETASK_STOPPEDTASK_TRACEDEXIT_ZOMBIEEXIT_DEAD进程状态和切换如下图所示使用 ps 命令可以查看进程的状态$ ps -e -o pid,state,command PID S COMMAND 27102 S sshd: ya [priv] 27222 S sshd: yapts/1ps 输出中对应的状态如下D uninterruptible sleep (usually IO)R running or runnable (on run queue)S interruptible sleep (waiting for an event to complete)T stopped by job control signalt stopped by debugger during the tracingW paging (not valid since the 2.6.xx kernel)X dead (should never be seen)Z defunct (zombie) process, terminated but not reaped by its parentps 命令默认显示的是进程中主线程的状态如果想要查看所有线程的状态可以加上-T参数。使用 top 命令也可以查看进程的状态同 ps 一样也是对应 S 列。top 命令默认显示的是进程中主线程的状态如果想要查看所有线程的状态可以加上-H参数。接下来我们来看看详细看看这几个状态。TASK_RUNNING 状态TASK_RUNNING 并不意味着进程已经分配了 CPU 资源正在运行而是指进程处于可运行状态。处于这个状态的进程有可能获得了 CPU 时间片正在执行也有可能没有获得时间片在就绪队列中等待分配时间片。比如我们执行一个跑满 CPU 的单线程程序$ sha256sum /dev/zero使用 ps 查看进程的状态如下$ ps -T -e -o pid,state,command PID S COMMAND 1549 R sha256sum /dev/zeroS 那一列是 R表示进程处于 TASK_RUNNING 状态。TASK_INTERRUPTIBLE 状态TASK_INTERRUPTIBLE 指的是可中断的睡眠状态比如等定时器、等锁、网络 IO 等都是属于 TASK_INTERRUPTIBLE 状态启动 sleep 命令$ sleep 1000然后查看进程状态$ ps -T -e -o pid,state,command PID S COMMAND 1744 S sleep 1000S 那一列是 S表示进程处于 TASK_INTERRUPTIBLE 状态。TASK_UNINTERRUPTIBLE 状态这个状态又被称为 D 状态表示不可中断的睡眠状态与 TASK_INTERRUPTIBLE 不同的是这是一种深度睡眠状态不能被信号唤醒连kill -9也不行。后面有一个小节专门介绍这里先不展开。TASK_STOPPED 状态当一个程序在终端中前台执行时按下 CtrlZ 可以挂起作业比如我们在终端中执行 sleep 10000 命令然后输入 CtrlZ终端中会打印出被停止的命令。$ sleep 10000 ^Z [1] 25729 suspended sleep 10000按下 CtrlZ 实际上是发送了 SIGSTOP 信号给 sleep 进程使其进入了 TASK_STOPPED 状态top 命令的输出如下PID USER PR NI VIRT RES SHR S %CPU %MEM TIME COMMAND 25729 ya 20 0 107956 628 528 T 0.0 0.0 0:00.00 sleep此时的 S 列显示的是 T表示进程处于 TASK_STOPPED 状态而且是因为 job control 信号导致进入了 TASK_STOPPED 状态。同样我们可以用 systemtap 来验证这一说法在内核函数 do_signal_stop 插入探针。probe kernel.function(do_signal_stopkernel/signal.c).call { printf (enter do_signal_stop, process name: %s\n,execname()) print_backtrace() }重新运行 sleep 10000 然后按下 Ctrl Z此时 systemtap 的输出如下所示$ sudo stap process_state.stp enter do_signal_stop, process name: sleep 0xffffffff8109cc30 : do_signal_stop0x0/0x270 [kernel] 0xffffffff8102a467 : do_signal0x57/0x6c0 [kernel] 0xffffffff8102ab2f : do_notify_resume0x5f/0xb0 [kernel] 0xffffffff816b527d : int_signal0x12/0x17 [kernel]挂起的程序可以使用 fg 命令在前台恢复执行或使用 bg 命令在后台恢复执行。这两种方式实际上是发送 SIGCONT 信号来恢复被停止的作业。$ fg [1] 25729 continued sleep 1000cpulimit 实现原理cpulimit 工具利用 SIGSTOP、SIGCONT 两个信号实现控制 CPU 的使用率它的原理是给进程设置一个 CPU 占用上限并检测进程是否超过这个阈值如果超过了则发送 SIGSTOP 信号给这个进程让进程挂起一段时间。接下来我们来跑一个单线程跑满 100% CPU 的程序这里从/dev/zero不停地读取数据计算 sha256如下所示sha256sum /dev/zero运行这行命令以后CPU 占用率会跑到 100%。接下来用 cpulimit 给进程设置 50% 的 CPU 占用率cpulimit -l 50 -p 6482 -v通过观察 top 命令可以看到sha256sum 进程的状态也在不停的在 T 和 R 之间切换top 命令输出中的 R 表示 running 状态T 表示被作业控制信号信号停止状态。cpulimit 命令输出的结果如下所示理解了这个原理我们可以写一段脚本用 cpulimit 初略模拟一个 CPU 方波pidpidof sha256sum while true; do cpulimit -b -p $pid -l 50 sleep 10 kill -9 pidof cpulimit sleep 10 done这段脚本的原理是使用 cpulimit 命令让进程 10s 保持 50%接下来的 10s 保持 100%循环往复如下图所示运行上面的脚本对应的 CPU 使用率曲线图如下所示TASK_TRACED 状态TASK_TRACED 本质上也是一种 STOP 状态只是它是因为被调试程序所停止。比如我们用 gdb 启动 sleep$ gdb --args sleep 1000 (gdb) b main Breakpoint 1 at 0x401510 (gdb) r Starting program: /usr/bin/sleep 1000 Breakpoint 1, 0x0000000000401510 in main ()使用 ps 查看进程状态如下$ ps -T -e -o pid,state,command PID S COMMAND 2738 t /usr/bin/sleep 1000S 那一列是 t表示进程处于 TASK_TRACED 状态。EXIT_ZOMBIE 状态EXIT_ZOMBIE 表示僵尸状态的进程僵尸进程是指进程实际上已经死亡但是父进程还没调用 waitpid 回收它。下面是一段测试代码#include stdio.h #include unistd.h int main() { pid_t pid; pid fork(); if (pid 0) { printf(%s\n, fork error); } else if (pid 0) { printf(%s\n, enter child process); } else { // enter parent process getchar(); } return 0; }编译运行上面的代码使用 ps 查看进程状态子进程就变为了僵尸进程。$ ps -T -e -o pid,state,command | grep a.out 5475 S ./a.out 5476 Z [a.out] defunctS 那一列是 Z表示进程处于 EXIT_ZOMBIE 状态。关于僵尸进程后面还有一个独立的小节介绍这里先不展开。EXIT_DEAD 状态EXIT_DEAD 状态的进程是只父进程已经发起了 waitpid 但进程还没有完全移除之前的状态一般很难观测到。对于线程和进程我们有一个概念进程是资源的封装单位线程是调度单元那这句话到底是什么意思呢前面介绍过进程和线程在内核中都是一个 task_struct那这两者到底的区别和联系是什么系统调用 clone在上层看来进程和线程的区别确实有天壤之别两者的创建、管理方式都非常不一样。在 linux 内核中不管是进程还是线程都是使用同一个系统调用 clone接下来我们先来看看 clone 的使用。为了表述的方便接下来暂时用进程来表示进程和线程的概念。clone 函数的函数签名如下int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ... /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );参数释义如下第一个参数 fn 表示 clone 生成的子进程会调用 fn 指定的函数参数由第四个参数 arg 指定。child_stack 表示生成的子进程的栈空间。flags 参数非常关键正是这个参数区分了生成的子进程与父进程如何共享资源内存、打开文件描述符等。剩下的参数ptid、tls、ctid 与线程实现有关这里先不展开。接下来我们来看一个实际的例子看看 flag 对新生成的「进程」行为的影响。clone 系统调用接下来演示 CLONE_VM 参数对父子进程行为的影响下面的程序当运行参数包含 clone_vm 时给 clone 函数的 flags 会增加 CLONE_VM。代码如下static int child_func(void *arg) { char *buf (char *)arg; // 修改 buf 内容 strcpy(buf, hello from child); return 0; } const int STACK_SIZE 256 * 1024; int main(int argc, char **argv) { char *stack malloc(STACK_SIZE); int clone_flags 0; // 如果第一个参数是 clone_vm则给 clone_flags 增加 CLONE_VM 标记 if (argc 1 !strcmp(argv[1], clone_vm)) { clone_flags | CLONE_VM; } char buf[] msg from parent; if (clone(child_func, stack STACK_SIZE, clone_flags, buf) -1) { exit(1); } sleep(1); printf(in parent, buf:\%s\\n, buf); return 0; }上面的代码在 clone 调用时将父进程的 buf 指针传递到 child 进程中当不带任何参数时CLONE_VM 标记没有被设置表示不共享虚拟内存父子进程的内存完全独立子进程的内存是父进程内存的拷贝子进程对 buf 内存的写入只是修改自己的内存副本父进程看不到这一修改。编译运行结果如下$ ./clone_test in parent, buf:msg from parent可以看到 child 进程对 buf 的修改父进程并没有生效。再来看看运行时增加 clone_vm 参数时结果$ ./clone_test clone_vm in parent, buf:hello from child可以看到这次 child 进程对 buf 修改父进程生效了。当设置了 CLONE_VM 标记时父子进程会共享内存子进程对 buf 内存的修改也会直接影响到父进程。讲这个例子是为后面介绍进程和线程的区别打下基础接下来我们来看看进程和线程的本质区别是什么。线程与 clone系统调用接下来我们来看当创建一个线程时到底发生了什么。#include pthread.h #include unistd.h #include stdio.h void *run(void *args) { sleep(10000); } int main() { pthread_t t1; pthread_create(t1, NULL, run, NULL); pthread_join(t1, NULL); return 0; }使用 gcc 编译上面的代码gcc -o thread_test thread_test.c -lpthread然后使用 strace 执行 thread_test系统调用如下所示mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) 0x7f93daf02000 // ... clone( child_stack0x7f93db701fb0, flagsCLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, // ... ) 26063比较重要的是下面这些 flags 参数标记含义CLONE_VM共享虚拟内存CLONE_FS共享与文件系统相关的属性CLONE_FILES共享打开文件描述符表CLONE_SIGHAND共享对信号的处置CLONE_THREAD置于父进程所属的线程组中可以看到线程创建的本质是共享进程的虚拟内存、文件系统属性、打开的文件列表、信号处理以及将生成的线程加入父进程所属的线程组中如下图所示接下来我们看看进程与 clone 之间的关系。进程与 clone 系统调用以下面的代码为例pid_t gettid() { return syscall(__NR_gettid); } int main() { pid_t pid; pid fork(); if (pid 0) { printf(in child, pid: %d, tid:%d\n, getpid(), gettid()); } else { printf(in parent, pid: %d, tid:%d\n, getpid(), gettid()); } return 0; }使用 strace 运行输出结果如下clone(child_stackNULL, flagsCLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr0x7f75b83b4a10) 16274可以看到 fork 创建进程对应 clone 使用的 flags 中唯一需要值得注意的 flag 是 SIGCHLD当设置这个 flag 以后子进程退出时系统会给父进程发送 SIGCHLD 信号让父进程使用 wait 等函数获取到子进程退出的原因。可以看到 fork 调用中父子进程没有共享内存、打开文件等资源这样契合进程是资源的封装单位这个说法资源独立是进程的显著特征。我们多次强调进程是资源的封装单位父子进程作为不同的进程他们对外表现出来的是数据段和堆栈是完全独立的但是子进程又拥有和父进程一样的堆、栈、数据段这就需要从父进程拷贝一份到子进程如下图所示如果 fork 创建子进程需要复制父进程所有的数据那代价是非常高的fork 的一个典型的应用场景是 Redis 的 RDB 快照生成比如一个有几百 G 的 Redis 服务光拷贝这么多堆内存就需要耗费很多时间。除此之外很多场景中 fork 以后子进程马上会调用 exec这会替换父进程的代码段并重新初始化其数据段、堆栈空间如果子进程完全拷贝了父进程的数据就非常浪费了。现代的 Unix 系统都是采用写时拷贝技术copy-on-writeCoW来解决这个问题。父进程和子进程共享同一份数据直到其中一个对数据进行修改就会进行分裂执行真正的拷贝。那这个技术是如何实现的写时复制在进程 fork 中的应用内核会将父子进程中共享的区域标记为只读RO当有父子进程有一方写入时会产生一个 Minor 缺页异常pagefaultCPU 得知这个消息以后会判断 pagefault 的真正原因随后进行拷贝。口说无凭接下来我们来用实验证实确实发生了 pagefault。有下面这一段简单的 C 语言代码#include unistd.h #include stdio.h #includestring.h int main(int argc, char *argv[]) { int msg[1024 * 100] {0}; memset(msg, 0, sizeof(msg)); int pid; pid fork(); if (pid 0) { // getchar(); sleep(2); msg[1024 * 10] 1; sleep(1); msg[1024 * 20] 1; sleep(1); msg[1024 * 30] 1; sleep(1); msg[1024 * 40] 1; sleep(2); printf(adress msg[1024 * 10]: %-10lx\n, (unsigned long)(msg 1024 * 10)); printf(adress msg[1024 * 20]: %-10lx\n, (unsigned long)(msg 1024 * 20)); printf(adress msg[1024 * 30]: %-10lx\n, (unsigned long)(msg 1024 * 30)); printf(adress msg[1024 * 40]: %-10lx\n, (unsigned long)(msg 1024 * 40)); sleep(10000); } else { sleep(10000); } }这段程序在 fork 生成的子进程中首先执行 getchar() 等待终端的输入使我们有机会来得及获取父进程和子进程 pid 以便后续 systemtap 使用。接下来每个 1s 修改一次 msg 数组的值然后在 systemtap 中观察 pagefault 的变化。systemtap 的脚本如下所示#! /usr/bin/env stap global fault_begin_time_map // 记录 pagefault 发生的时间 global fault_address_map // 记录 pagefault 发生的地址 global fault_access_map // 记录 pagefault 是不是 write access probe begin { printf(cow pagefault probe begin...\n) } probe vm.pagefault { if (pid() target() || ppid() target()) { pid pid() fault_begin_time_map[pid] gettimeofday_s() // address 表示发生 pagefault 时的地址 fault_address_map[pid] address // write_access 表示这次 pagefault 是不是一次 write access, 1 表示 write0 表示 read fault_access_map[pid] write_access } } probe vm.pagefault.return { if(pid() target() || ppid() target()) { pid pid() if (!(pid in fault_begin_time_map)) next if (vm_fault_contains(fault_type, VM_FAULT_MINOR)) { fault_type_desc MINOR } else if (vm_fault_contains(fault_type, VM_FAULT_MAJOR)) { fault_type_desc MAJOR } else { next } printf([%s] pid:%d, address:%p access:%s, type:%s\n, ctime(fault_begin_time_map[pid]), // time pid, // pid fault_address_map[pid], // address fault_access_map[pid] ? w : r,// write、read access fault_type_desc // minor、major ) delete fault_begin_time_map[pid] delete fault_address_map[pid] delete fault_access_map[pid] } }对于上面的脚本有几个点需要解释一下stap 脚本执行时使用-x pid可以指定感兴趣的 pid在脚本中可以用 target() 方法获取执行传入的 pid。systemtap 为 pagefault 实现了方便探针方法 vm.pagefault 和 vm.pagefault.return。在probe vm.pagefault方法中可以方便地获取 address、write_access 的值在probe vm.pagefault.return可以获取 fault_type 的值。详细的实验步骤如下编译运行 cow.c这时父进程会 fork 一个子进程同时子进程处于阻塞等待 stdin 终端输入的状态使用 ps 命令获取当前的子进程 pid。执行 systemtap 脚本sudo stap -g cow_pagefault.stp -x child_pid在 cow 运行的终端输入enter然后子进程开始对 msg 数组的 write 赋值。这时 cow 程序输出的修改的四次地址如下所示adress msg[1024 * 10]: 7ffe0cdefb40 adress msg[1024 * 20]: 7ffe0cdf9b40 adress msg[1024 * 30]: 7ffe0ce03b40 adress msg[1024 * 40]: 7ffe0ce0db40systemtap 的输出如下所示对比 address 可以知道刚好是这四次发生了四次 write 的 MINOR pagefault。... [Sun May 10 11:35:00 2020] pid:12420, address:0x7ffe0cdefb40 access:w, type:MINOR [Sun May 10 11:35:01 2020] pid:12420, address:0x7ffe0cdf9b40 access:w, type:MINOR [Sun May 10 11:35:02 2020] pid:12420, address:0x7ffe0ce03b40 access:w, type:MINOR [Sun May 10 11:35:03 2020] pid:12420, address:0x7ffe0ce0db40 access:w, type:MINOR ...这个过程如下所示子进程和父进程任何一方执行一次修改都会触发一次写时复制分裂。Docker 中也使用了 COW 技术来处理镜像分层如果某一层只是使用了下层的文件没有修改则 docker 不会拷贝文件到那一层直接使用即可。如果某一层修改了下层的文件docker 会首先从下层拷贝文件到上层然后进行修改。以下图为例Layer 1 包含 A、B 两个文件Layer 2 修改了文件 B则会将文件 B 赋值到 Layer 2然后对其进行修改。这样做的好处是只有在文件有修改时才执行真正的拷贝操作在镜像构建时更加快速。除了 docker 的这种使用方式COW 在文件系统等领域也有不少的应用场景比如大名鼎鼎的 Btrfs 文件系统就使用了 COW 技术来进行构建。至此我们就清楚了进程和线程的本质区别和联系是什么。