Linux fork隐藏的开销-过时的fork(正传)

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/dog250/article/details/100168430

本文来自《Linux fork那些隐藏的开销》

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来替代。

我下面的demo也将全部基于Linux。

fork的开销

一提到这个话题,标准的答案似乎都是 不要用进程,因为进程创建的开销太大了,尽量用线程。

如果你去参加面试,这么对答应该是妥妥的,再进一步,也许会扯上线程是共享内存的,而进程不是,然后再进一步,如果采用fork子进程的话,切换地址空间需要切换页表…

切换页表就一定不好吗?嗯,不好,为什么呢?因为要刷cache呀…那要是用带有进程pid健值的cache呢?

我没见过呀…反正只要说起这个话题,很多人都能想到CR3寄存器,一旦加载CR3寄存器,就意味着某种不好的事情会发生。

fork还有一个明确的开销是关于写时拷贝的。写时拷贝会带来可观的缺页中断,而处理缺页中断需要付出时间。这也是众所周知的。

本文尝试避开cache和缺页中断处理,来一窥fork过程那些除此之外的隐藏着的开销,关注一些不为人知的秘密。

Linux内核数据结构的开销

楼高越矮的电梯房得房率一般也越高,因为电梯少。如果楼高了,光是电梯就要很多部,留下的住宅空间比例自然也就低了。

同样,在操作系统领域,也千万不要忽略内核数据结构的开销。本文讲的是fork,所以跟fork开销有关的两类数据也就必须要提一下:

  1. 页目录和页表
  2. 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调用,内存消耗至少是:

10000*sizeof(struct vm_area_struct);

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

啊哈,之所以来这么一出,完全就是为了迎合那个没有参数的fork!

