1. 基础知识

fork() 能干什么?

系统调用 fork() 用于创建一子进程(child),该子进程近乎于父进程(parent)的翻版,但要注意这是一个新的进程,拥有与父进程不同的PID。

#include <unistd.h>
pid_t fork(void);

想要理解 fork() 必须意识到,完成调用后将存在两个进程,且每个进程都会从 fork() 的返回处继续执行,即父子进程有条件也确实从 fork() 调用获得了不同的返回值。且调用 fork() 之后CPU率先“垂青”于哪个进程是无法确定的。

从父进程继承了什么?

fork() 时子进程的PCB中很多条目都是从父进程的PCB中继承而来的。值得关注的有下面这些内容。

虚拟地址空间、程序计数器:子进程的虚拟地址空间中的内容完全拷贝自父进程(暂且不考虑写时拷贝技术),也是说父子进程拥有完全相同的代码段。而子进程的程序计数器也继承自父进程。综合这两条,可以知道子进程创建完毕后父子进程运行到了同一条机器指令,而这发生在 fork() 返回之前。所以,父子进程有条件也确实从 fork() 获得了不同的返回值,父进程的 fork() 的返回值是子进程的PID,而子进程的 fork() 的返回值是0。搞清楚这一点十分重要,因为通常在 fork() 之后通过判断返回值来分流父子进程使之执行不同的任务。

文件描述符:执行 fork() 后,子进程获得了父进程的所有文件描述符的副本,这种拷贝类似于 dup(),即父子进程间相对应的文件描述符指向了相同的系统级打开文件表条目。这意味这两个文件描述符共享相同的文件读写偏移量、文件访问模式(只读、只写等)等。

代码共享、数据独有

由于父子进程运行的是相同的代码且代码段是只读的,所以完全可以在创建子进程的代码段时,将代码段对应的页表项映射到与父进程相同的物理内存页帧上,这样就避免了复制一些永远不可能被修改的数据,减少创建子进程的时间消耗。而父子进程的其他数据段当然是各自独有的,这样才能做不同的工作。

写时复制

对于父进程数据段、堆段和栈段中的各页,内核则采用了写时复制(copy-on-write)技术。其实一开始 fork() 也是直接把父进程的这些段直接给子进程复制了一份,一了百了。但人们很快发现,通常创建一个子进程的目的是执行另一个程序(即程序替换 exec()),而不是简单的在一个程序中分流。如果刚刚把父进程的虚拟地址空间复制了一份给子进程,而子进程立马又用 exec() 把虚拟地址空间给替换掉了,确实有些浪费。于是写时复制技术应运而生,fork() 的时候,内核令这些段的页表项指向于父进程相同的物理内存页帧,并将这些页面标记为只读。调用 fork() 之后,内核会捕捉所有父进程或者子进程针对这些页面的修改企图,并为将要修改的页面创建拷贝,然后对子进程相应页表项作出适当调整。从这一刻起,父子进程可以修改各自的页拷贝,不再互相影响。下图展现了写时拷贝技术。

2. vfork()

上面有提到,早期的 fork() 会直接把父进程的虚拟内存一股脑全部拷贝给子进程,而这在 fork() 之后立即执行 exec() 时是一种极大的浪费。所以在之后出现了 vfork() 系统调用,它的效率远高于早期的 fork() 。现代UNIX采用了写时复制技术,进而完全剔除了 vfork() 的需求。

类似于 fork() ,vfork() 为调用进程创建一个子进程。但vfork() 是为子进程立即执行 exec() 而设计的。

#include <unistd.h>
pid_t vfork();

vfork() 比早期的 fork() 具备更高的效率是因为下面两个因素:

  • 无需为子进程复制虚拟内存页和页表,相反,子进程共享父进程的内存,直到成功执行了 exec() 或者 _exit()。
  • 在子进程执行 exec() 或 _exit() 之前,父进程的运行将被阻塞。

所以,现代 fork() 能完全替代 vfork() 的原因也是显而易见的。而且,由于父子进程共享内存,所以子进程对数据段、堆栈做的任何修改在父进程恢复后都是可见的,而且子进程在 vfork() 与后续的 exec() 和 _exit() 之间执行函数返回同样会影响到父进程。事实上,在不影响父进程的前提下,子进程能在 vfork() 和 exec() 之间做的操作屈指可数。

总而言之,除非速度绝对重要的场合,新程序应当舍弃 vfork() 而使用 fork()。

3. fork() 之后的竞争条件

调用 fork() 之后,无法确定父、子进程谁先率先访问CPU。(在多处理器系统中,他们可能会同时各自访问一个CPU)。对于应用程序来说,如果为了产生正确的结果而或明或暗的依赖于特定的执行顺序,那么将可能产生竞争条件(Race Condition)。

竞争条件是这样一种场景,操作系统上共享资源的两个进程(或线程),其运行结果取决于一个无法预期的执行顺序,即这些进程获得CPU使用权的先后顺序。

事实上,Linux内核的发展史中针对父子进程谁默认先获得CPU调度权的争论持续了很久。支持两方的人都是公说公有理、婆说婆有理。而这种差异在不同的UNIX实现上,甚至是不同的Linux内核版本上都是很大的。

所以总而言之,不应对 fork() 之后父子进程谁先执行做出任何假设。如果确实需要保证某一特定的执行顺序,需要用到多种同步技术。

Last modification:November 10th, 2019 at 08:12 am