深入分析fork、vfork与clone

fork

系统调用fork允许一进程(父进程)创建一个新进程(子进程)。新的子进程几近于对父进程的翻版:子进程获得父进程的栈、数据段、堆和执行文本段的拷贝。

1
2
#include<unistd.h>
pid_t fork(void); //成功子进程中返回0,父进程返回子进程id,出错返回-1

完成对其调用后将存在两个进程,且每个进程都会从fork的返回处继续执行。这两个进程将执行相同的程序文本段,但却各自拥有不同的栈段、数据段以及堆段拷贝。子进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行fork之后,每个进程均可修改各自的栈数据以及堆中的变量而不影响另一进程。
调用fork时一般有以下常用模板:

1
2
3
4
5
6
7
8
9
pid_t childid;
switch(childid = fork()){
case -1:
/*error*/
case 0:
/*child*/
default:
/*parent*/
}

调用fork之后,系统率先执行那个进程是无法确定的
执行fork时,子进程会获得父进程所有文件描述符的副本。这些副本的创建方式类似于dup,这也意味着父子进程中对应的描述符均指向相同的打开文件句柄。又打开文件句柄包含有当前文件偏移量以及文件状态标志。故一个打开文件的这些属性也在父子进程间实现了共享。也就是说:如果子进程更新了文件偏移量,这种改变也会影响到父进程中的相应的描述符。如果不需要这种共享,可以1)令父子进程使用不同的文件描述符。2)各自立即关闭不再使用的描述符。

由于fork之后一般会紧接着执行exec,所以完全的进行复制是一种极大的浪费,现在的系统大多使用两种技术来避免这种浪费:

  • 内核将每一进程的代码段标记为只读,使得进程无法修改代码。这样父子进程可以共享同一代码段。系统调用fork在为子进程创建代码段时,其所构建的一系列进程级页表项均指向与父进程相同的物理内存页帧。
  • 对于父进程的数据段、堆段和栈段中的各页,内核采用写时复制技术。最初,内核做了一些设置,令这些段的页表项指向与父进程相同的物理内存页,并将这些页面自身标记为只读。调用fork之后,内核会捕获所有父进程或子进程针对这些页面的修改企图,并为将要修改的页面创建拷贝。系统将新的页面拷贝分配给遭内核捕获的进程,还会对子进程的相应页表项做适当调整。

vfork

类似于fork,vfork可以为调用进程创建一个新的子进程。然而,vfork是为子进程立即执行exec的程序而专门设计的。

1
2
#include<unistd.h>
pid_t vfork(void); //成功子进程中返回0,父进程返回子进程id,出错返回-1

vfork因为如下两个特性而更具效率,也是区别与fork所在:

  • 无需为子进程复制虚拟内存页或页表。相反,子进程共享父进程的内存,直至其成功执行了exec或是调用_exit退出。
  • 在子进程调用exec或_exit之前,将暂停执行父进程
    由于子进程使用父进程的内存,因此子进程对数据段、堆或栈的任何改变都将在父进程恢复时为其所见。此外,如果子进程在vfork与后续的exec或_exit之间返回,也会对父进程产生影响。
    在使用vfork时,一般应立即在vfork之后调用exec,如果exec调用失败,子进程应调用_exit退出(vfork产生的子进程不应该调用exit退出,因为这会导致父进程stdio缓冲区的刷新和关闭)。

clone

类似于fork与vfork,linux特有的系统调用clone也能创建一个新进程。与前两者不同的是,后者在进程创建期间对步骤的控制更为精准。clone主要用于线程库的实现。

1
2
3
4
5
#include<sched.h>
#define _GNU_SOURCE
int clone(int (*func) (void),void *child_stack,int flags,void *func_arg,
.../*pid_t *ptid,struct user_desc *tls,pid_t *ctid*/);
//成功返回子进程id,失败返回-1

如同fork,由clone创建的新进程几近于对父进程的翻版。但与fork不同的是,克隆生成的子进程继续运行时不以调用处为起点,转而去调用以参数func所指的的函数,func又称为子函数,调用子函数时的参数由func_arg指定。当函数func返回(此时返回值即为进程的退出状态)或是调用exit或_exit之后,克隆产生的进程就会终止。父进程可以调用wait类的函数获取终止状态。

对于内核而言,fork,vfork,clone最终均由同一函数实现(do_fork)。在这一层上,clone与fork更为接近:sys_clone并没有func和func_arg参数,且调用后sys_clone在子进程中返回方式也与fork相同。sys_clone在子进程中返回之后,由clone发起对func函数的调用。

因为克隆产生的子进程可能(类似vfork)共享父进程的内存,所以它不能使用父进程的栈,调用者必须分配一块大小适中的内存空间供子进程的栈使用,同时将这块内存的指针位于child_stack参数中。

其中的flags参数可以使得父子进程产生不同的共享效果。详情可以查阅手册

进程的创建速度

从fork行可以看出,进程所占内存越大,fork所需时间也就越长。额外时间花在了为子进程复制那些逐渐变大的页表,以及将数据段,堆段以及栈段的页记录标记为只读的工作上。因为子进程并未修改数据段或栈段,所以也没有对页复制。

从vfork行可以看出,尽管进程大小在增加,但所用时间保持不变,因为调用vfork时并未复制页表或页,调用进程的虚拟内存大小并未造成影响。fork和vfork在时间统计上的差值就是复制进程页表所需的时间总量。

从clone行可以看出(clone所用的标志为 CLONE_VM | CLONE_VFORK | CLONE_FS | CLONE_SIGHAND | CLONE_FILES),前两个标志模拟vfork,剩余的标志则要求父子进程应当共享文件系统属性文件权限掩码、根目录和当前工作目录,信号处置表以及打开文件描述符。clone和vfork之间的数据差值则代表了vfork将这些信息拷贝到子进程的少量额外工作。拷贝文件系统属性和信号处置表的成本是固定的。不过拷贝打开文件描述符表的开销则取决于描述符数量。


参考资料:
《linux/UNIX系统编程手册》(tlpi)