【Linux】进程的创建与终止

【Linux】进程的创建与终止


🔥 H.莓飛: 个人主页

❄️个人专栏:「C++」「数据结构」「Linux系统编程」

✨ 心不畏死,踏屍前行。

📦 代码仓库:cocatrice_csdn_code


📖 博主简介:


概览

  • fork 一次调用返回两次
  • fork 在系统调用层留下的痕迹
  • 写时拷贝在 smaps 里的直接证据
  • fork 之后被子进程继承的资源
  • fork 的常规用法与失败现场
  • 进程退出的三种场景与退出码的来历
  • exit 与 _exit 之间差的那一个缓冲区
  • vfork 的共享地址空间

核心知识

一、fork 之后进程从一个变成两个

创建进程的接口是 fork,它的声明放在 unistd.h 里,不接受任何参数,返回一个 pid_t 类型的值:

c 复制代码
#include <unistd.h>

pid_t fork(void);

一个不需要参数、返回值又不特别的函数,却让进程数从一变成二。控制流走到 fork 里面的那段内核代码之后,内核会做四件事:先给新进程分配内存块和它自己的内核数据结构,再把父进程的一部分数据结构拷进这份新结构,接着把新进程挂到系统进程列表里,最后从 fork 返回并把两条执行流都交给调度器。

把这四步落到一个程序上看,回显比解释更直接。下面这段程序在 fork 之前打印一次进程号,fork 之后打印一次进程号和返回值,跑完之后停两秒:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    pid_t pid;

    printf("Before: pid is %d\n", getpid());
    if ((pid = fork()) == -1) {
        perror("fork()");
        exit(1);
    }
    printf("After:pid is %d, fork return %d\n", getpid(), pid);
    sleep(2);
    return 0;
}

编译之后直接跑,三行输出落在终端上:

text 复制代码
Before: pid is 10478
After:pid is 10478, fork return 10479
After:pid is 10479, fork return 0

三行里只有一行 Before,却有两行 After。第一行由进程 10478 打印,它接着往下走又打了自己那一行 After,返回值是 10479;第三行是 10479 打印的,它同样走到了那一行 printf,返回值却是 0。子进程没有打印 Before,因为那一行在 fork 之前,只有父进程执行过。printf 在 fork 之前已经执行完毕,缓冲区里也没有留下没写完的半个句子,所以这段输出里没有重复的 Before。

只有三行文字还不够说明现在是两个进程,用 ps 按进程名查一次父子的关系更直观。在程序跑起来的那两秒里执行 ps,回显是这样:

text 复制代码
  PID  PPID STAT CMD
10485 10351 S+   ./bin/fork_basic
10487 10485 S+   ./bin/fork_basic

两行都指向同一份可执行文件,第一行的进程号 10485 就是第二行的父进程号,10351 是这次远程登录的 shell。两条执行流跑的是同一份代码,占用的是两个独立的进程表项,各自的进程号也已经分开。

fork 之前的代码只有父进程执行,fork 之后的代码两条流都会执行,至于谁先执行完,取决于调度器。下面这段程序里父子各打印三条,写的时候故意把父进程的三条写在子进程三条的前面:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int i;
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        for (i = 1; i <= 3; i++) {
            printf("子进程 %d 的第 %d 条\n", getpid(), i);
        }
        _exit(0);
    }
    for (i = 1; i <= 3; i++) {
        printf("父进程 %d 的第 %d 条\n", getpid(), i);
    }
    waitpid(pid, NULL, 0);
    return 0;
}

连着跑三次,六行输出的分组方式每次都一样:

text 复制代码
父进程 10493 的第 1 条
父进程 10493 的第 2 条
父进程 10493 的第 3 条
子进程 10494 的第 1 条
子进程 10494 的第 2 条
子进程 10494 的第 3 条

这台机器上三次都是父进程的三条先打完,子进程的三条跟在后面,看上去像是先来后到排好了队。把父进程先放慢一毫秒,顺序立刻换过来,下面这段程序在父进程里插了一句 usleep(1000):

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid == 0) {
        printf("子进程先说话了\n");
        _exit(0);
    }
    usleep(1000);
    printf("父进程后说话\n");
    waitpid(pid, NULL, 0);
    return 0;
}

连跑三次,每次都是子进程先开口:

text 复制代码
子进程先说话了
父进程后说话

前一次是父进程先跑完,这一次是子进程先跑完,两边的代码都没有改,改的只是父进程愿不愿意立刻把处理器让给别的进程。fork 只保证两个进程从同一个位置继续往下走,不保证谁先走,一旦代码里出现依赖先后次序的逻辑,就得自己用同步手段把它钉住。

图 1 fork 之后两条执行流:父子都从 fork 返回,谁先跑由调度器决定

二、两个返回值

fork 的返回值分三种情况,写代码时按进程的身份分开处理:子进程里拿到 0,父进程里拿到的是新进程的进程号,出错时父进程里拿到 -1。子进程永远拿不到 -1,因为 fork 失败的进程还是原来那个进程,并不会多出一个孩子。

把两边的返回值连同进程号一起打出来,父子关系在数字上就对得上了:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        printf("子进程 pid=%d ppid=%d fork 返回=%d\n", getpid(), getppid(), pid);
        exit(0);
    }
    printf("父进程 pid=%d ppid=%d fork 返回=%d\n", getpid(), getppid(), pid);
    waitpid(pid, NULL, 0);
    return 0;
}
text 复制代码
父进程 pid=10499 ppid=10351 fork 返回=10500
子进程 pid=10500 ppid=10499 fork 返回=0

父进程的返回值 10500 就是子进程的进程号,子进程的 ppid 也是 10500 的父进程 10499,三个数字互相印证。前面留下的三个设问到这个位置可以逐条回答。

第一个问题是为什么给子进程返回 0,给父进程返回子进程的进程号。子进程想知道父进程是谁,用 getppid 就能拿到,不需要额外的返回值;父进程要管的孩子可能不止一个,每调用一次 fork 就得知道这一次生出的是哪一个,返回值就是它的身份。两者需要的信息本来就不一样,一份返回值只能对着一边设计。

第二个问题是一个函数为什么有两个返回值。fork 之后进程变成了两个,从 fork 里面返回这个动作发生了两次,两次各自把返回值写进自己那套寄存器。所谓两个返回值其实是同一份代码在两个进程里各跑了一遍返回流程,谁也不会覆盖谁。

第三个问题是一个进程号为什么既等于 0 又大于 0。0 出现在子进程的判断里,10500 出现在父进程的判断里,两个数字从来没有出现在同一个进程里。写代码时判断的依据在于当前这次执行落在哪个分支上,跟写了几次 fork 没有关系。

三、写时拷贝

fork 之后两个进程看到的地址一模一样,值却可以各走各的。下面这段程序让子进程改一个全局变量的值,父进程在两边各打印一次地址:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int g_val = 100;

int main(void)
{
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        printf("子 写之前: g_val=%d &g_val=%p\n", g_val, (void *)&g_val);
        sleep(1);
        g_val = 200;
        printf("子 写之后: g_val=%d &g_val=%p\n", g_val, (void *)&g_val);
        exit(0);
    }
    printf("父 同 一时刻: g_val=%d &g_val=%p\n", g_val, (void *)&g_val);
    waitpid(pid, NULL, 0);
    printf("父 等完子进程: g_val=%d &g_val=%p\n", g_val, (void *)&g_val);
    return 0;
}

四行回显里地址完全一致,只有子进程那一份的值变了:

text 复制代码
父 同 一时刻: g_val=100 &g_val=0x60105c
子 写之前: g_val=100 &g_val=0x60105c
子 写之后: g_val=200 &g_val=0x60105c
父 等完子进程: g_val=100 &g_val=0x60105c

地址是虚拟地址,两个进程各有自己的页表,同一个虚拟地址在不同页表里指向哪一页物理内存,由页表自己决定。fork 时内核把父进程的页表复制一份,两份页表指向同一批物理页,并且把这些页在两边都标成只读。任何一方写入都会撞上只读标记,内核在缺页处理里复制一页、改掉触发方的页表项,写完这一页之后两边的数据就分开了。第 11 篇讲地址空间时分析过这套流程,这里换一个能看见物理页归属的视角,用 smaps 里的 Pss 字段给它称一次重量。

程序先申请 64 MB 并全部写一遍,然后在几个时间点分别统计 /proc/self/smaps 里的 Rss、Shared_Clean、Private_Dirty 和 Pss:

