Linux clone 系统调用:fork 的变体

继传统 UNIX fork 之后,本文介绍 fork 在 Linux 内核中的变体——clone 系统调用的精妙之处。

理解 fork 的原始意义,还是要回到 Melvin Conway 提出 fork 思想的论文 A Multiprocessor System Design。该论文的核心是 Conway 分离了进程(process)和处理器(processor)的概念:

  • 一个进程不必固定在某一个处理器上被处理;
  • 一个处理器未必处理特定的进程;
  • 系统中进程数量和处理器数量不需要相等。

fork 为上述核心思想提供了实现手段,后来被引入 UNIX,成为创建新进程几十年不变的通用操作。比较有意思的是,UNIX fork 是因著名的 fork-exec 序列闻名于世,而不是因它提供的并行多处理手段——这可能是因为线程概念出现后,并行处理均由线程担当,再没有人记起 fork 的并行初衷了。

如果说一系列进程是完全可并行的,那么它们之间便没有相互依赖的资源,这正是现代操作系统进程抽象的基础。线程概念的出现,就是对 UNIX 进程抽象中”资源如何共享”的重新解构与重构。

fork 的两个层面

在线程出现之前,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 的基础。

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);
}

UNIX fork 有两个层面的含义:

  1. 创建新进程fork-exec 序列(而非 fork 本身)对标 Windows 的 CreateProcess 或 POSIX spawn;
  2. 并行多处理:fork 作为多进程对标多线程。

很明显,无论在哪个层面,fork 均已落后于对手:

  1. 创建新进程,CreateProcess/spawn 剔除了不必要的资源复制;
  2. 并行多处理,多线程共享资源替代了昂贵的 IPC。

作为多进程的优化或替代,多线程的本质与 fork 的原始意义并无太大分歧,唯一区别似乎只是资源共享的深度不同。而 fork 的原始意义,在 Linux 内核 task 的设计中得到了延续和升华。

Linux 的 task 设计

Linux 内核的设计者很早以前就意识到了这一点——Linux 内核很早就没有设计一个表示进程的结构体,而只设计了 task_struct(简称 task),它只包含让一个指令流运行所需的最少的东西,并不包含特定于进程或线程概念的字段。

一个或一组 task 对象到底是什么,关键看你怎么调配它——就像相同的文字,组合不同,或是诅咒,或是祝福。一个 task 对象只是原材料,它和其它 task 对象对资源的共享关系决定了它是什么。一组 task 对象按照下面的 ID 类型被标识为不同实体:

enum pid_type
{
    PIDTYPE_PID,
    PIDTYPE_TGID,
    PIDTYPE_PGID,
    PIDTYPE_SID,
    PIDTYPE_MAX
};

对应底层 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 的声明依赖于宏 #define _GNU_SOURCE,这意味着 clone 是非标准的——确实,它只是 Linux 的一个系统调用。之所以存在这个灵活的 clone 调用,完全得益于 Linux 内核底层对 task 的灵活设计。

用 clone 创建线程

看一个最简单的 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 追踪可以发现,pthread_create 底层调用的是 clone。clone 的 flags 参数含义大致可以表述为:

  • 指示共享哪些资源(MM、FILES、FS 等);
  • 实现 POSIX 线程语义(如共享进程 PID、信号传递等)。

clone 之后就创建了一个线程,线程执行 func 后便退出了。问题是,线程如何退出?普通 C 程序中 main 函数返回到 C 库,C 库在 main 返回后调用 exit 退出程序;多线程程序显式链接了 libpthread,类似 C 库的事情就由 libpthread 代劳了。大致的 pthread_create 实现如下:

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);
}

从 strace 可以看出:线程退出用 exit 系统调用,而主进程退出用 exit_group 系统调用。二者的区别更多是 POSIX 进程/线程语义上的——严格讲,exit 只退出当前 task_struct,而 exit_group 退出当前 task_struct 所在进程的所有 task_struct,对多线程程序就是退出所有线程。这就是 Linux 内核级线程的实现原理。

clone 的另一个用途

clone 系统调用远不止实现多线程这么单一,它还能优化 UNIX fork 的另一个层面。对应传统 fork 的两个层面,Linux clone 的描述如下:

  1. 在执行新进程层面,clone 可以仅用 CLONE_VM 实现轻量级进程快速 exec,避免不必要的资源拷贝;
  2. 在并行多处理层面,CLONE_XX 联合 CLONE_THREAD 可以实现内核级 POSIX 线程。

作为 fork 的后传,就不要再数落 fork 的不是了——fork 的思想最终被 Linux 继承和发扬,一切回归到了 Conway 1963 年的原始论文,并行多处理终于在 Linux clone 系统调用上得到了落实:

  • clone 可以创建多线程并行执行序列;
  • clone 创建新进程时减少不必要的资源复制。

这就是 fork 的故事。


   转载规则


《Linux clone 系统调用:fork 的变体》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
fork 的开销与争议 fork 的开销与争议
fork 是一个拥有 50 年历史的系统调用,也是一个传奇。一个程序员可以永远不用 read/write,也可以不懂 mmap,但必须懂 fork。fork 没有参数、如此简单,是 UNIX 哲学的布道者们的首选,被写进了几乎每一本操作系统
2020-05-28
下一篇 
fork 思想起源:从 Conway 论文说起 fork 思想起源:从 Conway 论文说起
fork 的思想比 UNIX 早出现几年,大约是 1963 年,比 UNIX 在 PDP-7 上的第一个版本早了 6 年。1963 年,计算机科学家 Melvin Conway 写下论文 A Multiprocessor System De
2020-05-04
  目录