fork 是一个拥有 50 年历史的陈年系统调用,它是一个传奇!时至今日,它依旧灿烂。一个程序员可以永远不用 read/write,也可以不懂 mmap,但必须懂 fork。这是一种格调!fork 没有参数,它是如此简单,是 UNIX 哲学的布道者或者说卫道者们的首选,它被写进了几乎每一本操作系统教科书里,成了创建新进程的绝佳范式 ,fork 站在原地,似乎在闭着眼睛蔑视 Windows 的 CreateProcess ,它的参数是如此之多,如此之复杂,在 UNIX 的世界,简单就是一切!然而 UNIX 却不是整个世界!似乎在对立的另一面,响荡着不同的声音,fork 看起来是如此诡异,颠覆了初学者的认知,并且,fork 开销巨大……如果你知道 fork 开销巨大,那为何不用 clone 呢?诚然,clone 并非标准,且参数多,复杂,麻烦,不美观,不雅致,看上去并不是很符合 UNIX 的价值观……本文就站在上述这个对立面的立场,为 fork 再泼一盆冷水。
fork 是诡异的
C 语言教科书没法安安静静地讲 fork,因为 fork 不符合 C 函数的调用规范。C 语言和操作系统原本就是两门正交的课程,你可以认为它们是无关的,C 函数可以在没有操作系统的单片机上被调用,但是 fork 似乎不行。若想理解 fork 的返回值,你就要先理解操作系统进程,换句话说,对 fork 的理解依赖操作系统,不然老师在 C 语言课程上讲 fork 时,一下子进掉进操作系统的窟窿里了,哦,或者说,C 语言的老师估计也不懂操作系统原理。不要觉得自己现在理解 fork 了就觉得它一开始就是这么简单,说到底还是被灌输的,回忆一下自己第一次接触 fork 时的场景,懵圈吗?是不是想了好久也没想明白为什么一个函数可以返回两次?按照对 C 函数的认知,创建进程的 API 明显应该是这样子的啊:// 创建一个进程,成功返回 0,否则返回-1,新进程从 start 开始运行
int create_process(void*(*start)(void *), void *arg, ...);
然后,告诉学生,你可以在 start 里面调用 exec 加载新的程序映像。和上述 create_process 比较,fork 简直就是一个丑陋的幽灵,不知道如此诡异的东西怎么在 50 年间被吹捧成了简单的典范,若不是 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.
但是你看看它的参数,跟 Windows API 的风格有一拼,这不是 UNIX 的风格,尤其是其历史远不如 fork 久远,故没有 fork 受待见。我的天,UNIX/Linux 在瞬息让你拥抱变化的互联网时代,其文化竟然跟经典白酒葡萄酒一样,越陈年越香。站在这帮 UNIX 卫道士们的立场上,你不懂 UNIX 进程创建原理那是你自己的问题,当你懂了,fork 那就是简单的。
fork 是懒惰导致 trick
你看,fork 没有一个参数,你没法在创建一个新进程之前去设置这个新进程的任何参数,比如优先级等,因为在 fork 调用前,什么都没有,连个新进程创建的计划都没有,然而一旦 fork 调用返回,就什么都有了,也就是说,新进程继承父进程的一切!记住,是一切都继承父进程,连代码也是。所以说,你要想设置新进程的优先级,你就必须显式的在新进程里手工进行:
if (fork() == 0) {
//设置优先级
nice(-3);
} else {
...
}
你没法像下面这样:// prio 为新进程的 nice 增量。
int prio = -3;
ret = create_process(new_process, &argv[0], prio, ...);
那么, 为什么一切都继承父进程的呢? 因为懒!这可是 UNIX 作者 Dennis Ritchie 自己说的。Genie 分时系统被认为是首先实现 fork 的系统,而不是 UNIX。Genie 的 fork 远比 UNIX 的 fork 灵活的多,后来 UNIX 上位,就鸠占鹊巢了。UNIX 的 fork 调用其实是对 Genie fork 的拙劣模仿,也就是想照抄 Genie 分时系统的 fork 的样子,然而抄了一半觉得太麻烦了,干脆就全部把父进程复制一遍拉倒。这是明显没有经过设计直接上线的典范,我们每个人在工作和生活中遇到事情几乎都会采用这种临时的投机取巧的方案来应对。换句话说,没有参数的 fork 调用就是 UNIX 的一种临时取巧的方案,这种方案最终会留下很多坑,令人惊讶的是,这些填坑的方案竟然也成了经典!这在互联网行业叫做 “快速迭代,小步快跑”。我一向的观点就是 互联网行业无精品。 根本原因就是快速迭代小步快跑,然而,搞不好这是正确的呢?精品观念也许会成为历史呢……比如,优衣库秒杀巴黎高级成衣,扎啤秒杀法国高档红酒……还真是,多少牛逼的专家都是解 bug 解出来的,他们是得多么感激当初写 bug 的人啊。UNIX fork 的取巧实现留下了坑,促使了后来的写时复制,即 COW(copy on write)来填坑,却还是没有填平。在 UNIX 刚刚出现的那几年,当时内存很小,一般的进程也都是很小的,所以 fork 中完全复制父进程没有问题,然而随着大进程的出现,内存开销开始越来越大,所以才采用了写时复制技术来缓解这种大的内存开销。即便是内存页面写时复制了,但是地址空间的数据结构的复制操作仍然少不了,这一点我在后面会用 demo 来证实。Linux 内核是一个类 UNIX 系统内核,而且代码唾手可得,懂它的人也不在少数,现如今只要提到 UNIX,Linux 均可作为替代,也就是说,AIX,Solaris,HP-HX 这种老牌经典 UNIX 太不容易得到了,而且也没有 x86 版本,所以一般都用 Linux 来替代。
fork 的开销
一提到这个话题,标准的答案似乎都是不要用进程,因为进程创建的开销太大了,尽量用线程。 ……如果你去参加面试,这么对答应该是妥妥的,再进一步,也许会扯上线程是共享内存的,而进程不是,然后再进一步,如果采用 fork 子进程的话,切换地址空间需要切换页表……切换页表就一定不好吗?嗯,不好,为什么呢?因为要刷 cache 呀……那要是用带有进程 pid 健值的 cache 呢?我没见过呀……反正只要说起这个话题,很多人都能想到 CR3 寄存器,一旦加载 CR3 寄存器,就意味着某种不好的事情会发生。也对也不对。本文尝试避开这个关于 cache 的角度,来一窥 fork 过程到底哪里开销大了,关注一些不为人知的秘密。
Linux 内核数据结构的开销
楼高越矮的电梯房得房率一般也越高,因为电梯少。如果楼高了,光是电梯就要很多部,留下的住宅空间比例自然也就低了。同样,在操作系统领域,也千万不要忽略内核数据结构的开销。本文讲的是 fork,所以跟 fork 开销有关的两类数据也就必须要提一下:
- 页目录和页表
- vm_area_struct 对象
先说页表开销。在进程地址空间比较稀疏的情况下,光是页表就会占据很大的内存空间,64 位系统这个问题会更加严重,具体可以参见我下面的文章:CPU 高速缓存与反置页表&调度的科普: https://blog.csdn.net/dog250/article/details/94955775操作系统页表&进程调度Tips:
https://blog.csdn.net/dog250/article/details/94734640
换句话说,多级页表只是为了解决稠密地址空间不必要的页表分配问题,它本身并不能节省内存,相反,在稀疏地址空间,它还要浪费内存。如下图,我们构建一个稀疏的地址空间的多级页表,如果是一级页表,只有叶子结点需要占据内存,多级页表的表,整棵树的全部节点都要占据内存:下面的例子中,我的 demo 程序就将构建一个稀疏的地址空间,以此放大 fork 调用的写时复制带来的页表开销。再看 vm_area_struct 对象。我们知道,我们在用户态进程里申请的每一块内存,在内核中均以 vm_area_struct 对象被维护,如果我调用了 10000 次 mmap,那就有 10000 个 vm_area_struct 对象被创建,在 fork 调用中,即便没有任何内存写操作,这 10000 个 vm_area_struct 结构图对象的复制是无条件的,所以在这个例子中,一次 fork 调用,内存消耗至少是:
往往这是没有必要的,因为子进程一般都会 exec,从而释放掉这些地址空间的内存以及其 vm_area_struct 对象。啊哈,之所以来这么一出,完全就是为了迎合那个没有参数的 fork!10000*sizeof(struct vm_area_struct);fork 写时复制带来的普通内存开销
父进程在 fork 之后,子进程调用 exec 之前,如果父进程写了页面,那么将会发生写时复制,这种写时复制大多是不必要的!在父进程中创建大量的常驻内存的页面,在 fork 之后子进程 exec 之前,父进程写这些页面,将会造成这些页面被复制,这是一种明显的不必要的开销。为了防止这种情况,vfork 可以阻塞父进程直到子进程调用 exec,但是这对父进程是不公道的!下面我们来看一种不同的内存开销,即稀疏地址空间的页表开销,这种开销相比单纯的数据页面而言,显得更加严重。fork 写时复制带来的页表内存开销
话先不多说,先给出代码:#include <unistd.h> #include <printf.h> #include <stdlib.h> #include <sys/mman.h>
// 注意这个magic数字的由来。这是我基于/proc/$pid/maps文件计算出来的:
// 计算方法:用stack头减去heap尾即可,然后根据OOM信息手动修改
#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之间的所有区域。
// 受限于系统内存有限,CNT的值可以修改,我就改小了,因为我的虚拟机太矬。
for (i = 0; i < CNT; i += psps/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 += psps/16;
cnt ++;
}
printf(“mmap:%p %lx cnt:%d\n”, data, base, cnt);
/slabtop; cat /proc/meminfo|grep Slab; cat /proc/meminfo|grep SUnreclaim; free -m 后回车/
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) {
/slabtop; cat /proc/meminfo|grep Slab; cat /proc/meminfo|grep SUnreclaim; free -m 后回车/
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”);
}
} else {
}
// 写稀疏内存!
// 如果这个发生在子进程exec之前,将会导致不必要的写时复制。
for (i = 0; i < CNT; i += psps/16) {
(char )st1 = 122;
st1 += psps/16;
}
sleep(1000);
return 0;
}
// 代码很简单,让我们执行前,先看看系统的页表内存开销:[root@10 ~]# cat /proc/meminfo | grep PageTables
PageTables: 2564 kB
至于 free 什么的,我就不贴了,这里只关注页表的开销。现在执行代码:[root@10 fork]# ./a.out
mmap:0x7f5309f1e000 7f530a01e000 cnt:8238
请观察 fork 之前的内存用量!请注意稀疏 mmap 对页表内存占用的影响!敲任意键执行 fork!此时的页表开销为:[root@10 ~]# cat /proc/meminfo |grep PageTables
PageTables: 19504 kB
我勒个去,稀疏地址空间果然的,19M 的内存!现在敲回车,执行 fork 后:
- 父进程写稀疏地址空间
- 子进程不执行 exec
请观察 fork 之后,exec 之前的内存用量!敲任意键执行 exec!再看下页表内存消耗:[root@10 ~]# cat /proc/meminfo |grep PageTables
PageTables: 36004 kB
果不其然,增加了一倍!父进程页表占据 18M 内存那是四级页表的错,应该用反置页表,但 fork 之后页表消耗内存加倍,成为 36M,那绝对是 fork 的锅!现在再敲入回车,让子进程执行 exec:现在请观察 exec 之后的内存用量!此时的页表占用为:[root@10 ~]# cat /proc/meminfo |grep PageTables
PageTables: 19064 kB
内存恢复!如果你想看父子进程自己的页表开销分别是多大,可以通过下面的方式习得:
```c
for pid in `ps -e|grep bash|awk '{print $1}'` ;do cat /proc/$pid/status|grep VmPTE; done可以看到在 exec 前,父子进程均分配同样的内存用于页表,然而子进程根本就不需要!只是为了打印个 skinshoe,而且不凑巧父进程发生了写时复制,就要白白消耗 19M 的内存,虽然这个消耗止于 exec 调用,但是却是不必要的。为了确认页表内存的释放确实是因为 exec 调用而不是子进程退出导致的,我们把 echo skinshoe 换成个不会退出的程序试试,比如换成 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
[root@10 ~]#这个实验说明在下面的条件被满足的场景下,fork 调用光是页表的内存开销就是巨大的:
- 父进程地址空间是稀疏的。
- 子进程 exec 前父进程发生了写时复制。
这些条件在大型服务器守护进程中非常容易被满足,比如 memcached,redis 这种,如果在内存正吃紧时误用了 fork,搞不好 fork 会失败,更严重的会触发内核的 OOM。往往 fork 出来的子进程只是进行一些非常简单的工作。这种页表的开销完全是没有必要的。现在让我们把 mmap 参数的 FIXED 去掉,并且也不再指定 base,映射同样大小的内存,这样父进程地址空间便是稠密地址空间了,页表开销将非常小,同样的测试,fork 前,fork 后 exec 前发生写时复制,exec 后的页表消耗结论如下:
这种情况下,没有人会 care 什么页表消耗。本节指出的问题之所以没有成为普遍的问题,确实有一部分原因是并非所有的父进程都是稀疏地址空间且恰好在子进程 exec 前发生了写时复制,但这并不能掩盖问题本身,通过构造,我这不是构造出一个场景了吗?此外,问题不被重视还有一个很重要的原因是,即便是稀疏地址空间的页表内存消耗,这个消耗也是转瞬即逝的。一般而言,子进程会马上调用 exec,给操作系统内核的影响就是被针扎了一下而已,如果如此精细度的内存用量的毛刺可以被捕获到,那这个问题肯定会被放到台面上讲。换句话说,不是不想发现问题,而是以往的工具捕获不到如此精度的事件,同样的事情也发生在 Linux 内核调度域负载均衡领域,详见:http://www.ece.ubc.ca/~sasha/papers/eurosys16-final29.pdf当然,这个问题解法也是可以用vfork解决,但是,同样,这对父进程显得不公道。[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 kBfork 带来的 vm_area_struct 开销
本节和写时复制无关了。fork 调用在内核内部,父进程的整个地址空间会被复制到子进程,这里的地址空间在表象上以 vm_area_struct 来表达。非常容易想象这个复制的过程和结果会产生什么样的影响: - 如果父进程 vm_area_struct 对象非常多,复制的时间会非常长。
- 如果父进程 vm_area_struct 对象非常多,子进程 vm_area_struct 对象副本内存占用就会很大。
和上一节的页表内存占用一样,vm_area_struct 对象内存也是内核空间常驻物理内存的,用一点就少一点的内存资源,所以说,物理内存的好紧的后果在 fork 中可能会发生,直接结论是创建子进程失败,而根本原因竟然是, fork 机制的复制是不合理的。来吧,上代码,让我们创建超级多的 vm_area_struct 对象,这很简单,调用超级多次 mmap 即可。也许,你把事情想简单了,你会觉得像下面这样是不是可以呢:
并不行!Linux 内核的优化是见缝插针型的,如果你按照上面的逻辑进行 mmap,内核十有八九会把超级多个 mmap 区域,也就是 vm_area_struct 对象合并成一个。可以进行这个合并操作的前提条件非常简单,只要两个 vm_area_struct 对象的首尾连续即可。为了不让内核进行这种合并,我们还是要保留 mmap 的 FIXED 参数,我就不贴整个源码了,只是把相对于上一节的改动贴出来:for (i = 0; i < 100000000; i ++) { data = mmap(NULL, ps-1, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE, -1, 0); cnt ++; }
// 删去写时复制的段落这次我们来看 Slab 的开销,采样四个点,即测试开始前,fork 前,fork 后 exec 前,exec 后,四个点的 Slab 用量分别为:#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 ++; }
很多人会费解,并没有写什么内存啊,也没有分配新内存,为啥内存就少了呢?这里给出了答案。同时[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
以及观察 slabtop 会更有趣。有时你如果只看 free -m 的话,你会发现 used 并不多啊,为什么可用内存少了呢?这个时候就要看看这种内核管理数据结构的开销了。和上一节讲页表的开销一样,这个 vm_area_struct 对象的开销也是转瞬即逝的,很难捕获到,无论如何这个开销是没有必要的,根因还是一样,fork 中的全面复制是没有必要的!不要小看这个转瞬即逝的内存凸起的毛刺,如果恰好在这个时候,网络子系统需要分配 skb,就可能会因为内存不足而失败,但是由于只是一个内存毛刺,又很难有工具能捕获到,问题也就非常难以排查了, 内存明明够用,也无碎片,为什么 skb 就分配失败了呢?watch -d -n 1 free -mfork 带来的死锁问题
这么说吧,UNIX fork 出现的时候,根本就没有线程的概念,那个时候,进程就是一切,而进程的一切就是一个 独享的地址空间, 但是到了后来,事情慢慢地起了变化: - 线程出现了,多个线程共享了同一个地址空间。
- 地址空间不再是一切了,还包括很多其它非内存的硬件状态上下文。
对于 Linux 内核的实现而言,不管是线程还是进程(只有一个线程的进程),一切都是 task struct,fork 发生的时候,子进程复制的仅仅是调用线程的 task struck,如果这个时候,操作同一个地址空间的其它 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 来填坑:
```c
1. int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void))有没有人以会用这些为荣的,其实给 fork 多加几个参数,这些钩子处理关 pthread 什么事啊!和这个风格相类似的还有类似 FD_CLOSEEXEC 这种,你说本来就是 fork 的事,fork 啥参数也没有,直接把锅甩给了 exec。
fork 带来的 mm_struct 的同步开销
fork 调用的实现中是无条件复制父进程的整个地址空间的所有 vm_area_struct 对象的,复制的过程是要拿锁的,具体来讲就是 dup_mmap 的操作:
down_write_nested(&mm->mmap_sem, SINGLE_DEPTH_NESTING);
而这个信号量在所有操作地址空间的调用中都要拿。在多核多线程场景下,如果线程频繁操作地址空间,fork 调用则必然会与之产生竞争,徒增时间开销。还是那句话,折腾。你 fork 复制这些 vm_area_struct 对象你自己又不用,只是为了 fork 实现的方便呗,折腾这么大的场面。
fork 其它开销
前面说了页表,vm_area_struct 对象,锁等带来的空间或者时间开销,以及棘手的死锁问题,那么还有别的吗?实在太多啦。你看看 copy_process 这个函数,看看 fork 都需要拷贝什么就知道了。文件,信号……一个个试试吧。
fork…
嗯,fork 过时了!在多线程,多核 SMP,分布式时代,fork 不合时宜了。讽刺的是,内存很小的年代,fork 尚能被接受,如今内存如此廉价,fork 咋就不合时宜了呢?可见,空间开销只是事情的一面,时间开销,嗯,和空间开销一起,让 fork 不可救药。写时复制拯救 UNIX 30 年。谈谈写时复制。这并不是 UNIX 领域独创的,UNIX 只是利用了它而已。想象一下大型商场的专柜,样品摆在那里供人们看,感受,但如果你要买走一件,柜员会从仓库里帮你拿一件新的,样品还是那个样品。如果你非要买走那个样品,柜员就拿一件新的做新的样品。这就是写时复制。那么写实复制的反例呢?那就是菜市场或者超市。所有东西都摆在那里,你买走的就是摆在那里的货品的其中一件。比较一下,专柜和菜市场或者超市,哪个更占地方?然后理解写时复制的本质了吗?写时复制拯救了 UNIX 文化 30 年!如果没有采用写时复制,UNIX 在 1990 年代就扛不住了。自 1990 年代起,《莱昂氏 UNIX 源码分析》中 UNIX V6 的代码就变成玩具了,其中很大的因素就是 UNIX V6 代码在 1990 年代成了一个 能跑但不能用的代码。 不能用的原因就在于当时的程序都已经很大了,很多古老的东西没有与时俱进,变得不再适用。fork 保留下来是个奇迹,其中多亏了写时复制的功劳。写时复制无法继续拯救 UNIX/Linux fork 了。但写时复制本身却真的是伟大的。写时复制除了节省空间开销之外,还有一个功用,那就是保证时刻拥有一个稳定的版本。这一点文件系统用的比较多,比如当写一个文件时,复制一份去写,然后再将拷贝生效,如果写的过程或者拷贝生效的过程发生意外,至少还有一个原始的稳定版本。无论是节省空间,还是为了事务一致性而浪费空间,写时拷贝的本质都可以理解为 “保留所有版本的差异” 。这是一个伟大的发明。在 fork 的性能问题上,子进程如果确定会 exec,那么写时复制就是不必要的,之所以写时复制,完全是因为 fork 不理解业务 导致的,虽然按照常规理解,底层机制就要和业务策略分离,但是更时髦的说法是,脱离业务场景谈优化都是扯淡。我们来看看为什么 exec 场景下的写时复制是不必要的。
void *parent_need, *child_need;
...
pid = fork();
if (pid == 0) {
// 子进程很明显知道自己要干什么,代码保证不会操作parent_need就好了
process_pre_exec(child_need);
exec(newimage, ...);
} else {
// 这里父进程,谁会故意写子进程的内存,父进程写的内存子进程也不需要。不要去写child_need就好了
}
看到了吗?代码只要能保证内存隔离即可。我想说的是,fork 没有必要用看似优雅的写时复制技术去保证所谓的地址空间的绝对安全隔离。事实上,Linux 内核提供了父子进程共享内存的 SHARED mmap,很明显,当程序员在写代码时,他自己知道自己要干什么,如此性能损耗巨大的写时复制技术去保证操作系统概念上的进程的地址空间隔离的语义,我觉得没有必要。写时复制保证了进程就是进程,地址空间时隔离的。但是你也可以这么想,未加载程序 image 映像的进程只是一个半进程。
fork 与另一种方式-spawn 的争论
事实上,早在 1970 年代,fork 还未成为神话的年代,人们对创建新进程的方案持有大致两类观点,它们争议不断:
- 使用 fork+exec。
- 使用 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 .[1]
具体参见:https://en.wikipedia.org/wiki/Spawn_(computing)其实 spawn 和 Windows API CreateProcess 差不多,它显示指定了子进程的属性而不是让子进程完全继承父进程。当时 spawn 的支持者们声称 spawn 是 正规的,直接的 ,显然,UNIX fork 派对此并不买账,fork 派反驳说, spawn 在 load 新的 image 之前没有任何机会调整该 image 的运行进程环境。因此,fork+exec 比 spawn 更加灵活! 比如说,使用 fork,下面的逻辑成为可能:
显然,子进程的事情子进程自己干,这显然要比一切在父进程里做完要职责明确的多,再者说了, spawn 有赖于进程属性参数化 ,它的前提是这些属性可以被参数化。无论如何,争论的焦点在于:if (fork() == 0) { stat_and_readinfo(image, ...) if (st....) { nice(...); } else if (...) { ... } ... } else {
- 在子进程创建前设置好它的属性?
- 在子进程 image 加载前设置好它的属性?
显然,create 和 load 相分离的 fork+exec 方案看起来真的是很灵活,并且 职责明确,父子进程没有任何参数交互,子进程完全对自己负责– 加载 image 是子进程自己的事!此外,fork 更加简单也是一个非常有力的让人支持它的理由。想想看,调用 spawn 或者 CreateProcess 前需要准备那么多的一堆参数,看着都烦,即便它们大多数都能留空……留空意味着还是继承父进程的,这不跟 fork 一致了嘛,与其这么麻烦,还不如 fork 来的简单方便呢……spawn 派貌似自己打了自己的脸……然而接口简单是一回事,实现艰难是另一回事,在计算机领域,分层模型太多太多了,一层简单就意味着肯定另一层会更复杂,层级间甩锅了而已,总体复杂度并没有什么卵变化。多年以后,重新审视 fork 的实现,确实,在执行效率上,出现了很多问题。本文描述的可能只是冰山一角, “创建进程开销大” 这句话让进程背锅背了好多年,确实锅是进程的,但是重新审视 fork,至少能让锅被卸下几口吧……对比 fork 和 CLONE_VM clone 的时间开销
文章开头我提到, 如果你只是想 exec 一个新的程序,干嘛不试试 clone?确实,clone 如果用对了,用它来 exec 一个进程(注意,就是进程,而不是线程)的开销远小于 fork!怎么说呢?其实,只要知道 exec 的原理就可以了。exec 系统调用本身会重新分配一个新的地址空间,用以载入可执行文件的映像。该新的地址空间会替换掉当前进程的原有地址空间。对于 Linux 而言,地址空间的容器就是 mm_struct 对象,而其中的元素就是 vm_area_struct 对象!当我们用 CLONE_VM 作为 flag 参数调用 clone 系统调用时,其结果就是创建了一个和当前进程共享地址空间的新的进程。这听起来有点别扭,进程怎么还能共享地址空间呢?但确实如此。所谓的共享地址空间的进程,指的是新的子进程的 mm 指向调用进程,同时增加调用进程 mm 对象的引用计数,仅此而已!之所以利用 clone 来创建一个共享地址空间的进程而不是线程,背后的逻辑是该子进程马上就会调用 exec,而 exec 中,子进程将新建地址空间,从而与父进程的地址空间脱离。CLONE_VM 的 clone 子进程只是暂时 借用并附着 父进程的地址空间,exec 之后,便可以自立门户,exec 前不写共享的地址空间,便不会污染父进程,即便是写了,也不会写时复制。这个 CLONEVM 创建的子进程和 CLONETHREAD 创建的线程有什么区别呢?这里不想赘述 POSIX 线程的定义,只提几点: - CLONE_THREAD 创建的线程在 exec 时会释放调用进程的地址空间。
- CLONE_THREAD 创建的线程和调用进程共享信号处理。
- …
这些都是 POSIX 线程的语义定义的,但是 POSIX 没有规定只有 CLONE_VM 会怎样,所以,我们只要保证在 clone 的回调函数中仅仅 exec,而不去 touch 如何函数外的内存,就能保证调用进程的地址空间是干净的。所以,我们可以这样封装创建新进程的函数: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);
}
CLONEVM 的 clone 以及随后的 exec 意味着使用这个序列会节省地址空间复制的开销,也就是说,前文所示的 vm_area_struct 对象的复制操作没有必要了。为了做一个对比,我改了两版代码,首先是使用 fork 的代码 ttest.c:
```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 的代码 vest.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;
}
预期是 argv[1]的值不再影响耗时,因为不用复制任何 vm_area_struct 对象了!下面是一个简单的对比,打印出的 interval 就是对应的 fork/clone 耗时:孰好孰坏,一目了然。
我和 UNIX fork 神话
fork 太过完美,它没有任何参数,却承诺在底层把一切帮你拿捏的足够好,果真如此吗?我第一次接触 fork 是在 2006 年中软吉大工作试用期期间,我就是一个写 Java 的。第一次接触 fork 时觉得它好神奇,我也感受到了折腾底层的快感,从此便一发不可收拾,但直到今天我才有勇气写一篇关于 fork 的文章,令人我自己诧异的是,这篇文章还是喷 fork 的……我承认自己当时没有觉得 fork 诡异并且很喜欢,我是以奇技淫巧接受它的,我个人本身就喜欢奇技淫巧,这个我承认。但这貌似隐约间意味着,在我心里,可能从一开始 fork 就不是一个解决问题的正规方法,它一直只是一个奇技淫巧。然而,fork 在很多程序员心里成了一个神话。按照 UNIX 哲学,它是如此的简单,让人感觉到美!昨天下班路上,和朋友聊天,提到了唯产品论 ,在这种态度下,没人会去 patch fork,然而,如何去 patch,fork 是如此的简单,它没有任何参数,它竟然美到让人无法修改……我依然是 UNIX/Linux 的粉丝,正因为如此,我才觉得 fork 的问题让我自己如此痛苦。