c 复制代码
static void sample(const char *tag)
{
    FILE *fp = fopen("/proc/self/smaps", "r");
    char line[256];
    unsigned long rss = 0, shared = 0, dirty = 0, pss = 0, v;

    if (fp == NULL) {
        perror("smaps");
        return;
    }
    while (fgets(line, sizeof(line), fp) != NULL) {
        if (sscanf(line, "Rss: %lu kB", &v) == 1) {
            rss += v;
        } else if (sscanf(line, "Shared_Clean: %lu kB", &v) == 1) {
            shared += v;
        } else if (sscanf(line, "Private_Dirty: %lu kB", &v) == 1) {
            dirty += v;
        } else if (sscanf(line, "Pss: %lu kB", &v) == 1) {
            pss += v;
        }
    }
    fclose(fp);
    printf("%-22s Rss=%6lu kB  Shared_Clean=%6lu kB  Private_Dirty=%6lu kB  Pss=%6lu kB\n",
           tag, rss, shared, dirty, pss);
}

父进程写完 64 MB 之后调用 fork,子进程在写之前和写之后各统计一次,父进程在子进程写完和退出时再统计两次:

text 复制代码
缓冲区 0x7f6084911010,共 64 MB,父进程已经全部写过一遍
父 复制前:         Rss= 66112 kB  Shared_Clean=   472 kB  Private_Dirty= 65640 kB  Pss= 65677 kB
子 写之前:         Rss= 65772 kB  Shared_Clean=   132 kB  Private_Dirty=    44 kB  Pss= 32848 kB
子 写之后:         Rss= 65824 kB  Shared_Clean=   184 kB  Private_Dirty= 65588 kB  Pss= 65623 kB
父 子进程写完时: Rss= 66144 kB  Shared_Clean=   504 kB  Private_Dirty= 65588 kB  Pss= 65657 kB
父 子进程退出后: Rss= 66144 kB  Shared_Clean=   504 kB  Private_Dirty= 65640 kB  Pss= 65684 kB

关键的一行是子进程写之前那一次:它的 Rss 有 65772 kB,看起来和父进程一样占着六十多兆内存,可 Pss 只有 32848 kB,正好是父进程 65677 kB 的一半。Pss 是把一页按共享它的进程数平摊之后算出来的量,一半这个数字说明这 64 MB 在物理内存里只有一份,两个进程各认一半。子进程写完之后 Pss 回到 65623 kB,Private_Dirty 涨到 65588 kB,说明缺页处理真的复制出了一份属于它自己的副本,从此这段缓冲区在物理上也变成了两份。

这五个采样点还留下两个容易误读的地方。Rss 从头到尾都在 66000 kB 附近,因为虚拟地址范围并没有变大或者变小,写时拷贝换掉的是这些地址背后挂着的物理页,不是地址本身;Shared_Clean 全程只有几百 kB,这一列统计的是干净的可共享页,比如可执行文件映射进来的代码页,拿它来判断写时拷贝是否发生过会得到错误的结论。

写时拷贝还带来一个更实用的效果,就是延迟申请。fork 只复制页表,物理页等到有人写入时才分配,子进程如果用 fork 加 exec 立刻换成一个新程序,那些页根本来不及被写,也就不会产生任何复制代价。第 14 篇会用程序替换来验证这一点。

图 2 写时拷贝:两份页表指向同一批物理页,写入时复制一页再改页表

四、用 strace 看 fork 在系统调用层是什么

前三节都在 C 语言的层面看 fork,看返回值、虚拟地址和物理页的归属。镜头再往下推一层,看看一次 fork 在系统调用层留下了什么。测试机上的 strace 是 4.24 版,三个选项就能把这件事看清楚:-f 让跟踪跟着 fork 出来的子进程一起走,-e 只挑关心的几个调用,-o 把结果写进文件,免得跟踪信息和程序自己的输出交错在一起。

bash 复制代码
cd /home/cocatrice/lab12
strace -f -e trace=clone,write,exit_group -o /tmp/t_fork.strace ./bin/fork_basic
cat /tmp/t_fork.strace

回显的第一行就是 fork 的真身:

text 复制代码
25086 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f0daa956a10) = 25087
25086 write(1, "Before: pid is 25086\nAfter:pid i"..., 59 <unfinished ...>
25087 write(1, "Before: pid is 25086\nAfter:pid i"..., 55) = 55
25086 <... write resumed>)              = 59
25087 exit_group(0 <unfinished ...>
25086 exit_group(0 <unfinished ...>
25087 <... exit_group resumed>)         = ?
25086 <... exit_group resumed>)         = ?
25087 +++ exited with 0 +++
25086 +++ exited with 0 +++

glibc 没有给 fork 单独配一个新的系统调用,而是把它翻译成 clone 加一组标志位。返回值 25087 就是子进程的进程号,也就是父进程里 fork 拿到的那个值。child_stack=NULL 说明新进程用的是它自己那份地址空间,只有线程才会在这里填一个单独的栈地址。flags 里的三位各管一件事:CLONE_CHILD_SETTID 让内核把新进程的线程号写进它自己的内存,CLONE_CHILD_CLEARTID 在这个号没用之后负责清掉它,SIGCHLD 让子进程退出时给调用方发一个信号,父进程后面回收孩子就是被这个信号叫醒的。标志位里没有 CLONE_VM,两个进程各有各的页表,写时拷贝才有意义;换成线程时 flags 里会带上 CLONE_VM,两边共用一份地址空间,复制页表这件事根本不会发生。

两条 write 各自带着自己进程的编号往下走,59 字节的那次属于父进程,55 字节的那次属于子进程,两次写入谁先谁后和程序的输出顺序一样不受保证。收尾的方式两个进程都是 exit_group,而不是 exit。exit_group 这个名字的意思是整组线程一起结束,单线程程序看起来和 exit 没有区别,进程退出在内核里是一次整组收尾。

退出路径上还有一个能把缓冲区那件事钉死的对照,把第十节里的两个程序送进同一条 strace 命令:

bash 复制代码
strace -e trace=write,exit_group -o /tmp/t_bufexit.strace ./bin/buf_exit
cat /tmp/t_bufexit.strace
strace -e trace=write,exit_group -o /tmp/t_buf__exit.strace ./bin/buf__exit
cat /tmp/t_buf__exit.strace

两份清单放在一起看更清楚:

text 复制代码
--- buf_exit ---
write(1, "hello", 5)                    = 5
exit_group(0)                           = ?
+++ exited with 0 +++
--- buf__exit ---
exit_group(0)                           = ?
+++ exited with 0 +++

buf_exit 在退出之前多出一次 write,那 5 个字节就是 exit 收尾时把缓冲区刷出去的动作;buf__exit 从头到尾没有一次 write,字符串在内核门口连同进程一起消失了。缓冲区到底刷没刷,在系统调用层是两份长度不同的清单,比在终端上看一眼输出更硬。

再看一次前面那个派发任务的程序,三次 fork 对应三次 clone,三次回收对应四次 wait4:

text 复制代码
25182 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fa22baafa10) = 25183
25182 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fa22baafa10) = 25184
25182 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fa22baafa10) = 25185
25182 wait4(-1,  <unfinished ...>
25182 <... wait4 resumed>NULL, 0, NULL) = 25183
25182 wait4(-1,  <unfinished ...>
25182 <... wait4 resumed>NULL, 0, NULL) = 25184
25182 wait4(-1, NULL, 0, NULL)          = 25185
25182 wait4(-1, NULL, 0, NULL)          = -1 ECHILD (No child processes)

前面三次 wait4 各回收一个孩子,第四次返回 -1,errno 是 ECHILD,意思是手里已经没有孩子可以回收。等待家族的库函数在系统调用层是 wait4,第四次那个返回值以后还会遇到,一个进程的孩子都被收干净之后再等,拿到的就是这个错误。

vfork 在系统调用层更好认,它的名字就是 vfork:

bash 复制代码
strace -f -e trace=process,write -o /tmp/t_vfork2.strace ./bin/vfork_share
cat /tmp/t_vfork2.strace
text 复制代码
25169 execve("./bin/vfork_share", ["./bin/vfork_share"], 0x7ffc661aaa28 /* 23 vars */) = 0
25169 arch_prctl(ARCH_SET_FS, 0x7f2af48a3740) = 0
25169 vfork( <unfinished ...>
25170 exit_group(0)                     = ?
25170 +++ exited with 0 +++
25169 <... vfork resumed>)              = 25170
25169 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=25170, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
25169 write(1, "\345\255\220\350\277\233\347\250\213 pid=25170 g=1 &g=0x601"..., 72) = 72
25169 exit_group(0)                     = ?
+++ exited with 0 +++

