Linux 进程与线程模型

UNIX 的传统倾向于把一个任务交给一个进程全权受理。但一个任务内部往往不止一个执行流——就像公司里所有人做同一件事,每个人只负责一部分,粒度减小后各事情可以同时进行,同时大家又共享着所有资源。于是就有了线程:线程就是共享资源的不同的执行流。线程的语义和朴素的 UNIX 进程是不同的。

一、fork 模型:UNIX 的进程组织

朴素的 UNIX 进程依托于著名的 fork 调用。正是 fork 让 UNIX 进程和 Windows 进程截然不同,也使二者没有兼容的余地。

fork 的历史相当久远——早在 UNIX 之前的伯克利分时系统中就已存在。1969 年 UNIX 刚出现时其实并未引入 fork,当时只有两个固定进程连接两个终端;引入 fork 之后,进程数量才快速增加(注意,此时还没有 exec 调用)。

理解 fork 背后的哲学之前,先看它是什么:fork 就是”叉子”,由同一个叉柄逐渐分叉。有了 fork,理论上可以生成无数进程,它们都能向上回溯到相同的根。为何 UNIX 采用这个模型?要先理解在还没有”可执行文件”概念的年代,进程意味着什么:

1950~1960 年代初,程序都是现场录入的——通过纸带或沉重的磁带,文件系统尚无概念,介质上的内容就是计算机要执行的程序,执行完要换介质才能跑另一个程序。人们写程序当然是为了重复做一件事,因此如果有多个”进程”同时执行介质上的同一程序,系统吞吐率将大大提高。注意,多个进程执行的是同一个程序——这是最朴素的分时系统进程模型,fork 正因此在伯克利分时系统应运而生:它提供了复制当前执行流的手段,fork 出来的所有子进程可以方便地执行相同的代码。

这个 fork 调用深深影响了人们对分时系统的理解,并跟随 UNIX(及类 UNIX,如 Linux)至今,直接塑造了 UNIX 的进程模型。

UNIX 进程模型

在 UNIX 伊始,进程的概念与它的史前前辈一致。fork 调用把 UNIX 的进程组织成了一棵树,于是:

  1. 0 号 swap/sched 进程和 1 号 init 进程有了特殊地位;
  2. 形成了”谁 fork 谁 wait 并回收”的模型——在树状组织中,这一点对资源回收很重要;
  3. 父进程先退出时,所有子进程过继给 init,这要求 init 必须存在且不容退出——总之,任何进程都不能脱离整个进程树。

简言之,朴素的 UNIX 进程就是处在一棵树某个节点上的可执行对象

在这个基本原则之外,UNIX 在外围延续了 Multics 项目的 shell 思想,为每个终端开放一个 shell。shell 需要 fork 出来的进程 exec 出一个新的执行流。从 fork/exec 的历史看,二者从一开始就是分离的,这构成了完整的 UNIX 进程模型:fork + exec

进一步,早期的 UNIX 对进程进行组织,配合终端的概念,给出了「进程组」和「会话」两个概念:

  • 进程组:相关联的一组进程的集合(比如管道符连接的各个命令),它们之间的关联多由用户解释。
  • 会话:进程组的集合,意义在于让用户方便地让多个进程组以某种形式共享终端访问权。用户可以创建一个会话、在会话内创建多个进程组,并让不同的进程组轮流成为前台进程组。

由此,UNIX 进程模型构建出了一个分级的调度层次:最底层是进程(由内核调度),往上是进程组(协作完成一个任务),再往上是会话(由操作员调度)。最底层,所有进程组织成一棵树。fork+exec 是这一图景的基本原则——fork 和 exec 之间,进程有了更多控制自己归属哪个组、哪个会话的空间,这种归属由进程自己决定而非调用者决定(对比 Win32 API 的 CreateProcess)。

二、资源模型:进程提供环境,线程执行

在更晚的时代,执行的粒度细化到了程序内部——一个应用程序要完成一项任务,需要同时做几件不同的事。此时,进程(在 WinNT 中可理解为从可执行文件中抽取出来的命名资源集合)已不再适合作为可执行对象,真正可执行的对象变成了线程;进程只提供一个资源环境,线程使用这些共享的资源共同完成任务。这种”提供资源环境的进程模型”称为资源模型

这里以 WinNT 为例,只是因为它作为资源模型的代表比较纯粹。实际上很多 UNIX 版本也在努力融合 fork 模型和资源模型,既想继承 UNIX 的语义,又想实现多线程调度。

三、两种模型的调和

fork 模型与资源模型的冲突很明显,典型体现在两方面:

  1. 信号问题:到底哪个线程执行信号处理;
  2. fork 语义:已经运行了一个线程,在其中执行 fork,如何解释 fork 的是哪个执行流。

第一个问题比较好解决:不是由线程自身引发的异常信号,由任意线程处理;反之由引发异常的线程处理。第二个问题比较棘手,取决于某个 UNIX 如何实现进程模型。

回到思想层面再看进程、进程组、会话之间的关系:最基本的可执行对象是进程,进程组和会话都是对进程集合的某种封装,每个集合都有可共享的资源(如会话的环境变量、进程组的命令行变量)。那么线程是什么?线程不过是一组共享内存地址空间的执行流的集合