好了,现在开始,让代码说话。

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 += 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);

	/*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 += ps*ps/16) {
		*(char *)st1 = 122;
		st1 += ps*ps/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之后的内存用量!
skinshoe

此时的页表占用为:

[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调用,但是却是不必要的。

为了确认页表内存的释放确实是因为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后的页表消耗结论如下:

[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前发生了写时复制,但这并不能掩盖问题本身,通过构造,我这不是构造出一个场景了吗?

此外,问题不被重视还有一个很重要的原因是,即便是稀疏地址空间的页表内存消耗,这个消耗也是转瞬即逝的。一般而言,子进程会马上调用exec,给操作系统内核的影响就是被针扎了一下而已,如果如此精细度的内存用量的毛刺可以被捕获到,那这个问题肯定会被放到台面上讲。

换句话说,不是不想发现问题,而是以往的工具捕获不到如此精度的事件,同样的事情也发生在Linux内核调度域负载均衡领域,详见:
http://www.ece.ubc.ca/~sasha/papers/eurosys16-final29.pdf

当然,这个问题解法也是可以用vfork解决,但是,同样,这对父进程显得不公道。

fork带来的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即可。

也许,你把事情想简单了,你会觉得像下面这样是不是可以呢:

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

并不行!

Linux内核的优化是见缝插针型的,如果你按照上面的逻辑进行mmap,内核十有八九会把超级多个mmap区域,也就是vm_area_struct对象合并成一个。可以进行这个合并操作的前提条件非常简单,只要两个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后,四个点的Slab用量分别为:

[root@10 ~]# cat /proc/meminfo |grep Slab
Slab:              29500 kB
[root@10 ~]# cat /proc/meminfo |grep Slab
Slab:             251624 kB
[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并不多啊,为什么可用内存少了呢?这个时候就要看看这种内核管理数据结构的开销了。

和上一节讲页表的开销一样,这个vm_area_struct对象的开销也是转瞬即逝的,很难捕获到,无论如何这个开销是没有必要的,根因还是一样,fork中的全面复制是没有必要的!

不要小看这个转瞬即逝的内存凸起的毛刺,如果恰好在这个时候,网络子系统需要分配skb,就可能会因为内存不足而失败,但是由于只是一个内存毛刺,又很难有工具能捕获到,问题也就非常难以排查了, 内存明明够用,也无碎片,为什么skb就分配失败了呢?

fork带来的死锁问题

这么说吧,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来填坑:

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的争论

先看Windows创建新进程的方法:

BOOL CreateProcess(
	LPCTSTR lpApplicationName,
	LPTSTR lpCommandLine,
	LPSECURITY_ATTRIBUTES lpProcessAttributes,
	LPSECURITY_ATTRIBUTES lpThreadAttributes,
	BOOL bInheritHandles,
	DWORD dwCreationFlags,
	LPVOID lpEnvironment,
	LPCTSTR lpCurrentDirectory,
	LPSTARTUPINFO lpStartupInfo,
	LPPROCESS_INFORMATIONlpProcessInformation
);

不多说,自己看MSDN。这才是正确的姿势。至少要用clone把fork+exec包装一下啊。

事实上,早在1970年代,fork还未成为神话的年代,人们对创建新进程的方案持有大致两类观点,它们争议不断:

  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 .[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,下面的逻辑成为可能:

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

显然,子进程的事情子进程自己干,这显然要比一切在父进程里做完要职责明确的多,再者说了, spawn有赖于进程属性参数化 ,它的前提是这些属性可以被参数化。

无论如何,争论的焦点在于:

  • 在子进程创建前设置好它的属性?
  • 在子进程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前不写共享的地址空间,便不会污染父进程,即便是写了,也不会写时复制。

这个CLONE_VM创建的子进程和CLONE_THREAD创建的线程有什么区别呢?这里不想赘述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);
}

CLONE_VM的clone以及随后的exec意味着使用这个序列会节省地址空间复制的开销,也就是说,前文所示的vm_area_struct对象的复制操作没有必要了。

为了做一个对比,我改了两版代码,首先是使用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的代码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耗时:

[root@10 PK]# gcc ttest.c -o tt
[root@10 PK]# gcc vtest.c -o vt
[root@10 PK]# ./tt
interval: 41
skinshoe
parent
[root@10 PK]# ./tt 1
interval: 4246
skinshoe
parent
[root@10 PK]# ./tt 2
interval: 8233
skinshoe
parent
[root@10 PK]#
[root@10 PK]# ./tt 4
interval: 20958
skinshoe
parent
[root@10 PK]# ./tt 8
interval: 31340
skinshoe
parent
[root@10 PK]# ./tt 16
interval: 84829
skinshoe
parent
[root@10 PK]# ./vt
interval: 18
skinshoe
parent
[root@10 PK]# ./vt 1
interval: 25
skinshoe
parent
[root@10 PK]# ./vt 2
interval: 35
skinshoe
parent
[root@10 PK]# ./vt 4
interval: 20
skinshoe
parent
[root@10 PK]# ./vt 8
interval: 22
skinshoe
parent
[root@10 PK]# ./vt 16
interval: 24
skinshoe
parent

孰好孰坏,一目了然。

我和UNIX fork神话

fork太过完美,它没有任何参数,却承诺在底层把一切帮你拿捏的足够好,果真如此吗?

我第一次接触fork是在2006年中软吉大工作试用期期间,我就是一个写Java的。第一次接触fork时觉得它好神奇,我也感受到了折腾底层的快感,从此便一发不可收拾,但直到今天我才有勇气写一篇关于fork的文章,令人我自己诧异的是,这篇文章还是喷fork的…

我承认自己当时没有觉得fork诡异并且很喜欢,我是以奇技淫巧接受它的,我个人本身就喜欢奇技淫巧,这个我承认。但这貌似隐约间意味着,在我心里,可能从一开始fork就不是一个解决问题的正规方法,它一直只是一个奇技淫巧。

然而,fork在很多程序员心里成了一个神话。按照UNIX哲学,它是如此的简单,让人感觉到美!

昨天下班路上,和朋友聊天,提到了 唯产品论 ,在这种态度下,没人会去patch fork,然而,如何去patch,fork是如此的简单,它没有任何参数,它竟然美到让人无法修改…

我依然是UNIX/Linux的粉丝,正因为如此,我才觉得fork的问题让我自己如此痛苦。

不管怎样,还是那句话结束,然后去思考…


浙江温州皮鞋湿,下雨进水不会胖。

文章创建于: 2019-09-03 23:20:56
展开阅读全文

没有更多推荐了,返回首页