vfork 这一行从发起到返回之间,夹着子进程从出生到退出的全过程,父进程确实被挂起在这一次调用里,一直到 SIGCHLD 才继续往下走。最后那次 write 是父进程一个人发出的,72 个字节里同时装着两条 printf 的内容,因为子进程的 printf 写进了共享的缓冲区,_exit 没有刷,全部由父进程在收尾时一起送出去。第十二节讲 vfork 的时候还会用到这一段。

这几段回显并排放好之后,用户态和内核的分界线就出来了:程序里写的是 fork、vfork、exit 这些函数,系统调用层出现的是 clone、vfork、exit_group,前者是对后者的封装,多出来的那点工作也就有了落脚点。

图 3 一次 fork 在系统调用层的痕迹:clone 带着三个标志位建出新进程,父子各自 exit_group 收尾

五、fork 之后哪些资源被继承

fork 复制的是内核里那张记账的表格,表格上除了代码和数据,还记着一长串别的东西。这些东西决定了子进程一出生就接手了父进程的哪些家当,日常代码里那些看起来奇怪的现象也大多从这里来。这一节用三组真机回显把几张表翻一遍,最后用一张表收口。

文件描述符表被复制,表项指向同一份打开文件

下面这段程序在 fork 之前打开一个文件写下第一行,子进程接着写第二行,然后关掉自己手里的描述符,父进程等它结束之后再写第三行:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(void)
{
    int fd = open("out/share.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    pid_t pid;

    if (fd < 0) {
        perror("open");
        return 1;
    }
    write(fd, "fork 之前写的第一行\n", strlen("fork 之前写的第一行\n"));
    printf("父进程 fd = %d\n", fd);

    pid = fork();
    if (pid == 0) {
        write(fd, "子进程写的第二行\n", strlen("子进程写的第二行\n"));
        close(fd);
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    if (write(fd, "父进程写的第三行\n", strlen("父进程写的第三行\n")) < 0) {
        perror("父进程再写");
    }
    close(fd);
    return 0;
}

在 /home/cocatrice/lab12 下跑一次,再把文件内容读出来:

text 复制代码
父进程 fd = 3
--- out/share.txt 的内容 ---
fork 之前写的第一行
子进程写的第二行
父进程写的第三行
--- 字节数 77 ---

三行都在,一个字节都没有被覆盖,这份回显说明三件事。fork 之前 open 拿到的 fd=3 在子进程里同样能用,说明描述符表被复制了一份。子进程写完就 close 掉了自己的 fd,父进程等它退出之后用同一个 fd=3 还能接着写,说明两张表是各自独立的,一方的 close 收不走另一方的名额。两行文字是顺着排下来的,谁都没有回到文件开头重写,说明两个进程的 3 号表项指向内核里同一份打开文件对象,读写用的偏移两边共用。

这一条在后面的篇章里会反复出现:第 14 篇的程序替换会让新程序继承这批描述符,第 15 篇的 shell 把子进程的输出写进重定向的文件,靠的也是这份继承。

各自 open 一次,偏移就各算各的

把上面那个程序改一处,让子进程自己重新 open 一次同一个文件:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(void)
{
    int fd = open("out/reopen.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    pid_t pid;

    if (fd < 0) {
        perror("open");
        return 1;
    }
    pid = fork();
    if (pid == 0) {
        int fd2 = open("out/reopen.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
        write(fd2, "AAAAAAAAAA\n", 11);
        close(fd2);
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    write(fd, "BBBBB\n", 6);
    close(fd);
    return 0;
}
text 复制代码
--- out/reopen.txt 的内容 ---
BBBBB
AAAA
--- 字节数 11 ---

子进程写进去的 11 个字节刚落地,父进程用老 fd 从偏移 0 开始写了 6 个字节,把前面那一行盖掉,只剩下四个 A 的尾巴。两次 open 在内核里对应两个打开文件对象,各自的偏移互不相干,所以父进程那次写回到了文件开头,而不是接在子进程后面。

两段合起来看,fork 继承过去的是那张表本身,不是偏移的一份独立拷贝。父子同时往同一个描述符里写,写入会接龙;想要各写各的、每次都追加在末尾,做法是让每个进程打开文件的时候带上 O_APPEND,这在基础 IO 那一批里细讲。

信号处理方式被复制一份

资源继承里最容易被忽略的是信号的处理方式。下面这段程序给 SIGUSR1 装一个处理函数,fork 之后让子进程给自己发一个同样的信号,再把子进程的处理方式改成忽略,最后父进程也发一次:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

static void OnSignal(int sig)
{
    char buf[64];
    int n = snprintf(buf, sizeof(buf), "    处理函数被调到,收到的信号是 %d\n", sig);
    write(1, buf, n);
}

int main(void)
{
    struct sigaction act;
    pid_t pid;

    memset(&act, 0, sizeof(act));
    act.sa_handler = OnSignal;
    sigemptyset(&act.sa_mask);
    sigaction(SIGUSR1, &act, NULL);
    printf("父进程 pid=%d,已经给 SIGUSR1 装上处理函数\n", getpid());

    pid = fork();
    if (pid == 0) {
        printf("  子进程 pid=%d,准备给自己发一个 SIGUSR1\n", getpid());
        raise(SIGUSR1);
        printf("  子进程接到信号之后还在往下跑\n");
        signal(SIGUSR1, SIG_IGN);
        raise(SIGUSR1);
        printf("  子进程把 SIGUSR1 改成忽略之后,再发一次就没有动静了\n");
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    printf("父进程这边再给自己发一次 SIGUSR1\n");
    raise(SIGUSR1);
    printf("父进程的处理函数还是原样,没被子进程改掉\n");
    return 0;
}

回显按顺序排开是这样的:

text 复制代码
父进程 pid=25082,已经给 SIGUSR1 装上处理函数
  子进程 pid=25083,准备给自己发一个 SIGUSR1
    处理函数被调到,收到的信号是 10
  子进程接到信号之后还在往下跑
  子进程把 SIGUSR1 改成忽略之后,再发一次就没有动静了
父进程这边再给自己发一次 SIGUSR1
    处理函数被调到,收到的信号是 10
父进程的处理函数还是原样,没被子进程改掉

SIGUSR1 的默认动作是结束进程,子进程里如果什么都没有继承到,raise 那一行之后就看不到后面的输出了。回显里处理函数照常被调到,说明信号的处理方式跟着 fork 一起复制了过去,子进程不重新装就有。子进程后来把 SIGUSR1 改成忽略,自己再发一次果然没有动静;父进程这边照样被处理函数接住,说明这张处理方式的表也是各自一份,子进程改自己的那一份动不了父进程。程序替换是个例外,exec 之后处理方式会复位成默认值,第 14 篇会用回显验证这一条。

继承项与新建项

把上面几段加上前面几节的内容收进一张表,fork 之后子进程手里有什么,一眼能看全:

项目 fork 之后的归属
地址空间与页表 页表复制一份,物理页写时拷贝
文件描述符表 复制一份表,表项指向同一份打开文件对象,偏移共用
C 库缓冲区 连里面的内容一起复制,第十节的 buf_dup 就是这个现象
信号处理方式与屏蔽字 复制一份,子进程改自己的不影响父进程
环境表 复制一份,取值的三条路在第 11 篇里走过
当前工作目录、文件创建掩码、真实的用户与组 全部带过去
进程号与父进程号 新分配,子进程的父进程号就是 fork 的调用方
CPU 时间记账 从零开始,父进程已经花掉的时间不算在子进程头上
挂起的信号 子进程里是空的,不继承
定时器与 fcntl 记录锁 不继承

这张表在面试里通常会被压成一句:fork 之后子进程拿到的是父进程的一份副本,进程号、时间记账和挂起的信号是新的,页表和描述符表虽然复制了,但底下指向的物理页与打开文件对象还是一家。

图 4 fork 之后的资源账本:描述符表复制一份指向同一份打开文件,信号处理方式与缓冲区各得一份副本

六、fork 的两种常规用法

fork 在真实代码里通常出现在两种场合,一种是父进程复制自己,让父子各跑一段不同的代码;另一种是复制出来的子进程立刻换成一个新程序去执行。两种用法的代码骨架不一样,理解它们的差别,后面的程序替换与自主 shell 都会顺很多。

第一种用法的典型形态是父进程守着一个循环等任务,每来一个请求就 fork 一个子进程去处理,父进程自己继续等下一个。下面这段程序用一个循环模拟三个请求,每派发一个就建一个孩子:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int req;
    pid_t pid;

    for (req = 1; req <= 3; req++) {
        printf("父进程 %d: 收到第 %d 个请求\n", getpid(), req);
        pid = fork();
        if (pid < 0) {
            perror("fork");
            return 1;
        }
        if (pid == 0) {
            printf("    子进程 %d: 开始处理第 %d 个请求\n", getpid(), req);
            sleep(1);
            printf("    子进程 %d: 第 %d 个请求处理完\n", getpid(), req);
            exit(0);
        }
    }
    while (wait(NULL) > 0) {
    }
    printf("父进程 %d: 请求都收完了\n", getpid());
    return 0;
}

跑起来的回显把两条线的分工写得很清楚:

text 复制代码
父进程 10512: 收到第 1 个请求
父进程 10512: 收到第 2 个请求
    子进程 10513: 开始处理第 1 个请求
父进程 10512: 收到第 3 个请求
    子进程 10514: 开始处理第 2 个请求
    子进程 10515: 开始处理第 3 个请求
    子进程 10513: 第 1 个请求处理完
    子进程 10514: 第 2 个请求处理完
    子进程 10515: 第 3 个请求处理完
父进程 10512: 请求都收完了

父进程的三条收到请求挤在输出前面,三个子进程的进程号各不相同,处理动作可以重叠进行。父进程在循环里没有等待任何一个孩子,它把任务派发出去之后立刻回到下一轮;三个子进程各自睡觉、各自打印、各自退出,父进程最后用一个 while 循环把孩子的退出状态收干净,这一步不做就会留下僵尸进程,第 9 篇已经把这个坑踩过一遍。

第二种用法的形态是父子分工之后,子进程马上调用 exec 系列函数,把自己整个程序映像换成另一个程序。这种写法的关键是换程序之前不能让子进程做多余的事,因为 exec 成功之后原程序的代码和数据都会被新的覆盖,写在后面的话根本没有机会执行。后面写自主 shell 的时候,每敲一条外部命令走的都是这条路:shell 先 fork 出一个孩子,孩子换成 ls 或者 grep 去跑,shell 自己在原地等着读下一条命令。

图 5 fork 的两种常规用法:父子分工处理任务,或者子进程换成一个新程序

七、fork 失败的两个原因

fork 返回 -1 表示这一孩子没能建起来,失败原因写在 errno 里,这台机器上会撞到的两个值是 11 与 12,对应的描述串是 Resource temporarily unavailable 与 Cannot allocate memory。11 号错误说的是系统里的进程或者这个用户的进程数已经到顶,12 号错误说的是内存不够再建一个进程。

想要看见 11 号错误,光把上限压小还不够。最初写的程序每 fork 一个孩子就立刻回收,同时存活的进程数始终只有一两个,把上限压到 120 之后连着建了两千个也没有撞墙:

text 复制代码
4096
当前进程号 10556,开始连续 fork
连着成功 2000 次,没有撞到上限,先停
成功创建并回收了 2000 个子进程

限制管的是某一时刻同时存在的进程数,不是总共能创建多少个,前脚建好后脚回收的写法永远看不到上限。把子进程改成睡 30 秒一直活着、父进程建完再统一清理,现场立刻出现:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <sys/wait.h>

#define MAXC 4096

static pid_t kids[MAXC];

int main(void)
{
    int n = 0, i;
    pid_t pid;

    while (n < MAXC) {
        pid = fork();
        if (pid < 0) {
            printf("第 %d 次 fork 失败: errno=%d (%s)\n", n + 1, errno, strerror(errno));
            break;
        }
        if (pid == 0) {
            sleep(30);
            _exit(0);
        }
        kids[n++] = pid;
    }
    printf("失败之前成功建起了 %d 个子进程\n", n);
    for (i = 0; i < n; i++) {
        kill(kids[i], SIGKILL);
    }
    for (i = 0; i < n; i++) {
        waitpid(kids[i], NULL, 0);
    }
    return 0;
}

在 ulimit -u 120 的限制下跑起来,回显只留下两行:

text 复制代码
第 116 次 fork 失败: errno=11 (Resource temporarily unavailable)
失败之前成功建起了 115 个子进程

数字是 115 而不是 120,差额来自这次会话里本来就存在的进程。ulimit 的限制按用户统计,登录 shell、执行脚本的 bash、以及之前程序留下的进程都算在里面,剩下能用的名额在程序开始循环之前就已经被占去几个。

12 号错误比 11 号难复现,下面这段程序先申请 80 MB 并全部写满,再去 fork:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>

int main(void)
{
    size_t size = 80UL * 1024 * 1024;
    char *p = malloc(size);
    pid_t pid;

    if (p == NULL) {
        perror("malloc");
        return 1;
    }
    memset(p, 'A', size);
    printf("父进程已经占用 80 MB,当前 pid=%d\n", getpid());
    pid = fork();
    if (pid < 0) {
        printf("fork 失败: errno=%d (%s)\n", errno, strerror(errno));
        return 0;
    }
    if (pid == 0) {
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    printf("fork 成功,子进程 pid=%d\n", pid);
    return 0;
}

把虚拟内存上限压到 120 MB 跑一次,再把限制放开跑一次,两次都建好了孩子:

text 复制代码
#### 虚拟内存压到 120 MB 再 fork
父进程已经占用 80 MB,当前 pid=19016
fork 成功,子进程 pid=19017

#### 不压限制时同一份程序
父进程已经占用 80 MB,当前 pid=19018
fork 成功,子进程 pid=19019

八十兆的地址空间对应的页表只有几十页,fork 克隆页表的花费很小,而真正的物理页要等有人写入时才分配,写时拷贝把这个开销推到了后面。想让 fork 撞上 12 号错误,需要让父进程的地址空间大到连页表都放不下,靠一个缓冲区很难做到。

八、进程退出的三种场景

进程结束的方式归成三类:代码从头跑到尾而且结果正确,代码跑完了但结果不正确,代码还没跑完就被意外终止。前两类都由进程自己决定,区别只在传给父进程的退出码;第三类由外部信号造成,进程连选择退出码的机会都没有。

正常退出有四种写法,分别是 main 里的 return、库函数 exit、系统调用 _exit,以及代码里什么都不写、函数走到末尾自己返回。它们的细微差别留在第十节和第十一节里拆,这里只需要记住它们都属于进程主动结束。异常终止的现场可以自己给自己发信号来构造:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

int main(int argc, char *argv[])
{
    int sig = 2;

    if (argc > 1) {
        sig = atoi(argv[1]);
    }
    printf("给自己发信号 %d\n", sig);
    fflush(stdout);
    raise(sig);
    printf("这一行不会出现\n");
    return 0;
}

程序自己调用 raise 把信号发给自己,三个常用的终止信号跑出来的结果是这样:

text 复制代码
给自己发信号 2
SIGINT 之后 -> 130
给自己发信号 15
19027 Terminated              ./bin/sig_self 15
SIGTERM 之后 -> 143
给自己发信号 9
19028 Killed                  ./bin/sig_self 9
SIGKILL 之后 -> 137

三行标记里带 Terminated 或者 Killed 的那些由 shell 补上,它是在告诉使用者这个孩子不是正常结束的。130、143、137 这三个数字来自 shell 的一次换算,把信号编号加上 128 就得到它,程序自己并没有填过这样的退出码,2、15、9 三个信号编号分别对应键盘中断、终止请求和无法被拦截的强杀。

进程退出时内核要做的事情和创建时刚好对称,把进程占用的代码段、数据段和内核里的进程表项全部收回,其中内核数据结构的回收在父进程把退出状态读走之后才真正完成,这也是僵尸进程产生的源头。

图 6 进程退出的三种场景与它们的退出码

九、退出码是给谁看的

退出码这个名字容易让人以为程序自己能看到它,实际写进退出码的进程在写完的那一刻就不存在了,能看到它的是父进程和 shell。在 shell 里读上一条命令的退出码用的是 echo $?,几条真实命令的结果排在一起很有代表性:

text 复制代码
ls 自己家目录 -> 0
ls /root -> 2
bash: let: a=1/0: division by 0 (error token is "0")
let a=1/0 -> 1
grep 找不存在的东西 -> 1

访问自己能读的目录返回 0,访问只有 root 能进的目录返回 2,这是 ls 自己定义的失败码。除法算错的那条命令返回 1,因为在它之前 bash 已经把错误信息打了出来,退出码只表示这一条命令没有成功,不携带更细的原因。故意去读一个没有权限的文件也会得到 1,同样的 1 对应了完全不同的两种情形,光看数字判断失败原因并不可靠。

自己写的程序用退出码区分结论时,范围要控制在一个字节里。main 的返回值虽然是 int,真正被父进程读到的只有低 8 位,超出范围的部分会被截掉:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    int code = 0;

    if (argc > 1) {
        code = atoi(argv[1]);
    }
    printf("main 准备返回 %d\n", code);
    return code;
}

同一个程序传进不同的值,shell 读到的结果是这样:

text 复制代码
传 0,$? 读到 0
传 1,$? 读到 1
传 255,$? 读到 255
传 256,$? 读到 0
传 -1,$? 读到 255
传 300,$? 读到 44

256 与 0 的结果都是 0,因为 256 的二进制是 1 后面跟八个零,低八位全是零;-1 补码的八个低位全是 1,读出来是 255;300 减去 256 剩下的 44 被留下。程序内部拿返回值当作任务结论时,用 0 到 255 之外的值就要预料到这种截断。

同一个数字还有第二种解释方式,它可能是信号编号加上 128 之后的结果。128 到 255 这一段区间里既有程序员自己定义的退出码,也有 shell 拼出来的信号码,区分它们的办法是看进程结束的方式,正常退出留下的值可以自己定义,被信号终止留下的值由内核与 shell 决定。errno 与退出码是两套独立的编号,容易混在一起,把常见的 errno 用同一个函数翻译一遍有助于记住它们的区别:

c 复制代码
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void)
{
    int list[] = {0, 1, 2, 11, 12, 13, 14, 22, 24, 111, 112, 113};
    size_t i;

    for (i = 0; i < sizeof(list) / sizeof(list[0]); i++) {
        printf("errno %3d -> %s\n", list[i], strerror(list[i]));
    }
    return 0;
}

这台机器上给出的描述串是这样的:

text 复制代码
errno   0 -> Success
errno   1 -> Operation not permitted
errno   2 -> No such file or directory
errno  11 -> Resource temporarily unavailable
errno  12 -> Cannot allocate memory
errno  13 -> Permission denied
errno  14 -> Bad address
errno  22 -> Invalid argument
errno  24 -> Too many open files
errno 111 -> Connection refused
errno 112 -> Host is down
errno 113 -> No route to host

errno 只描述函数失败的细节,进程退出码回答的是进程这个整体干得怎么样,两者在程序里各管一段,不要用一套编号去套另一件事。

十、exit 与 _exit 差一个缓冲区

exit 与 _exit 都能结束进程并且带一个退出码,区别在于退出之前做了多少收尾工作。_exit 是系统调用,进到内核里直接结束进程;exit 是 C 库对 _exit 的封装,在调用 _exit 之前会多做三件事:把 atexit 与 on_exit 注册过的清理函数跑一遍,关闭所有打开的流并把缓冲区里剩下的数据写出去,然后才调用 _exit。

这两件事能不能被看见,取决于缓冲区里有没有还没送出去的数据。下面两个程序只差一行,一个用 exit 出门,一个用 _exit 出门:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    printf("hello");
    exit(0);
}
c 复制代码
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("hello");
    _exit(0);
}

两个程序里的 printf 都没有换行,数据先躺在 C 库的用户态缓冲区里。在终端上分别跑一次,一个打出了 hello,另一个什么都没有:

text 复制代码
hello <- buf_exit 后面没有换行
 <- buf__exit 这里什么都没有

判断有没有丢数据不能只看终端,重定向到文件之后再数一次字节更准确:

text 复制代码
buf_exit 的文件大小 5 字节,内容是 [hello]
buf__exit 的文件大小 0 字节,内容是 []

换到重定向之后 exit 的那份文件里有五个字节的 hello,_exit 的那份是空文件。终端上看起来像是 _exit 也打出了东西,那是因为终端属于行缓冲之外的另一种缓冲策略,可一旦输出落到文件就只剩下冷冰冰的零字节。写程序时如果只在终端上看过效果,把输出重定向到文件之后发现少了一段日志,多数就是这个原因造成的。

atexit 注册的清理函数同样只在 exit 这一边生效:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

static void bye1(void)
{
    printf("[atexit] 清理函数一\n");
}

static void bye2(void)
{
    printf("[atexit] 清理函数二\n");
}

int main(void)
{
    atexit(bye1);
    atexit(bye2);
    printf("主流程走完,准备退出\n");
    exit(3);
}

把 exit(3) 换成 _exit(3),清理函数就不会被执行,两次回显放在一起对照:

text 复制代码
主流程走完,准备退出
[atexit] 清理函数一
[atexit] 清理函数二
-> 3
主流程走完,准备退出
-> 3

清理函数的执行顺序和注册顺序相反,先注册的 bye1 最后跑,这个行为像是把清理函数压进一个栈里,退出时从栈顶往外取。退出码那一行两次都是 3,说明 _exit 照样能把退出码交出去,它少做的只是用户态的收尾工作。

这个缓冲区还有一个容易被忽略的连带影响,fork 会把 C 库的缓冲区一起复制给子进程。下面这段程序在 fork 之前打印一行不带换行的字,然后让子进程 exit(0):

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid;

    printf("这一行没有换行,先躺在缓冲区里");
    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        exit(0);
    }
    waitpid(pid, NULL, 0);
    printf("\n父进程收尾\n");
    return 0;
}