于是,只要把 UNIX 进程模型图景中的”进程”改成”调度实体”,再向下走一层,线程就自然而然被支持了:

调度实体 → 调度实体组 → 进程组 → 会话

就像进程组里可以只有一个进程(组 ID 等于进程 ID)一样,进程里也可以只有一个线程(线程 ID 就是进程 ID)。一切都统一到这个 UNIX 进程模型的图景中了:一个线程集合若只有一个线程,就称其为进程;若拥有多个线程,则称这个集合为进程、集合的元素为线程——此时怎么称呼已经无所谓了。

现在还缺的是”线程集合如何共享内存地址空间”。传统的 UNIX fork 模型无法做到这一点,因为它没有任何参数用来指示这种行为。于是需要稍微修改 fork 语义,引入一个 clone 调用,含有用户可控制的参数:

int clone(int (*fn)(void *), void *child_stack,
          int flags, void *arg, ...
          /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

用户不但可以控制用户栈的位置,还有诸多 flags 可供选择。若要共享调用者的内存,CLONE_VM 这个标志是必需的;clone 线程当然也不止需要这一个标志,具体可参考 NPTL 规范。

四、Linux 的实现

Linux 对线程支持的实现非常精简——它几乎没有触动任何已有的 task_struct 结构体,也没有改变任何既有的 fork 语义,只是引入了一个 PID 类型:TGID,即线程组 ID。

Linux 中的可执行对象就是 task_struct,而且只有 task_struct。每个 task_struct 拥有不止一个 ID,依 ID 的不同解释方式,可将 task_struct 定位到一个进程或一个进程的某个线程。ID 类型如下:

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

其中:

  • PIDTYPE_PID:调度实体 ID。若该 task_struct 是一个进程的线程,则为线程 ID;若进程只有唯一线程,则同时是进程 ID;
  • PIDTYPE_TGID:线程集合 ID。若该进程拥有多个线程,则为进程 ID;若只有一个线程,则等同于 PIDTYPE_PID
  • PIDTYPE_PGID:进程组 ID;
  • PIDTYPE_SID:会话 ID。

根据上述解释,无论进程拥有一个线程还是多个线程,其进程 ID(PID)均等于 PIDTYPE_TGID 标识的 ID,而 PIDTYPE_PID 标识的 ID 则视具体情况给予不同解释。具体实施如下:

  1. 每个 task_struct 都有一个在本 PID 命名空间内唯一的 ID,初始化时同时赋给进程 ID 和线程 ID;
  2. 若该 task_struct 是进程的第一个线程(由标准 fork 创建),则保持初始值不变;
  3. 若该 task_struct 不是第一个线程(由带 CLONE_VM 等的 clone 创建),则将调用者的 PIDTYPE_TGID 覆盖新 task_struct 的 PIDTYPE_TGID
  4. 进程组 ID 和会话 ID 由 setpgid、setpgrp、setsid 等系统调用设置,实现与上述进程/线程类似;
  5. 每个 task_struct 中有 4 个 pid 结构体,用链表(而非 task_struct 本身)连接起来,指示谁是进程、谁是某个进程的线程、谁是某个进程组的成员等。

总之,在 Linux 中,线程和进程都使用 task_struct 结构体,由其 PID type 的连接方式指示如何构建 UNIX 进程模型的图景。这个精简的模型恰好与 Linux 精简的实现相配——它洞察到了 UNIX 进程模型”进程、进程组、会话”三个层次的分层结构,只需把 task_struct 向下移动一层(加入线程层),就绘制出了上述图景。

五、结语

丹尼斯·里奇在回顾 UNIX 发展史时,最后说了一段话:

One of the comforting things about old memories is their tendency to take on a rosy glow. The programming environment provided by the early versions of Unix seems, when described here, to be extremely harsh and primitive. I am sure that if forced back to the PDP-7 I would find it intolerably limiting and lacking in conveniences. Nevertheless, it did not seem so at the time; the memory fixes on what was good and what lasted, and on the joy of helping to create the improvements that made life better. In ten years, I hope we can look back with the same mixed impression of progress combined with continuity.


   转载规则


《Linux 进程与线程模型》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
深入解析 gRPC 的重连机制 深入解析 gRPC 的重连机制
gRPC 的重连机制是确保客户端在连接断开后能够自动重新连接到服务器的一种机制,对于分布式系统和微服务架构中的高可用性和容错性至关重要。 什么是 gRPC 重连机制gRPC 重连机制是指在客户端与服务器之间的连接断开后,客户端自动尝试重新建
2020-04-02
下一篇 
TCP在CLOSE_WAIT状态还能收到报文 TCP在CLOSE_WAIT状态还能收到报文
我们都知道 TCP 是全双工通信,当客户端发送 FIN 后,服务端回复 ACK 进入 CLOSE_WAIT 状态,这个时候对于客户端来说,发送功能没有了,但是还可以正常接收服务端的数据,对于服务端来说,既可以接收客户端的数据也可以给客户端
2020-03-19
  目录