在本文中,传统 UNIX fork 之后,我给出传统的 UNIX fork 在 Linux 内核中的变体 clone 系统调用的精彩。若要理解 fork 的原始意义,还是要看 Melvin Conway 提出 fork 思想的原始论文 A Multiprocessor System Design:
https://archive.org/details/AMultiprocessorSystemDesignConway1963/page/n7
该论文的核心在于 Conway 分离了 “进程(process)” 和 “处理器(processor)” 的概念:
- 一个进程不必特定于一个处理器上被处理。
- 一个处理器未必处理特定的进程。
- 系统中进程数量和处理器数量不需要相等。
fork 为上述的核心思想提供了实现的手段。后来 fork 被引入到 UNIX 系统,成了创建新进程几十年不变的通用操作。比较有意思的是,UNIX fork 是通过著名的 fork-exec 序列而闻名于世的,而不是因为其提供的并行多处理手段而闻名于世,这可能是因为在线程概念出现以后,并行处理均由线程担当,也再没有人记起 fork 了吧。如果说一系列进程是完全可并行的,那么它们便没有资源是相互依赖的,这便是现代操作系统进程(即 process)抽象的基础。可见,基于进程抽象的现代操作系统本身就是一个可并行系统。在一个可并行的系统中,进程之间本就是资源隔离的,如果需要 join 操作,引入 IPC 机制便是。
线程概念的出现,就是对 UNIX 进程抽象的资源如何共享重新解构再重构。我们看看在线程出现之前,fork 提供的并行多处理是多么高效。最典型的例子就是 TCP 服务编程模型了:
void handle_request(int csd)
{
...
// 读取请求
ret = recv(csd, buf_req, len);
ret = read(fd, buf_tosend, ret);
ret = send(csd, buf_tosend, ret);
close(csd);
}
void server(int sd)
{
...
while (1) {
csd = accept(sd, 10);
if (fork() == 0) {
close(sd);
handle_request(csd); // 可并行处理
}
}
}
这几乎成了服务器编程范式,是理解和设计select/poll/epoll程序的前提,也是理解后来Apache Web Server以及Nginx的基础。
以上这段简单代码,请问,用 Windows 的 CreateProcess API 如何实现?不使用线程 API,只用进程 API,若要并行处理多个请求,CreateProcess 需要载入一个磁盘程序映像来执行 handle_request,该映像程序写出来可能是下面的样子(这不是最高效的写法,这只是一种直接的写法):
void handle_request(int csd)
{
...
// 读取请求
ret = recv(csd, buf_req, len);
ret = read(fd, buf_tosend, ret);
ret = send(csd, buf_tosend, ret);
close(csd);
}
int main(int argc, char **argv)
{
char *client_info = argv[1];
int sd;
sd = GetOrCreateSocket(client_info);
handle_request(sd);
}
我们知道载入一个程序的映像开销非常大,但为了并行处理不得不如此,否则 Windows 就必须串行处理 handle_reques 和接下来的 accept。Windows 没有 fork,它没有可以实现进程在任意点的分叉的机制。当然,现实中,Windows 可以使用多线程 API CreateThread 来干这件事。还可以大肆声张多线程要比多进程方案高效。但如果没有多线程,想必 Windows 面对 fork 的挑衅只能忍气吞声而兴叹了。因此,UNIX fork 有两个层面的含义:
- 创建新进程,fork-exec 序列(而不是 fork 本身)竞争 Windows CreateProcess 或者 POSIX spawn。
- 并行多处理,fork 作为多进程竞争多线程。
很明显,无论在哪个层面,fork 均已落后于对手: - 创建新进程,CreateProcess/spawn 剔除了不必要的资源复制操作。
- 并行多处理,多线程共享资源替代了昂贵的 IPC。
作为多进程的优化或者说替代,多线程的本质和 fork 的原始意义看起来并无太大的分歧。唯一的区别似乎就是资源共享的深度不同。fork 的原始意义将要在 Linux 内核 task 的设计中得到了延续和升华!Linux 内核的设计者似乎在很早以前就意识到了这一点,在很早的年代,Linux 内核就没有去设计一个表示进程的结构体,而只设计了一个 task_struct(以下简称 task),该结构体包含有让一个指令流能运行所需要的最少的东西!因此它并不包含特定于进程或者线程概念的字段。一个或者一组 task 对象到底是什么,关键看你怎么调配它!就像使用相同的文字,组合不同,或是诅咒,或是祝福。一个 task 对象只是一个原材料,它和其它 task 对象对资源的共享关系决定了它是什么。一组 task 对象按照下面的 ID 类型被标识为不同的实体:
关于上图更多的解释,参见下面的文章 朴素的 UNIX 之-进程/线程模型:enum pid_type { PIDTYPE_PID, PIDTYPE_TGID, PIDTYPE_PGID, PIDTYPE_SID, PIDTYPE_MAX };
https://blog.csdn.net/dog250/article/details/40208219
对应底层关于 task 灵活的设计,必须给予应用程序调配它的接口以适应这种灵活。完成这种适配的是 Linux 的 clone 系统调用,该系统调用在很早的 Linux 内核(至少是 2.2 版本)中就已经存在了:#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 */ );
/ For the prototype of the raw system call, see NOTES /
可见,参数众多,这里的 flags 参数就是让调用者控制如何和子进程共享资源的,拥有这种控制权是 clone 和 fork 最大的不同:注意到 clone 函数的声明依赖于一个宏:
```c
#define _GNU_SOURCE这意味着 clone 是非标准的。确实,它只是 Linux 的一个系统调用。之所以存在这个灵活的 clone 调用,完全得益于 Linux 内核底层对 task 灵活的设计。在传统 UNIX 系统或者类 UNIX 系统,未实现 clone。这里面的原因可能是 UNIX 从一开始就明确定义了进程,到了后来,当 UNIX 不得不支持线程的时候,就要引入一个所谓轻量级进程的新概念,意思是可以共享某些资源的进程。参见名牌 UNIX Solaris 中 lwp 的实现。在这些老牌 Unix 系统中,一开始过重的进程概念在引入多线程机制时造成了阻碍。然而对于 Linux,为了支持线程引入新的数据结构完全没有必要。虽然人们经常说 clone 调用创建的是轻量级进程,但也只是称呼罢了,Linux 内核内部没有一个表示轻量级进程的结构体。Linux 内核在底层 task 设计以及系统调用接口如此这般的设计,注定它实现 Posix 线程规范是超级简单的。一个 clone 参数就能搞定:注意后面那个 “(since Linux 2.4.0)” 注解,这意味着在 2.4 内核之前,Linux 内核是不支持 Posix 线程的。但是这里说的不支持,只是无法在内核级实现 Posix 规范要求线程必须遵循的语义,并不是说在并行多处理机制上不支持,至于说 POSIX 线程的语义,在用户态支持也是一个办法,这都是 2.4 内核之前的事了。2.4 内核之后,Linux 对线程的支持就完全是内核级的了。pthread 库完全基于 CLONE_THREAD 实现。CLONE_THREAD 的注释参见上图所示的 clone manual。具体如何创建一个线程呢?底层到底发生了什么呢?参见下面最简单 demo:
#include <pthread.h>
#include <stdio.h>
void *func(void *unused)
{
printf("sub thread\n");
return (void *)123;
}
int main(int argc, char **argv)
{
pthread_t t1;
void *p;
pthread_create(&t1, NULL, *func, NULL);
pthread_join(t1, &p);
printf("main thread:%d\n", (int)p);
return 0;
}
关于线程,重要的有两点,即创建和销毁。让我们来 strace 一下:
其中,clone 系统调用的 flags 参数的含义大致可以表述如下:
- 黄色:指示都共享哪些资源,MM,FILES,FS 等
- 红色:实现 POSIX 线程的语义,比如共享进程 PID,信号传递这些。
clone 之后,就创建了一个线程。线程执行 func 之后便退出了。问题是,线程是如何退出的呢?对于普通的 C 程序,我们知道 main 函数返回到了 C 库,而 C 库在 main 返回后会调用 exit 退出程序,而对于多线程程序,在编译代码的时候,我们显式链接了 libpthread,那么类似 C 库的事情在多线程程序里就 libpthread 库代劳了。大致的 pthread_create 应该是这个样子:
我们通过上面的 strace 可以看出,线程退出使用 exit 系统调用,而主进程退出则使用 exit_group 系统调用,二者的区别更多的是 Posix 进程/线程的语义上的,严格来讲,exit 系统调用仅仅退出当前的 task_struct,而 exit_group 则是退出当前 task_struct 所在进程的所有 task_struct,对于多线程程序,它当然就是退出所有的线程了。这就是 Linux 内核级线程的实现原理了。但是,clone 系统调用远不是仅仅实现多线程这么单一,它还可以优化 UNIX fork 的另一个层面。按照传统 UNIX fork 在两个层面的效用,Linux clone 的对应描述如下:void clone_func(Thread *thread) { ret = thread->fn(...); exit(ret); } int pthread_create(..., fn, ...) { thread = malloc(sizeof(&thread)); thread->fn = fn; ret = clone(clone_func, &thread); return ERR_NO(ret); }
- 在执行新进程层面,clone 可以仅仅 CLONE_VM 实现轻量级进程快速 exec 以避免不必要的资源拷贝。
- 在并行多处理层面,如前所述,clone 的 CLONE_XX 联合 CLONE_THREAD 可以实现内核级 POSIX 线程。
本文作为关于 fork 的后传,再不要说 fork 的不是了,fork 的思想最终被 Linux 所继承和发扬,一切回归到了 Conway 在 1963 年的原始论文,并行多处理,终于在 Linux clone 系统调用上得到了落实:
- clone 可以创建多线程并行执行序列。
- clone 创建新进程,减少不必要的资源复制。
好了,这就是我要为你讲述的 “fork” 的故事。