同一条 printf 在输出里出现了两次:

text 复制代码
这一行没有换行,先躺在缓冲区里这一行没有换行,先躺在缓冲区里
父进程收尾

子进程从 fork 里出来时,缓冲区里那份还没送出去的字符跟着页表一起被复制了一份,子进程调用 exit 时把它刷了出去,父进程后面也把同一句话刷了出去。往文件里重定向一次结果完全一样,因为两份缓冲区最终都落到了同一个文件偏移上,这个现象解释了为什么有人会把日志写成两份而完全找不到原因。

图 7 exit 与 _exit 的差别:一个跑清理与刷缓冲,一个直接进内核

十一、return 的两种身份

return 在不同函数里的含义完全不同,出现在 main 里它和一个退出码走的是同一条路,出现在别的函数里它只是普通的返回上一层。下面这段程序在 work 里 return 3,在 main 里没有把它继续传出去:

c 复制代码
#include <stdio.h>

static int work(void)
{
    printf("work 里 return 3\n");
    return 3;
}

int main(void)
{
    work();
    printf("main 里继续往下跑\n");
    return 0;
}

回显里 work 的返回值没有走到 shell 那一边,$? 读到的是 main 自己的 0:

text 复制代码
work 里 return 3
main 里继续往下跑
-> 0

