fork 的开销与争议

fork 是一个拥有 50 年历史的系统调用,也是一个传奇。一个程序员可以永远不用 read/write,也可以不懂 mmap,但必须懂 fork。fork 没有参数、如此简单,是 UNIX 哲学的布道者们的首选,被写进了几乎每一本操作系统教科书。然而在对立的另一面,回荡着不同的声音:fork 看起来诡异、颠覆初学者认知,而且开销巨大。本文站在这个对立面,为 fork 泼一盆冷水。

fork 是诡异的

C 语言教科书没法安安静静地讲 fork,因为 fork 不符合 C 函数的调用规范。C 语言和操作系统本就是两门正交的课程,C 函数可以在没有操作系统的单片机上调用,但 fork 不行——想理解 fork 的返回值,就要先理解操作系统进程。按照对 C 函数的认知,创建进程的 API 明显应该是这样的:

// 创建一个进程,成功返回 0,否则返回 -1,新进程从 start 开始运行
int create_process(void *(*start)(void *), void *arg, ...);

然后告诉学生,你可以在 start 里调用 exec 加载新程序映像。与之相比,fork 简直就是一个”丑陋的幽灵”——若不是 UNIX 卫道士们的鼓吹和灌输,fork 应该是反面教材才对,至少 Linux 不是还有 clone 调用吗?

#define _GNU_SOURCE
#include <sched.h>

int clone(int (*fn)(void *), void *child_stack,
         int flags, void *arg, ...
         /* pid_t *ptid, void *newtls, pid_t *ctid */ );

看下 clone 的 manual:

clone() creates a new process, in a manner similar to fork(2). ... When the child process is created with clone(), it commences execution by calling the function pointed to by the argument fn. (This differs from fork(2), where execution continues in the child from the point of the fork(2) call.) The arg argument is passed as the argument of the function fn.

但 clone 的参数之多,跟 Windows API 的风格有一拼,且历史远不如 fork 久远,所以没有 fork 那么受待见。

fork 是懒惰导致的 trick

fork 没有一个参数,你没法在创建新进程之前设置它的任何参数(比如优先级)。一旦 fork 调用返回,新进程就继承父进程的一切——连代码也是。所以你想设置新进程的优先级,必须在子进程里手工做:

if (fork() == 0) {
    // 设置优先级
    nice(-3);
} else {
    ...
}

你没法像下面这样:

// prio 为新进程的 nice 增量。
int prio = -3;
ret = create_process(new_process, &argv[0], prio, ...);

为什么一切都继承父进程?因为——这是 UNIX 作者 Dennis Ritchie 自己说的。UNIX fork 的取巧实现留下了坑,促使了后来的写时复制(copy on write)来填坑,却还是没有填平。在 UNIX 刚出现的那几年,内存很小、进程也很小,fork 完全复制父进程没问题;随着大进程出现,内存开销越来越大,才用写时复制来缓解。但即便内存页面写时复制了,地址空间的数据结构复制仍然少不了。

fork 的开销

提到这个话题,标准答案似乎都是”不要用进程,进程创建开销太大,尽量用线程”。再进一步,会扯上线程共享内存而进程不共享,再进一步,切换地址空间要切换页表,切换页表就要刷 cache……本文尝试避开 cache 的角度,看看 fork 到底哪里开销大。

内核数据结构的开销

操作系统领域绝不能忽略内核数据结构的开销。跟 fork 开销有关的两类数据:

  1. 页目录和页表;
  2. vm_area_struct 对象。

先说页表开销。在进程地址空间比较稀疏的情况下,光是页表就会占据很大内存,64 位系统更严重。多级页表只是为了解决稠密地址空间不必要的页表分配,它本身并不能节省内存,在稀疏地址空间反而更浪费。下面的 demo 就构建一个稀疏地址空间,放大 fork 写时复制带来的页表开销。

再看 vm_area_struct 对象:用户态进程里申请的每一块内存,在内核中都以 vm_area_struct 维护。如果调用了 10000 次 mmap,就有 10000 个 vm_area_struct 对象被创建。在 fork 调用中,即便没有任何内存写操作,这 10000 个结构对象的复制也是无条件的:

10000 * sizeof(struct vm_area_struct);

这往往没有必要,因为子进程一般都会 exec,从而释放掉这些地址空间和对应的 vm_area_struct 对象。

fork 写时复制带来的普通内存开销

