写服务端程序,迟早要回答几个问题:开线程还是开进程,fork 出来的子进程和父进程到底共用什么,ps 里那个 Z 是什么、为什么 kill -9 杀不掉。这几个问题的答案都在内核怎么表示一个进程上:它用同一个结构体表示进程和线程,差别只在创建时选择共享多少。
1. 进程和线程在内核里是同一种东西
1.1 task_struct:内核只调度“任务”
内核为每个能被调度的执行流建一个 struct task_struct,里面记着它的状态、寄存器保存在哪、属于哪个用户、能用多少资源,以及几根指向“资源”的指针:mm 指向地址空间(页表、各段的映射),files 指向 fd 表,sighand 指向信号处理函数表,fs 指向当前目录和 umask。
调度器只认 task_struct,不区分进程和线程:一个进程里有 4 个线程,在调度器眼里就是 4 个任务,各自排队、各自上 CPU。进程和线程的区别不在调度,而在这几根资源指针指向的是自己的一份,还是和别人共用一份。 指向同一个 mm 的几个任务,看到的是同一份内存,用户态就把它们叫作同一个进程里的线程。
所以 Linux 里线程也有一个号,叫 tid,和 pid 来自同一个编号空间。同一个进程里的线程共用一个线程组号 tgid,getpid() 返回的其实是 tgid,gettid() 返回的才是每个线程自己的号。主线程的 tid 等于 tgid,这就是为什么主线程的 tid 和 pid 一样。
1.2 clone 的标志:共享多少,在创建那一刻决定
新任务都由 clone 系统调用创建,它的标志位逐项决定新任务和调用者共用哪些资源:
// glibc 的 pthread_create 最后调 clone,给的标志是这些:
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD |
CLONE_SYSVSEM | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID
// CLONE_VM:共用地址空间 CLONE_FILES:共用 fd 表
// CLONE_FS:共用当前目录和 umask CLONE_SIGHAND:共用信号处理函数
// CLONE_THREAD:放进同一个线程组(同一个 tgid)
// CLONE_SETTLS:给新线程设好自己的 TLS 基址
// fork 相当于一个共享标志都不给,只给 SIGCHLD:
// 每样资源都复制一份,退出时给父进程发 SIGCHLD
给了 CLONE_VM,新任务的 mm 指针直接指向调用者的那份,不复制任何东西;不给,内核就要为新任务复制一份地址空间,这就是 fork 要做的事(第 2 部分)。创建线程比 fork 快,是因为它少做了“复制地址空间”这一步,而不是因为线程是更轻的另一种对象。 创建一个线程约几 µs 到几十 µs,主要花在准备它的栈上;fork 一个小进程约几十到几百 µs,进程用的内存越多越慢。
1.3 线程共享什么、独占什么
按上面的标志,同一个进程里的线程共用一份资源,每个线程只留下“正在执行”所必需的那些状态:
| 资源 | 线程之间 | 为什么这样分 |
|---|---|---|
| 地址空间:代码、全局变量、堆、共享库,以及所有线程的栈 | 共用,同一套页表 | CLONE_VM:线程之间要靠共享内存交换数据 |
| fd 表:打开的文件、socket | 共用 | CLONE_FILES:一个线程 accept 的连接,交给另一个线程去读 |
| 信号处理函数 | 共用 | 信号是发给整个进程的,处理方式只能有一种 |
| 当前目录、umask、用户 ID、pid | 共用 | 它们描述的是“这个程序”,不是某一条执行流 |
寄存器:rip、rsp…… | 各一份 | 每个线程执行到不同的地方,切走时各自保存 |
| 栈 | 各用一段 | 每个线程有自己的调用链;主线程的在地址空间最高处,其他线程的由 pthread_create 从 mmap 区分配,默认 8 MiB |
| TLS(线程局部存储) | 各一份 | thread_local 变量和 errno 放在这里,靠 fs 寄存器指向各自那块 |
| 信号掩码、调度优先级、CPU 亲和性 | 各一份 | 每个线程可以各自屏蔽信号、各自绑核 |
| 内核栈 | 各一份 | 每个线程都可能同时在内核里执行系统调用 |
注意栈的位置:每个线程的栈“独占”,只是说每个线程用自己那一段,而不是说别的线程碰不到。 栈就在共用的地址空间里,线程 A 把自己栈上一个局部变量的地址交给线程 B,B 照样能读写;等 A 的函数返回,那块栈被下一次调用复用,B 手里的指针就悬空了。线程间传数据要放在堆上或全局变量里,原因就在这里。各段在地址空间里的具体位置见进程地图,栈里一帧放了什么见函数调用栈帧。
在自己机器上看一个进程的线程:
ls /proc/<pid>/task # 每个线程一个目录,目录名就是 tid
ps -L -o pid,tid,stat,comm -p <pid> # pid 列都一样,tid 列各不相同
grep -E 'Tgid|Pid|Threads' /proc/<pid>/status
1.4 心智模型:进程是容器,线程是在里面跑的执行流
把 1.1–1.3 合起来,可以这样记:进程是一份资源的容器,线程是在容器里跑的执行流。 容器里装的是地址空间(CR3 指向的那份页表)、fd 表、信号处理函数、当前目录和用户身份;执行流自己只有“跑到哪了”:寄存器、栈、TLS、信号掩码、绑在哪个核。调度器放到核上跑的是执行流,一个容器里至少有一个。单线程进程就是只有一个执行流的容器,所以平时感觉不到这是两样东西。
拿不准一样东西归谁,就问它描述的是“这个程序”,还是“这一条执行流”:
errno归线程:它放在 TLS 里,否则一个线程的系统调用失败,会覆盖另一个线程刚要读的错误码。- fd 归进程:一个线程
close(fd),其他线程手里的同一个号也跟着失效,这个号还可能马上被别的open复用。 - 进程号归进程:
getpid()在每个线程里都一样,gettid()才各不相同(1.1)。
后面几部分的结论,都能从这个模型直接推出来:
- 切换:在同一个容器里换执行流,容器不用动,CR3 和 TLB 接着用;换到另一个容器,地址空间跟着换(第 4 部分)。
- 崩溃:一个线程写坏了堆,坏的是整个容器,进程里所有线程一起完蛋(4.1)。
- fork:复制的是容器,执行流只带走调 fork 的那一个,所以多线程程序 fork 出的子进程里只剩一个线程(2.5)。
- 改页表:页表是容器的,一个线程
munmap,容器里正在跑的其他线程都要陪着处理一次 TLB shootdown(专题 3.1)。
内核里两者是同一种 task_struct(1.1),容器和执行流之间的界线由 clone 的标志来划:全都共用,是同一进程里的线程;全都复制,是新进程;中间也有别的组合(比如只共用地址空间、不共用 fd 表),只是很少用。
2. fork:复制一个进程
2.1 fork 返回两次:子进程从同一行接着跑
fork 创建一个子进程,它是调用者的副本:同样的代码、同样的内存内容、同样的寄存器,所以子进程也从 fork 返回的地方接着执行。两边唯一的区别是返回值:父进程拿到子进程的 pid,子进程拿到 0,程序靠它分岔:
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int num = 10; // 全局变量,在 .data 里
int main(void) {
pid_t pid = fork();
if (pid == 0) { // 子进程
num += 100;
printf("child num=%d &num=%p\n", num, (void *)&num);
return 0;
}
waitpid(pid, NULL, 0); // 父进程:等子进程结束
printf("parent num=%d &num=%p\n", num, (void *)&num);
return 0;
}
输出里子进程是 110,父进程还是 10,可两边打印的 &num 是同一个地址。同一个虚拟地址读出不同的值,说明两个进程各有一套页表,同一个虚拟页被映射到了不同的物理页。这两个物理页是什么时候分开的,就是下一节的内容。
2.2 写时复制(copy-on-write):先共用物理页,谁写谁复制
如果 fork 真把父进程的内存全拷一遍,一个用了 4 GiB 的进程,fork 一次就要拷 4 GiB,而大多数子进程紧接着就调 exec 换成别的程序,拷的东西全白费。所以内核这样做:
- 复制页表,不复制物理页:子进程的页表项和父进程的指向同一批物理页,每页的引用计数加 1;
- 把两边所有可写的私有页都改成只读,代码这类本来就只读的页不用动;
- 之后任何一方写其中一页,因为页表项是只读的,CPU 触发缺页异常;
- 内核在缺页处理里看出这是写时复制的页:分配一个新物理页,把原来那页的 4 KiB 拷过去,让写的一方的页表项指向新页、改回可写,再回去重新执行那条写指令。
fork 的代价因此分成两笔:fork 那一刻复制页表,之后每写一个共用页付一次缺页加 4 KiB 拷贝。 第一笔和进程用了多少内存成正比,每 GiB 已用内存约几 ms 到十几 ms,所以一个几十 GiB 的进程 fork 一次,主线程会停顿几百 ms。第二笔是一次次摊开的,每次约 1–2 µs;如果父进程在 fork 之后大量写内存,被写到的页都要复制一份,最坏时内存用量接近翻倍。用快照做持久化的程序(比如 Redis 的 BGSAVE)就是这样:子进程慢慢把 fork 那一刻的内存写到磁盘,父进程照常改数据,改到哪页复制哪页。缺页本身怎么处理,见 new 一次走到哪。
2.3 fork 之后的 fd:表复制一份,打开的文件是同一个
fd 只是 fd 表里的下标,表里每一项指向内核里一个“打开的文件”对象(struct file),文件偏移、打开方式(读写、O_APPEND、非阻塞)都记在这个对象里。fork 复制的是 fd 表,表项还指着原来那些对象,对象的引用计数加 1。所以父子两边的 fd 3 指的是同一个打开的文件,连文件偏移都是共用的:
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) {
write(fd, "child\n", 6);
_exit(0);
}
wait(NULL);
write(fd, "parent\n", 7); // 接在 child 后面,不会从文件开头覆盖:偏移是共用的
这个规则能推出几件常见的事。子进程 close(fd) 只是去掉自己表里那一项,父进程不受影响,文件对象要等最后一个引用关掉才真正关闭;所以服务器 fork 出子进程处理连接以后,父进程必须关掉自己手里的那个连接 fd,否则子进程关了连接,对端也收不到 FIN。epoll 实例也是一个文件,fork 之后父子两边的 epoll fd 指向内核里同一个 eventpoll,共用同一棵红黑树和就绪链表(见 epoll 那篇),一边 epoll_ctl 改了,另一边也看得到。
2.4 fork 加 exec:换掉整个程序
fork 出来的子进程还在跑父进程的代码,要运行另一个程序,就调 exec 系列函数。exec 丢掉当前的整个地址空间,按新程序的 ELF 文件重新建一份:代码段、数据段映射进来,新建堆和栈,从新程序的入口开始执行。pid 不变,父子关系不变,打开的 fd 默认也保留,只有设了 O_CLOEXEC(或 FD_CLOEXEC)的会在 exec 时关掉;设了处理函数的信号恢复默认处理,因为原来那些函数的代码已经不在了(设成忽略的仍然忽略)。
写时复制让 fork 加 exec 很便宜:fork 时只复制页表,紧接着 exec 把这套页表整个扔掉,中间几乎没有页被真正复制。 剩下的成本是复制页表本身,父进程很大时它仍然明显。所以还有两个更省的办法:vfork 让子进程直接借用父进程的地址空间、连页表都不复制,父进程暂停到子进程 exec 或 _exit 为止,代价是子进程在这之间不能改任何内存、不能从函数返回;posix_spawn 把 fork 加 exec 包成一个调用,glibc 用 clone(CLONE_VM | CLONE_VFORK) 加一块单独的栈实现它,既有 vfork 的速度,又没有那些限制。只是想启动一个程序时,用 posix_spawn 最省心。
2.5 多线程程序里 fork:子进程里只剩一个线程
一个有 8 个线程的进程调 fork,子进程里只有 1 个线程,就是调 fork 的那个;其他 7 个线程不会被复制。但内存是整份复制的,包括那 7 个线程正拿着的锁:
std::mutex m;
// 线程 A:
{
std::lock_guard<std::mutex> g(m);
// ……拿着锁干活,这时候线程 B 调了 fork
}
// 线程 B:
if (fork() == 0) {
std::lock_guard<std::mutex> g(m); // 子进程里的 m 是“已锁”状态的副本,
// 拿锁的线程 A 在子进程里不存在:永远等下去
}
子进程里那把锁的状态是“被线程 A 锁着”,可 A 在子进程里根本不存在,没有人会去解锁。所以多线程程序 fork 之后,子进程在 exec 之前只能调异步信号安全的函数(write、dup2、close、exec 这类,清单见 man 7 signal-safety),不能用 printf、不能 new,因为谁也不知道 fork 那一刻哪个线程正拿着它们内部的锁。glibc 会在 fork 前后处理好自己 malloc 的锁,但你自己的锁、第三方库的锁它管不到;pthread_atfork 能注册 fork 前后的回调去处理,但很难写对。实际的做法是:多线程程序要起子进程,就用 posix_spawn,或者 fork 之后立刻 exec。
3. 进程状态:一生经过哪几种
3.1 R、S、D、T、Z:ps 第一列字母的意思
ps 的 STAT 列、top 的 S 列,第一个字母就是任务当前的状态:
R 不等于正在跑,而是“只差一个 CPU”:正在某个核上执行的,和在运行队列里排队等核的,都显示 R。课本的三态模型把这两种分成“运行”和“就绪”,Linux 不分,因为对任务自己来说没有区别,轮到它就跑。
S 是绝大多数进程的常态。一个空闲的服务器进程睡在 epoll_wait 里,一个 shell 睡在 read 里等你敲键盘,都是 S:它们在等一个事件,事件来了被叫醒回到 R(epoll 那篇里“睡在等待队列上”说的就是这个状态)。“可中断”是说信号也能把它叫醒,所以 Ctrl-C 能打断一个睡在 read 里的程序,那次系统调用返回 EINTR。
D 也是睡着在等,但信号叫不醒,kill -9 也要等它醒来以后才生效。内核在一些不能半途放弃的等待里用这个状态,最常见的是等磁盘 IO:读一页文件读到一半被信号打断,内核没法把这件事干净地撤回。D 正常只持续几 µs 到几 ms;如果 ps 里一批进程长时间卡在 D,通常是磁盘或网络文件系统(NFS)出了问题,它们都在等一个不回来的 IO。D 状态的任务也算进系统负载(load average),所以 CPU 很闲、负载却很高时,先看有没有一堆 D。
T 是被 SIGSTOP 或 Ctrl-Z(SIGTSTP)暂停,SIGCONT 让它继续;被调试器停住时显示小写的 t。Z 是已经退出、还没被父进程回收,下一节讲。
在自己机器上看:
ps -eo pid,ppid,stat,nlwp,cmd | head # stat 第一个字母是状态,nlwp 是线程数
grep -E 'State|PPid|ctxt_switches' /proc/<pid>/status
# voluntary_ctxt_switches:自己睡下让出 CPU 的次数(进了 S 或 D)
# nonvoluntary_ctxt_switches:时间片用完被换下的次数
3.2 僵尸进程(zombie):已经退出,等父进程来收
进程退出时,内核马上释放它的内存、关掉它的 fd,但留下 task_struct 里的一小部分:pid、退出码、用了多少 CPU 时间。留着它是因为父进程可能想知道子进程是怎么结束的,要等父进程调 wait 或 waitpid 把退出码取走,内核才删掉这条记录、把 pid 还回去。从退出到被 wait 之间,它就是僵尸进程,ps 里显示 Z,后面标着 <defunct>。
僵尸进程已经死了,所以 kill -9 对它没用:信号要发给一个还在跑的进程才有意义。 它几乎不占内存,危害在于占着 pid:系统的 pid 有上限(/proc/sys/kernel/pid_max,小系统默认 32768,64 位的发行版常设成 4194304),父进程一直不收,僵尸越积越多,最后 fork 会因为没有 pid 可用而失败。要清掉它,只能让父进程去 wait,或者杀掉父进程,让它变成孤儿,交给新的父进程去收。
wait 的行为也由此而来:有子进程已经是僵尸时,它立刻返回那个子进程的 pid 并把它收掉;子进程都还活着时,它阻塞到有一个退出;一个子进程都没有了,才返回 -1,errno 是 ECHILD。
3.3 孤儿进程(orphan):父进程先走了
父进程先退出,子进程还在跑,子进程就成了孤儿。内核会给它重新找一个父进程:默认是 pid 1 的 init(现在通常是 systemd);如果它的某个祖先进程用 prctl(PR_SET_CHILD_SUBREAPER) 把自己设成了“收养者”(subreaper),就交给离它最近的那个。新父进程负责在它退出时 wait,init 一直在循环做这件事,所以孤儿本身没有害处,等它自己退出时会被收掉。
容器里有一个由此而来的坑:容器里 pid 为 1 的是你的程序,不是 init。如果你的程序 fork 了子进程,子进程又生了孙进程,子进程先退出,孙进程就交给了你的程序;而你的程序从来不 wait 它不认识的进程,孙进程退出后就一直是僵尸。所以容器常用一个很小的 init(比如 docker run --init 带的 tini)当 pid 1,专门负责收养和回收。
3.4 不留僵尸的三种写法
最直接的是父进程在合适的地方 waitpid。父进程有自己的事件循环、不能阻塞等时,就在 SIGCHLD 里收:子进程退出时内核给父进程发 SIGCHLD,处理函数里循环调非阻塞的 waitpid:
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
static void on_child(int sig) {
(void)sig;
int saved = errno; // waitpid 会改 errno,别弄坏被打断的代码
while (waitpid(-1, NULL, WNOHANG) > 0) {} // 循环收:几个子进程同时退出,信号只来一次
errno = saved;
}
int install_reaper(void) {
struct sigaction sa = {0};
sa.sa_handler = on_child;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // 被打断的系统调用自动重来;子进程暂停时不通知
return sigaction(SIGCHLD, &sa, NULL);
}
必须用循环,是因为同一种信号在被处理之前只记一次:3 个子进程几乎同时退出,父进程可能只收到 1 个 SIGCHLD,只 waitpid 一次就会漏掉两个。
另外两种写法都是让别人去收。一种是把 SIGCHLD 设成忽略(signal(SIGCHLD, SIG_IGN)),Linux 上这样设了以后,子进程退出时直接被回收,不留僵尸,代价是父进程再也拿不到它们的退出码。另一种是两次 fork:父进程 fork 出子进程,子进程再 fork 出孙进程后立刻退出,父进程 wait 收掉子进程,孙进程成了孤儿,交给 init 去收;守护进程传统上就这样脱离启动它的终端和父进程。
4. 切换的代价:线程和进程差在哪
一次切换做了什么、页表和 TLB 是每个进程一份还是每个核一份、改页表时要通知谁,这些低延迟里常讨论的内容放在专题上下文切换、页表与 TLB里。这里只留结论:
- 切换本身:线程切换和进程切换,连同唤醒、调度都在 1–5 µs,只差要不要改写 CR3。
- 切换之后:同一进程的线程接着用 TLB 和缓存里的东西,换进程要重新热起来。线程比进程轻,轻在切换之后。
- 改页表:TLB 没有 MESI,一个线程
munmap,此刻还装着这份页表的核都要被打断一次(TLB shootdown),绑核和isolcpus挡不住。
4.1 开线程还是开进程
把前面几部分合起来,取舍就清楚了。线程共用地址空间,所以创建快、切换后缓存热、交换数据只要传一个指针;代价是没有隔离,一个线程越界写坏了堆、或者段错误,整个进程一起完蛋,共享的数据还得自己加锁。进程各有一份地址空间,一个崩了不影响别的,可以分别以不同的用户、不同的权限运行;代价是创建慢、交换数据要走进程间通信(管道、共享内存、socket,见进程间通信)。
所以要的是并行干同一件事、频繁共享数据,用线程;要的是隔离,一个出错不能拖垮其他,或者要跑另一个程序,用进程。浏览器给每个标签页一个进程、Web 服务器用一个进程里的线程池处理请求,就是这两种取舍。