main 里的 return 走的是另一条路,它等同于调用 exit。调用 main 的那个运行时函数会把返回值当成 exit 的参数传出去,所以 main 里 return 与直接写 exit 的效果一样,连 atexit 注册的清理函数都会被执行:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

static void bye(void)
{
    printf("[atexit] main 里 return 也会跑到这里\n");
}

int main(void)
{
    atexit(bye);
    printf("main 准备 return 7\n");
    return 7;
}
text 复制代码
main 准备 return 7
[atexit] main 里 return 也会跑到这里
ret_exit 之后 $? = 7

清理函数跑到了,退出码也准确地传成了 7。看到这里就能明白为什么在 main 里写 return 0 和在别处写 return 0 不是一回事,也就能理解为什么有人在子函数里写 return 逻辑上没问题,却拿不到想要的退出码。

十二、vfork 与它的红线

vfork 也能创建一个新进程,和 fork 的差别在于它不复制父进程的地址空间。子进程直接借用父进程的地址空间接着跑,连页表都不用克隆,代价是父进程必须停下来等子进程离开这段地址空间才能继续,离开的方式只有 exec 或者 _exit 两种。

地址空间共享这件事用一个全局变量就能验证:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g = 1;

int main(void)
{
    pid_t pid = vfork();

    if (pid < 0) {
        perror("vfork");
        return 1;
    }
    if (pid == 0) {
        printf("子进程 pid=%d g=%d &g=%p\n", getpid(), g, (void *)&g);
        g = 2;
        _exit(0);
    }
    printf("父进程 pid=%d g=%d &g=%p\n", getpid(), g, (void *)&g);
    return 0;
}

父进程自己把 g 初始化成 1,从来没有改过它,读到的时候却变成了子进程写进去的 2:

text 复制代码
子进程 pid=14638 g=1 &g=0x601054
父进程 pid=14637 g=2 &g=0x601054

两个进程的地址一样,值也串在了一起,这是 fork 里永远不会出现的结果。父进程在子进程跑完之前处于什么状态,用一个让子进程睡五秒的程序配合 ps 采样就能看到:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = vfork();

    if (pid < 0) {
        perror("vfork");
        return 1;
    }
    if (pid == 0) {
        printf("子进程 %d 开始睡 5 秒,父进程这时候在干什么?\n", getpid());
        sleep(5);
        printf("子进程 %d 睡完了\n", getpid());
        _exit(0);
    }
    printf("父进程 %d 回来了\n", getpid());
    return 0;
}