父进程 fork 之后、子进程 exec 之前,如果父进程写了页面,就会发生写时复制,而这种写时复制大多是不必要的。vfork 可以阻塞父进程直到子进程 exec,但这对父进程不公道。

fork 写时复制带来的页表内存开销

先给出代码:

#include <unistd.h>
#include <printf.h>
#include <stdlib.h>
#include <sys/mman.h>
// 注意这个 magic 数字的由来:基于 /proc/$pid/maps 文件计算,用 stack 头减 heap 尾
#define CNT    8638055936
char *data;
int cnt = 0;
int main(int argc, char *argv[])
{
    long i, j = 0;
    // base 就是 heap 尾的大致位置
    unsigned long st1, base = 0x7f510721e000;
    pid_t pid;
    int ps = sysconf(_SC_PAGE_SIZE);
    // 由于要 fix 映射,需要页面对齐
    base = ((unsigned long)base & 0xfffffffffffff000);
    // 写时复制备用
    st1 = base;
    // 循环构建稀疏地址空间,将 CNT/ps/16 个页面均匀摊到 heap 和 stack 之间
    for (i = 0; i < CNT; i += ps*ps/16) {
        // FIX 映射,PRIVATE 映射
        data = mmap(base, ps-1, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE|MAP_FIXED, -1, 0);
        // 为展示写时复制的开销,父进程的稀疏页面需要在内存中
        mlock(data, ps-1);
        base += ps*ps/16;
        cnt++;
    }
    printf("mmap:%p %lx   cnt:%d\n", data, base, cnt);
    printf("请观察 fork 之前的内存用量!\n");
    printf("请注意稀疏 mmap 对页表内存占用的影响!\n");
    printf("敲任意键执行 fork!\n\n");
    getchar();
    if ((pid = fork()) < 0) {
        printf("create failed\n");
        exit(1);
    } else if (pid == 0) {
        printf("请观察 fork 之后、exec 之前的内存用量!\n");
        printf("敲任意键执行 exec!\n\n");
        getchar();
        printf("现在请观察 exec 之后的内存用量!\n");
        if (execl("/usr/bin/echo", "echo", "skinshoe", NULL) < 0) {
            perror("error on exec");
        }
    }
    // 写稀疏内存!如果发生在子进程 exec 之前,会导致不必要的写时复制
    for (i = 0; i < CNT; i += ps*ps/16) {
        *(char *)st1 = 122;
        st1 += ps*ps/16;
    }
    sleep(1000);
    return 0;
}

执行前先看页表内存开销:

