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 的进程组织成了一棵树,于是:
- 0 号 swap/sched 进程和 1 号 init 进程有了特殊地位;
- 形成了”谁 fork 谁 wait 并回收”的模型——在树状组织中,这一点对资源回收很重要;
- 父进程先退出时,所有子进程过继给 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 模型与资源模型的冲突很明显,典型体现在两方面:
- 信号问题:到底哪个线程执行信号处理;
- 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 则视具体情况给予不同解释。具体实施如下:
- 每个 task_struct 都有一个在本 PID 命名空间内唯一的 ID,初始化时同时赋给进程 ID 和线程 ID;
- 若该 task_struct 是进程的第一个线程(由标准 fork 创建),则保持初始值不变;
- 若该 task_struct 不是第一个线程(由带
CLONE_VM等的 clone 创建),则将调用者的PIDTYPE_TGID覆盖新 task_struct 的PIDTYPE_TGID; - 进程组 ID 和会话 ID 由 setpgid、setpgrp、setsid 等系统调用设置,实现与上述进程/线程类似;
- 每个 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.