程序放到后台跑,在子进程睡觉的那一秒采一次样,等它结束之后再采一次:

text 复制代码
--- ps 采样(子进程睡着的这一秒)---
  PID  PPID STAT CMD
19562 19530 D+   ./bin/vfork_wait
19564 19562 S+   ./bin/vfork_wait
--- 子进程结束后再看一次 ---
  PID  PPID STAT CMD

--- 输出 ---
子进程 19564 开始睡 5 秒,父进程这时候在干什么?
子进程 19564 睡完了
父进程 19562 回来了

父进程那一行的状态是 D,意思是不可中断的睡眠,子进程那一行是 S,正在可中断地睡觉,谁在等谁一目了然。子进程跑完之后父进程才打印出自己那一行,两条输出也证实了顺序。vfork 的这条规矩并不只是建议,子进程如果在 _exit 之前直接 return,父进程的栈帧会被子进程的返回动作破坏,程序会掉进一种没有明确定义的状态:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = vfork();

    if (pid == 0) {
        printf("子进程不调 _exit,直接 return\n");
        return 0;
    }
    printf("父进程接着跑\n");
    return 0;
}

用 timeout 给它五秒钟,输出刷满了屏幕也没有停下来的意思:

text 复制代码
子进程不调 _exit,直接 return
父进程接着跑
子进程不调 _exit,直接 return
父进程接着跑
...(同一个两行循环一直刷下去,五秒到点被 timeout 掐掉)

两行输出交替出现,说明父子之间已经互相踩乱了栈和返回地址,谁也不再按原来的顺序执行。写 vfork 的子进程时有三条不能碰:不要在 _exit 或者 exec 之前修改任何数据,不要调用会修改缓冲区或者分配内存的函数,也不要让流程走到 main 的末尾。

那什么时候该用 vfork,答案是在现代 Linux 上几乎没有这个必要。fork 因为写时拷贝的存在,在子进程立刻 exec 的场合已经不复制物理页,vfork 省下来的那点页表开销,远远抵不上它带来的约束与风险。

图 8 vfork 与 fork 的地址空间对比:共享地址空间与挂起的父进程

实例代码

工程结构

text 复制代码
/home/cocatrice/lab12
├── src   (27 个 .c 源文件)
│   atexit__exit.c   atexit_exit.c    buf_dup.c        buf__exit.c
│   buf_exit.c       cow_mem.c        cow_val.c        exit_codes.c
│   fd_reopen.c      fd_share.c       fork_basic.c     fork_fail2.c
│   fork_fail.c      fork_nomem.c     fork_race2.c     fork_race.c
│   fork_ret.c       fork_task.c      jobs.c           ret_exit.c
│   ret_func.c       sig_inherit.c    sig_self.c       strerror_demo.c
│   vfork_bad.c      vfork_share.c    vfork_wait.c
├── bin   (28 个可执行文件)
└── out   (5 个输出文件)
    buf_dup.txt
    buf__exit.txt
    buf_exit.txt
    reopen.txt
    share.txt

源文件逐个编译,文件名保持一致,用一段循环把所有程序一次编完:

bash 复制代码
cd /home/cocatrice/lab12/src
for f in *.c; do
    name=$(basename $f .c)
    if gcc -Wall -o ../bin/$name $f 2>/tmp/gcc_err.txt; then
        echo "编译通过 $name"
    else
        echo "编译失败 $name"
        cat /tmp/gcc_err.txt
    fi
done

跑起来每个文件都过一遍,回显是一串整齐的编译通过:

text 复制代码
编译通过 atexit__exit
编译通过 atexit_exit
编译通过 buf_dup
编译通过 buf__exit
编译通过 buf_exit
编译通过 cow_mem
编译通过 cow_val
编译通过 exit_codes
编译通过 fd_reopen
编译通过 fd_share
编译通过 fork_basic
编译通过 fork_fail2
编译通过 fork_fail
编译通过 fork_nomem
编译通过 fork_race2
编译通过 fork_race
编译通过 fork_ret
编译通过 fork_task
编译通过 jobs
编译通过 ret_exit
编译通过 ret_func
编译通过 sig_inherit
编译通过 sig_self
编译通过 strerror_demo
编译通过 vfork_bad
编译通过 vfork_share
编译通过 vfork_wait

完整代码

前面各节用到的程序大多已经给过全文,这里补上写时拷贝那一段里只出现过统计函数的完整程序,它把缓冲区申请、父子采样和睡眠的时序串成了一条线:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define MB (1024UL * 1024UL)

static void sample(const char *tag)
{
    FILE *fp = fopen("/proc/self/smaps", "r");
    char line[256];
    unsigned long rss = 0, shared = 0, dirty = 0, pss = 0, v;

    if (fp == NULL) {
        perror("smaps");
        return;
    }
    while (fgets(line, sizeof(line), fp) != NULL) {
        if (sscanf(line, "Rss: %lu kB", &v) == 1) {
            rss += v;
        } else if (sscanf(line, "Shared_Clean: %lu kB", &v) == 1) {
            shared += v;
        } else if (sscanf(line, "Private_Dirty: %lu kB", &v) == 1) {
            dirty += v;
        } else if (sscanf(line, "Pss: %lu kB", &v) == 1) {
            pss += v;
        }
    }
    fclose(fp);
    printf("%-22s Rss=%6lu kB  Shared_Clean=%6lu kB  Private_Dirty=%6lu kB  Pss=%6lu kB\n",
           tag, rss, shared, dirty, pss);
}

int main(void)
{
    size_t size = 64 * MB;
    char *p = malloc(size);
    pid_t pid;

    if (p == NULL) {
        perror("malloc");
        return 1;
    }
    memset(p, 'A', size);
    printf("缓冲区 %p,共 64 MB,父进程已经全部写过一遍\n", (void *)p);
    sample("父 复制前:");

    pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        sample("子 写之前:");
        memset(p, 'B', size);
        sample("子 写之后:");
        sleep(3);
        exit(0);
    }
    sleep(2);
    sample("父 子进程写完时:");
    waitpid(pid, NULL, 0);
    sample("父 子进程退出后:");
    return 0;
}

其余的源文件都可以在核心知识里按节找到全文,第一节的 fork_basic.c 与 fork_race.c 与 fork_race2.c、第二节的 fork_ret.c、第三节的 cow_val.c、第五节的 fd_share.c 与 fd_reopen.c 与 sig_inherit.c、第六节的 fork_task.c、第七节的 fork_fail.c 与 fork_fail2.c 与 fork_nomem.c、第八节的 sig_self.c、第九节的 exit_codes.c 与 strerror_demo.c、第十节的 buf_exit.c 与 buf__exit.c 与 buf_dup.c 与 atexit_exit.c 与 atexit__exit.c、第十一节的 ret_func.c 与 ret_exit.c、第十二节的 vfork_share.c 与 vfork_wait.c 与 vfork_bad.c,一共二十五个,第四节里那几个 strace 命令用的都是前面的程序,加上这个 cow_mem.c 与后面实例里的 jobs.c,与 src 目录里的二十七个文件一一对应。

正常情况

cow_mem 是这一篇里跑得最久的一个程序,它先申请 64 MB 全部写一遍,然后在每一个关键时间点各采一次样,两次睡眠把父子两条线的时间错开:

text 复制代码
缓冲区 0x7f6084911010,共 64 MB,父进程已经全部写过一遍
父 复制前:         Rss= 66112 kB  Shared_Clean=   472 kB  Private_Dirty= 65640 kB  Pss= 65677 kB
子 写之前:         Rss= 65772 kB  Shared_Clean=   132 kB  Private_Dirty=    44 kB  Pss= 32848 kB
子 写之后:         Rss= 65824 kB  Shared_Clean=   184 kB  Private_Dirty= 65588 kB  Pss= 65623 kB
父 子进程写完时: Rss= 66144 kB  Shared_Clean=   504 kB  Private_Dirty= 65588 kB  Pss= 65657 kB
父 子进程退出后: Rss= 66144 kB  Shared_Clean=   504 kB  Private_Dirty= 65640 kB  Pss= 65684 kB

顺序上父进程先打印缓冲区地址并且立刻采样,子进程从 fork 里出来后先采一次再整片写一遍,写完再采一次然后睡三秒;父进程那边先睡两秒再采样,正好落在子进程写完的时刻附近,最后一次采样在 waitpid 返回之后。五个采样点连起来,写时拷贝从共享到复制的过程被完整记录了一次。