[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:         2564 kB

执行代码,构建稀疏地址空间后:

[root@10 ~]# ./a.out
mmap:0x7f5309f1e000 7f530a01e000   cnt:8238

此时页表开销为:

[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:        19504 kB

稀疏地址空间果然如此,19M 的内存!敲回车执行 fork 后(父进程写稀疏地址空间、子进程不执行 exec):

[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:        36004 kB

果不其然,增加了一倍!父进程页表占据 18M 是四级页表的”锅”,但 fork 之后页表消耗加倍成为 36M,那绝对是 fork 的锅。现在再敲回车让子进程 exec:

[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:        19064 kB

内存恢复。想看父子进程各自的页表开销,可以这样:

for pid in `ps -e | grep bash | awk '{print $1}'`; do cat /proc/$pid/status | grep VmPTE; done

可以看到在 exec 前,父子进程都分配了同样的页表内存,然而子进程根本不需要——只是为了打印个 skinshoe,且父进程不巧发生了写时复制,就要白白消耗 19M。为了确认页表内存释放确实是 exec 导致而非子进程退出导致,把 echo 换成不会退出的 sleep 3600:

if (execl("/usr/bin/sleep", "sleep", "3600", NULL) < 0) {

重新执行,exec 完成后页表内存消耗为:

[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:        19108 kB
[root@10 ~]# ps -elf | grep [s]leep
0 S root     21434 21430  0  80   0 - 26989 hrtime 13:15 pts/2    00:00:00 sleep 3600

这个实验说明,在满足下面条件的场景下,fork 光是页表的内存开销就是巨大的:

  • 父进程地址空间是稀疏的;
  • 子进程 exec 前父进程发生了写时复制。

这些条件在大型服务器守护进程中很容易被满足(如 memcached、redis)。若内存吃紧时误用 fork,搞不好 fork 会失败,甚至触发内核 OOM。而 fork 出来的子进程往往只做非常简单的工作,这种页表开销完全没有必要。

若把 mmap 的 FIXED 去掉、不再指定 base,映射同样大小的内存,父进程地址空间便是稠密的,页表开销将非常小。同样的测试,结论如下:

[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:         2576 kB
[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:         2660 kB
[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables:         2644 kB

这种情况下没有人会 care 页表消耗。即便稀疏地址空间的页表消耗,也是转瞬即逝的——子进程一般马上 exec,给内核的影响就是”被针扎了一下”。不是不想发现问题,而是以往的工具捕获不到如此精度的事件。这个问题也可以用 vfork 解决,但同样对父进程不公道。

fork 带来的 vm_area_struct 开销

本节与写时复制无关。fork 调用在内核内部会把父进程的整个地址空间复制到子进程,地址空间在表象上以 vm_area_struct 表达。很容易想象这个复制会产生什么影响:

  • 父进程 vm_area_struct 对象非常多时,复制时间会非常长;
  • 子进程 vm_area_struct 副本的内存占用会很大。

和页表内存一样,vm_area_struct 对象内存也是内核空间常驻物理内存的、用一点少一点的资源,物理内存吃紧时 fork 可能直接创建子进程失败,根本原因就是 fork 的复制机制不合理。

来创建超级多的 vm_area_struct 对象——调用超级多次 mmap 即可。你可能会觉得像下面这样就行:

for (i = 0; i < 100000000; i++) {
    data = mmap(NULL, ps-1, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE, -1, 0);
    cnt++;
}

并不行!Linux 内核会把首尾连续的多个 mmap 区域(vm_area_struct 对象)合并成一个。为了阻止这种合并,保留 mmap 的 FIXED 参数:

#define CNT    1000000
for (i = 0; i < CNT; i++) {
    // FIX 映射 ps-2 的大小,每次跨越一个页面,阻止 vm 区域合并
    data = mmap(base, ps-2, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE|MAP_FIXED, -1, 0);
    base += ps*2;
    cnt++;
}

这次看 Slab 开销,采样四个点(测试前、fork 前、fork 后 exec 前、exec 后):

[root@10 ~]# cat /proc/meminfo | grep Slab
Slab:              29500 kB
[root@10 ~]# cat /proc/meminfo | grep Slab
[root@10 ~]# cat /proc/meminfo | grep Slab
Slab:             473864 kB  # 不必要的 vm 区域复制操作
[root@10 ~]# cat /proc/meminfo | grep Slab
Slab:             251620 kB

很多人会费解:并没有写内存、也没分配新内存,内存怎么就少了?答案就在这里。配合 watch -d -n 1 free -m 和 slabtop 观察会更有趣。有时只看 free -m 会发现 used 并不多,可用内存却少了——这时就要看内核管理数据结构的开销。

不要小看这个转瞬即逝的内存毛刺:如果恰好此时网络子系统要分配 skb,就可能因内存不足而失败。但由于只是内存毛刺,很难有工具能捕获到,问题也就极难排查——内存明明够用、也无碎片,为什么 skb 就分配失败了呢?

fork 带来的死锁问题

UNIX fork 出现时还没有线程概念,进程的一切就是一个独享的地址空间。但后来事情变了:

  • 线程出现了,多个线程共享同一个地址空间;
  • 地址空间不再是一切,还包括很多其它非内存的硬件状态上下文。

对 Linux 内核实现而言,线程和进程(单线程进程)都是 task_struct。fork 发生时,子进程复制的仅仅是调用线程的 task_struct。如果此时操作同一地址空间的其它 task_struct 获得了一把锁,那么虽然调用 fork 的 task_struct 并不知道这件事(它要 lock 一下才知道),这个事实还是会悄无声息地传给子进程——子进程如果此时去拿锁,就会死锁,它哪知道自己已经持有锁了啊!根源就是:多个 task_struct 操作同一个地址空间,而 fork 只参照其中一个(调用者)的状态复制地址空间。

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

pthread_mutex_t mutex;

void *mmap_unmap(void *arg)
{
    while (1) {
        pthread_mutex_lock(&mutex);
        sleep(4);
        pthread_mutex_unlock(&mutex);
    }
}

int main(int argc, char *argv[])
{
    pthread_t tid;
    pthread_mutex_init(&mutex);
    pthread_create(&tid, NULL, mmap_unmap, NULL);
    sleep(1);
    if (fork() == 0) {
        pthread_mutex_lock(&mutex);
        pthread_mutex_unlock(&mutex);
        printf("未死锁!\n\n");
    }
    sleep(1000);
    return 0;
}

由于 fork 自己的坑,pthread 引入了特殊 API 来填坑:

int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void))

fork 带来的 mm_struct 同步开销

fork 实现中无条件复制父进程的整个地址空间的所有 vm_area_struct,复制过程要拿锁,具体就是 dup_mmap 操作:

down_write_nested(&mm->mmap_sem, SINGLE_DEPTH_NESTING);

这个信号量在所有操作地址空间的调用中都要拿。多核多线程场景下,线程频繁操作地址空间,fork 必然与之竞争,徒增时间开销。

fork 与 spawn 的争论

事实上早在 1970 年代,人们对创建新进程的方案就分两派:

  1. 使用 fork+exec;
  2. 使用 spawn。
什么是 spawn?
Spawn in computing refers to a function that loads and executes a new child process. The current process may wait for the child to terminate or may continue to execute concurrent computing. Creating a new subprocess requires enough memory in which both the child process and the current program can execute. There is a family of spawn functions in DOS, inherited by Microsoft Windows. There is also a different family of spawn functions in an optional extension of the POSIX standards.

具体参见。其实 spawn 和 Windows API CreateProcess 差不多,它显式指定子进程属性,而不是让子进程完全继承父进程。

spawn 的支持者声称 spawn 是正规的、直接的;fork 派反驳说 spawn 在 load 新 image 之前没有任何机会调整该 image 的运行环境,因此 fork+exec 更灵活。例如用 fork,下面的逻辑成为可能:

if (fork() == 0) {
    stat_and_readinfo(image, ...)
    if (st....) {
        nice(...);
    } else if (...) {
        ...
    } ...
} else {
    ...
}

子进程的事情子进程自己干,比一切在父进程里做完职责更明确。争论的焦点在于:

  • 在子进程创建前设置好它的属性?
  • 在子进程 image 加载前设置好它的属性?

create 和 load 相分离的 fork+exec 方案很灵活、职责明确,父子进程无参数交互,子进程完全对自己负责。此外 fork 更简单也是有力理由——调用 spawn 或 CreateProcess 前要准备一堆参数,即便大多数能留空,留空就意味着还是继承父进程,那不就跟 fork 一致了嘛。不过接口简单是一回事,实现艰难是另一回事:分层模型中一层简单,就意味着另一层更复杂,层级间只是甩锅,总体复杂度并没有变。

对比 fork 和 CLONE_VM clone 的时间开销

如果只是想 exec 一个新程序,用 clone 的开销远小于 fork。原理在于:exec 系统调用本身会重新分配一个新的地址空间(容器是 mm_struct,元素是 vm_area_struct)。当用 CLONE_VM 作为 flag 调用 clone 时,创建了一个和当前进程共享地址空间的新进程——新子进程的 mm 指向调用进程,同时增加其引用计数。

之所以用 clone 创建共享地址空间的进程而非线程,是因为该子进程马上会调用 exec,exec 中会新建地址空间与父进程脱离。CLONE_VM 的子进程只是暂时借用父进程的地址空间,exec 后自立门户;exec 前不写共享地址空间就不会污染父进程。

CLONE_VM 创建的子进程与 CLONE_THREAD 创建的线程区别在于:

  • CLONE_THREAD 创建的线程在 exec 时会释放调用进程的地址空间;
  • CLONE_THREAD 创建的线程与调用进程共享信号处理。

我们可以这样封装创建新进程的函数:

int create_process(char *path, char *prog, char *argv, int nice);

#define STACK_SIZE    32768
void *stack;
struct info {
    char *path;
    char *prog;
    char *argv;
    int nice;
};

void *do_exec(void *argv)
{
    struct info *info = (struct info *)argv;
    nice(info->nice);
    if (execl(info->path, info->prog, info->argv, NULL) < 0) {
        perror("error on exec");
    }
}

int create_process(char *path, char *prog, char *argv, int nice)
{
    stack = malloc(STACK_SIZE);
    info = malloc(...);
    info->path = path;
    info->prog = prog;
    info->argv = argv;
    info->nice = nice;
    clone(&do_exec, (char *)stack + STACK_SIZE, CLONE_VM, &info);
}

CLONE_VM 的 clone 加随后的 exec 意味着节省地址空间复制的开销。为做对比,改两版代码。首先是使用 fork 的 ttest.c:

// ttest.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>
#include <time.h>
#include <sys/time.h>

#define CNT    389638055936
long long start, end;
struct timeval tv;
char *data;

int main(int argc, char *argv[])
{
    long i;
    unsigned long base = 0x7f510721e000;
    pid_t pid;
    int ps = sysconf(_SC_PAGE_SIZE);
    base = ((unsigned long)base & 0xfffffffffffff000);
    if (argc == 2) {
        int delta = atoi(argv[1]);
        for (i = 0; i < CNT; i += ps*ps/delta) {
            data = mmap((void *)base, ps-1, PROT_READ|PROT_WRITE, MAP_ANON|MAP_SHARED|MAP_FIXED, -1, 0);
            base += ps*ps/delta;
        }
    }
    gettimeofday(&tv, NULL);
    start = tv.tv_sec*1000*1000 + tv.tv_usec;
    pid = fork();
    if (pid == 0) {
        if (execl("/usr/bin/echo", "echo", "skinshoe", NULL) < 0) {
            perror("error on exec");
        }
    } else {
        gettimeofday(&tv, NULL);
        end = tv.tv_sec*1000*1000 + tv.tv_usec;
        printf("interval: %lld\n", end - start);
    }
    sleep(1);
    printf("parent\n");
    return 0;
}

预期是随着 argv[1] 参数的增加,fork 耗时线性增加,因为 vm_area_struct 对象数量在线性增加。下面是使用 CLONE_VM clone 的 vtest.c:

// vtest.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>
#include <time.h>
#include <sys/time.h>
#define _GNU_SOURCE
#include <sched.h>

#define CNT    389638055936
#define STACK_SIZE  16384
long long start, end;
struct timeval tv;
#define CLONE_VM 0x100
#define CLONE_VFORK    0x4000
char *data;
void *stack;

void *do_exec(void *arg)
{
    if (execl("/usr/bin/echo", "echo", "skinshoe", NULL) < 0) {
        perror("error on exec");
    }
}

int main(int argc, char *argv[])
{
    long i;
    unsigned long base = 0x7f510721e000;
    pid_t pid;
    int ps = sysconf(_SC_PAGE_SIZE);
    base = ((unsigned long)base & 0xfffffffffffff000);
    if (argc == 2) {
        int delta = atoi(argv[1]);
        for (i = 0; i < CNT; i += ps*ps/delta) {
            data = mmap((void *)base, ps-1, PROT_READ|PROT_WRITE, MAP_ANON|MAP_SHARED|MAP_FIXED, -1, 0);
            base += ps*ps/delta;
        }
    }
    stack = malloc(STACK_SIZE);
    gettimeofday(&tv, NULL);
    start = tv.tv_sec*1000*1000 + tv.tv_usec;
    clone(&do_exec, (char *)stack + STACK_SIZE, CLONE_VM, 0);
    gettimeofday(&tv, NULL);
    end = tv.tv_sec*1000*1000 + tv.tv_usec;
    printf("interval: %lld\n", end - start);
    sleep(1);
    printf("parent\n");
    return 0;
}

UNIX fork 神话

fork 太过完美——它没有任何参数,却承诺在底层把一切帮你拿捏得足够好。按照 UNIX 哲学,它是如此简单、让人感觉到美。在”唯产品论”的态度下,没人会去 patch fork,然而 patch fork 又是如此简单,它没有任何参数,美到让人无法修改。我依然是 UNIX/Linux 的粉丝,正因如此,fork 的问题才让我感到痛苦。


   转载规则


《fork 的开销与争议》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
循环引用:用 shared_ptr 还是 weak_ptr? 循环引用:用 shared_ptr 还是 weak_ptr?
一、回顾循环引用问题当两个对象通过 shared_ptr 相互引用时,会产生循环引用问题,导致内存泄漏。因为这两个对象的引用计数永远不会变为 0,即使它们在程序的其他部分已经不被使用了。 典型循环引用: #include <memor
2020-06-03
下一篇 
Linux clone 系统调用:fork 的变体 Linux clone 系统调用:fork 的变体
继传统 UNIX fork 之后,本文介绍 fork 在 Linux 内核中的变体——clone 系统调用的精妙之处。 理解 fork 的原始意义,还是要回到 Melvin Conway 提出 fork 思想的论文 A Multiproce
2020-05-16
  目录