其余程序的正常运行都不需要额外的参数,返回值从 0 到 2 的几个任务分发程序、改一个全局变量就能看出的地址相同、把输出重定向到文件后数出的五个字节,都在各节的回显里出现过,这里不再重复贴一遍。

常见报错

第一个常见现象是编译告警,写 _exit 的时候忘记包含 unistd.h,gcc 只会给出警告并且照常生成可执行文件,程序照跑不误:

text 复制代码
/tmp/noheader.c: In function 'main':
/tmp/noheader.c:6:5: warning: implicit declaration of function '_exit' [-Wimplicit-function-declaration]
     _exit(0);
     ^
/tmp/noheader.c:6:5: warning: incompatible implicit declaration of built-in function '_exit' [enabled by default]
gcc 退出码 0

同样的警告在漏掉 sys/wait.h 调用 waitpid 时也会出现,编译器把没有声明过的函数当成返回 int 来处理,参数个数也对不上,程序能编译能链接,运行时却可能把栈里的垃圾当成返回值:

text 复制代码
/tmp/nowait.c: In function 'main':
/tmp/nowait.c:11:5: warning: implicit declaration of function 'waitpid' [-Wimplicit-function-declaration]
     waitpid(pid, NULL, 0);
     ^
gcc 退出码 0

第二个报错是 fork 自己返回 -1,回显里两行就能看出原因:

text 复制代码
第 116 次 fork 失败: errno=11 (Resource temporarily unavailable)
失败之前成功建起了 115 个子进程

Resource temporarily unavailable 这个描述听着像是资源暂时紧张稍后重试就行,实际原因是进程数已经到顶,这时候立刻重试只会继续失败,正确的做法是先把已经建好的孩子回收一部分。

第三个报错来自 vfork 的子进程不调 _exit 直接 return,这一次程序刷了八千多行之后自己停了下来,退出码居然是 0:

text 复制代码
子进程不调 _exit,直接 return
父进程接着跑
...(两行交替刷下去)
父进程接着跑
父进程接着跑

同一个程序在前面一次运行里被 timeout 五秒掐掉,两次的运行时间与输出行数都对不上,这就是未定义行为的典型表现,同一个程序每次跑出来的结果都不一样,靠调试手段也很难复现。

第四个坑埋在拿信号测退出码的时候,把 sleep 作为后台任务交出去再用 kill 给它发 SIGINT,wait 读到的是 0:

text 复制代码
1) 同一行里交出去的后台任务,kill -INT 之后 -> 0
2) 单独一段脚本里同一条命令 -> 0

非交互的 shell 会把交出去的后台任务对 SIGINT 的处理方式设成忽略,信号送过去没有生效,sleep 自己睡满三十秒正常退出,于是 wait 读到 0。想验证 130 这个退出码,得让进程自己给自己发信号,sig_self 就是这么写的。

第五个坑是输出消失,程序在终端上跑得好好的,重定向到文件之后日志少了一段,回去数一数字节就能确认:

text 复制代码
buf_exit 的文件大小 5 字节,内容是 [hello]
buf__exit 的文件大小 0 字节,内容是 []

第六个坑出现在测进程数上限的时候,把 fork 写在循环里、建一个就立刻回收一个,无论把 ulimit -u 压到多低都撞不到限制:

text 复制代码
4096
当前进程号 10556,开始连续 fork
连着成功 2000 次,没有撞到上限,先停
成功创建并回收了 2000 个子进程

限制统计的是同时存在的进程数量,回收得太快就等于没有压力,要留住现场必须让子进程活到下一次 fork 之前。

边界情况

main 的返回值只有低 8 位会被父进程读到,越界的值会被截断,这一组数字值得单独记一次:

main 里的返回值 shell 读到的 $? 说明
0 0 成功
1 1 常见的失败码
255 255 低八位能表示的最大值
256 0 1 后面八个零,低八位全被丢掉
-1 255 补码的八个低位全是 1
300 44 300 减 256 剩下的部分

被信号终止的进程没有机会自己填退出码,shell 用信号编号加 128 补出三个常用的值:

信号 编号 shell 读到的 $? 现场标记
SIGINT 2 130 无
SIGTERM 15 143 Terminated
SIGKILL 9 137 Killed

fork 的返回值同样分三档,判断的顺序会影响程序的健壮性:

返回值 出现在哪个进程 处理方式
-1 父进程 perror 打印 errno,回收已经建好的孩子之后退出
0 子进程 干活,然后 exit 或者 _exit 交回结论
大于 0 父进程 记下这个进程号,后面用 waitpid 按号回收

再往里还有几个边界值得留意,ulimit -u 统计的是一个用户下同时存在的进程与线程数量,脚本、登录 shell 和遗留进程都要占名额,所以 120 的上限只够建起 115 个孩子。vfork 的子进程与父进程共用一份地址空间,父进程在子进程调用 exec 或者 _exit 之前一直停在 D 状态,子进程改掉的每一个字节父进程都会看到。fork 之前留在 C 库缓冲区里的内容会被复制一份,子进程 exit 的时候刷一次,父进程也刷一次,输出里就会出现两行一样的内容。

实测数据

把这一篇里用到的环境与读数集中列一遍,方便对照:

项目 读数 说明
内核版本 3.10.0-1160.119.1.el7.x86_64 CentOS 7 默认内核
编译器 gcc 4.8.5 20150623 加 -Wall 全部编译通过
进程数上限 4096 / 120 ulimit -u 的默认值与压小后的值
fork 失败现场 第 116 次失败,此前成功 115 个 errno=11
退出码截断 256 到 0、-1 到 255、300 到 44 低八位有效
信号退出码 130 / 143 / 137 对应 SIGINT、SIGTERM、SIGKILL
缓冲区变化 Pss 从 65677 kB 变成 32848 kB 子进程写之前,物理页共享的直接证据
复制之后的缓冲区 Private_Dirty 从 44 kB 变成 65588 kB 子进程写完之后
输出字节数 buf_exit 5 字节,buf__exit 0 字节 重定向到文件后统计
vfork 父进程状态 D+ 子进程睡觉期间采样
vfork_bad 输出 8183 行后自行退出 未定义行为
fork 的系统调用 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD) = 子进程号 strace -f 抓到的第一行
缓冲区刷没刷 exit 多一次 write,5 个字节的 hello 被刷出去;_exit 连一次 write 都没有 两份清单对照
孩子收干净之后再等 wait4(-1, NULL, 0, NULL) = -1 ECHILD 实例里第四次等待
描述符继承 子进程 close 之后父进程用 fd=3 接着写,字节数 77 out/share.txt
偏移是否共用 父进程用老 fd 从偏移 0 覆盖,字节数 11 out/reopen.txt

实例

把这一篇里的创建与终止拼在一起,就是一个能把任务分出去、还能把每个任务的结论收回来小分发器。它给三个任务各建一个子进程,每个子进程干活的时间不一样,结论用不同的退出码交回去:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int i, status;
    int code[3] = {0, 1, 2};
    pid_t pid, kids[3];

    for (i = 0; i < 3; i++) {
        printf("父进程 %d: 派发任务 %d\n", getpid(), i + 1);
        pid = fork();
        if (pid < 0) {
            perror("fork");
            return 1;
        }
        if (pid == 0) {
            printf("    子进程 %d: 开始处理任务 %d\n", getpid(), i + 1);
            sleep(i + 1);
            printf("    子进程 %d: 任务 %d 结束,退出码 %d\n", getpid(), i + 1, code[i]);
            exit(code[i]);
        }
        kids[i] = pid;
    }
    for (i = 0; i < 3; i++) {
        pid = waitpid(kids[i], &status, 0);
        printf("父进程 %d: 回收子进程 %d,读到退出码 %d\n", getpid(), pid, WEXITSTATUS(status));
    }
    printf("父进程 %d: 三个任务都收完了\n", getpid());
    return 0;
}

编译之后跑一次,派发与回收的过程按时间排开是这样的:

text 复制代码
父进程 19277: 派发任务 1
父进程 19277: 派发任务 2
    子进程 19278: 开始处理任务 1
父进程 19277: 派发任务 3
    子进程 19279: 开始处理任务 2
    子进程 19280: 开始处理任务 3
    子进程 19278: 任务 1 结束,退出码 0
父进程 19277: 回收子进程 19278,读到退出码 0
    子进程 19279: 任务 2 结束,退出码 1
父进程 19277: 回收子进程 19279,读到退出码 1
    子进程 19280: 任务 3 结束,退出码 2
父进程 19277: 回收子进程 19280,读到退出码 2
父进程 19277: 三个任务都收完了

父进程连着三次派发,三个孩子的进程号分别是 19278、19279、19280,都记在 kids 这个数组里。子进程各自睡一到三秒,最先派出去的任务最先结束,这个顺序由 sleep 的参数决定,和派发的先后正好一致。父进程的派发全部做完之后才进入回收循环,回收的顺序按数组下标来,而不是按孩子结束的先后,所以输出里前面的任务先被回收,看起来整整齐齐。

回收那一行里的 WEXITSTATUS 是用来把退出码从 status 里取出来的宏,子进程 19280 交出 2,父进程读到的也是 2,结论没有丢。status 这个整型里面还装着别的信息,它哪几位表示退出码、哪几位表示终止信号、父进程怎样用一次等待处理任意一个孩子,都是下一篇的内容。

踩坑点

  • fork 只保证两条执行流从同一个位置继续,不保证谁先执行,依赖先后顺序的逻辑必须自己用等待或者别的同步手段钉住。
  • 子进程拿到的是 0、父进程拿到的是子进程号,判断分支的时候不要写成 pid == 0 之后又去用 pid 当进程号,它在那条分支里恒等于 0。
  • fork 返回 -1 表示这次创建失败,这个返回值只可能出现在父进程里,子进程里永远不会拿到 -1。
  • 写时拷贝换掉的是物理页,虚拟地址不会变,所以父子打印出来的地址始终相同,靠地址比较判断是否发生了复制得不到结论。
  • Pss 是判断共享程度的字段,Rss 只说明这段地址有多少内容驻留在内存里,用 Rss 衡量写时拷贝会得出错误的结论。
  • fork 失败的两个原因里,进程数超限会产生 EAGAIN,这个错误的描述串写的是 Resource temporarily unavailable,不要在循环里无脑重试。
  • ulimit -u 管的是同时存在的进程数量,建一个就回收一个的写法永远撞不到上限,要复现失败现场必须让子进程活着。
  • ulimit 的限制按用户统计,登录 shell 和脚本自己都占名额,所以压到 120 之后能建起来的孩子通常不到 120 个。
  • _exit 不会刷 C 库的缓冲区,printf 后面不带换行时数据还躺在用户态,换成 _exit 退出就会整段丢掉。
  • exit 会按注册顺序的倒序执行 atexit 注册的清理函数,需要保证资源一定被释放时别把清理工作写在 _exit 之后。
  • main 里 return 和 exit 是一回事,别的函数里 return 只是返回上一层,把结论写在普通函数里返回给 main 拿不到退出码。
  • fork 会把 C 库缓冲区里的内容一起复制给子进程,之前有过没换行的 printf 就会在输出里看到两遍。
  • 退出码只有低八位有效,返回 256 会被读成 0,返回 -1 会被读成 255,用超过 255 的数字表示结论要预料到截断。
  • 拿 sleep 这类后台任务测信号退出码时容易读到 0,非交互 shell 里的后台任务会忽略 SIGINT,验证 130 要用进程自己发信号的方式。
  • fork 之后父子共用同一个打开文件的偏移,两边同时往一个描述符里写会接龙,要各写各的得让每个进程自己 open 一次或者带上 O_APPEND。
  • 信号处理方式跟着 fork 一起复制,子进程改自己的那一份不影响父进程;exec 之后会复位成默认动作,不要指望新程序继承过来。
  • strace 里看到的 clone 就是 fork 的真身,flags 里没有 CLONE_VM 说明父子各有页表,看到 clone 不要默认它是在建线程。
  • vfork 的子进程必须用 _exit 或者 exec 离开,直接 return 会破坏父进程的栈帧,程序进入每次结果都不一样的未定义状态。
  • vfork 期间父子共用地址空间,父进程停在 D 状态一直等到子进程离开,子进程在 _exit 之前改数据会直接影响父进程看到的变量。
  • 现代 Linux 上 fork 配合写时拷贝已经足够省,子进程立刻 exec 的场合几乎没有复制开销,没有特别理由不要用 vfork。

本篇总结(模拟面试问题)

问:fork 之后父子进程各自拿到什么返回值,为什么要这样设计?

核心要点:子进程拿到 0,父进程拿到子进程的进程号,失败时父进程拿到 -1。子进程找父进程用 getppid 就够,父进程却要区分自己生下的每一个孩子,返回值只能对着父进程这边设计,两个进程看到的数字因此不同。

问:fork 之后为什么两个进程打印出来的地址一样,值却可以不一样?

核心要点:地址是虚拟地址,父子各有自己的页表,页表项指向同一批物理页,写入时触发缺页,内核复制一页并改掉触发方的页表项,之后两边的数据才分开。这也解释了为什么虚拟地址始终不变。

问:写时拷贝省下来的到底是什么开销?

核心要点:省下的是物理页的复制开销。fork 只克隆页表,物理页等到有人写入时才分配,子进程如果紧接着 exec 换程序,那些页根本不会被写,复制过程自然不会发生。

问:怎么用命令验证某个进程的退出码,又会碰到哪些坑?

核心要点:在 shell 里用 echo $? 读上一条命令的退出码。要注意返回值只有低八位有效,256 会变成 0、-1 会变成 255;被信号终止时读到的值是信号编号加 128,而给后台任务发 SIGINT 会因为信号被忽略而拿到 0。

问:exit 与 _exit 有什么区别,什么时候必须用 _exit?

核心要点:exit 会先跑 atexit 注册的清理函数、刷出 C 库缓冲区里剩余的数据,再调用 _exit 进内核结束进程,_exit 只有最后一步。vfork 出来的子进程必须用 _exit 或者 exec 离开,因为它的栈与父进程共用,返回会破坏父进程的栈帧。

问:fork 失败有哪些原因,怎么在现场复现出来?

核心要点:进程数超限会产生 EAGAIN,内存不足会产生 ENOMEM。复现 EAGAIN 要让子进程活着不回收,再把 ulimit -u 压小;ENOMEM 在现代内核上不好复现,因为 fork 只克隆页表,物理内存的分配被写时拷贝推后了。

问:一个进程的结论怎样交给父进程,父进程又该怎么读?

核心要点:子进程用 exit 或者 main 里的 return 把结论写进退出码,父进程用 wait 或者 waitpid 拿到 status,再用 WEXITSTATUS 把退出码取出来。只写不读会留下僵尸进程,父进程要负责回收。

问:fork 之后子进程继承了哪些东西,哪些是重新建立的?

核心要点:地址空间与页表被复制,物理页等到写入时才复制;文件描述符表复制一份,表项指向同一份打开文件对象,偏移共用;C 库缓冲区连里面的内容一起复制,信号处理方式与屏蔽字复制一份;进程号与父进程号重新分配,CPU 时间记账从零开始,挂起的信号、定时器与 fcntl 记录锁不继承。

问:在系统调用层看 fork 是什么,strace 里该盯哪几个名字?

核心要点:glibc 把 fork 翻译成 clone 加一组标志位。child_stack=NULL 表示新进程有自己的地址空间,SIGCHLD 让子进程退出时通知父进程,flags 里没有 CLONE_VM 说明父子各有页表,写时拷贝才有意义。子进程用 exit_group 收尾,父进程回收孩子走 wait4,孩子收干净之后再等会拿到 -1 和 ECHILD。

参考

相关推荐
blueSatchel4 小时前
关于dev下节点的创建
linux·嵌入式
老秦包你会4 小时前
Linux第十课---------进程间通信
linux
从小白到软工4 小时前
4-1 指针
c语言·开发语言·数据结构·经验分享·ai编程
研发小能4 小时前
DevOps 工具链选型:一体化平台,还是把单点工具拼起来?
运维·研发效能·devops·嘉为蓝鲸
福大大架构师每日一题5 小时前
agno v3.1.3发布:外部智能体全面进入“可持久化、可恢复、可取消、可回放”时代
服务器·数据库·php
wespaul5 小时前
VeapAI 自然语言操作实战|第 08 篇:治理层——审计、指标、评估与权限
运维·网络·数据库
宋明河5 小时前
windows与ubuntu双系统主机下ubuntu访问windows磁盘是只读
linux·windows·ubuntu
Sayai5 小时前
Kafka 2.8→3.9.1 跨大版本升级实战:ZooKeeper 同步升级、数据目录迁移与回滚预案全流程
大数据·运维·分布式·zookeeper·kafka
chenxy025 小时前
国产加速卡-应知应会
linux·运维·服务器