共计 5873 个字符,预计需要花费 15 分钟才能阅读完成。
这篇文章将为大家详细讲解有关 UNIX 中的进程及线程模型是怎样的,文章内容质量较高,因此丸趣 TV 小编分享给大家做个参考,希望大家阅读完这篇文章后对相关知识有一定的了解。
UNIX 的传统倾向于将一个任务交给一个进程全权受理,但是一个任务内部也不仅仅是一个执行绪,比如一个公司的所有成员,大家都在做同一件事,每个人却只
负责一部分,粒度减小之后,所有的事情便可以同时进行,不管怎样,大家还都共享着所有的资源。因此就出现了线程。线程其实就是共享资源的不同的执行绪。线程的语义和朴素的 UNIX 进程是不同的。
0. 原始进程模型 - 著名的 fork 调用
朴素的 UNIX 进程依托于著名的 fork 调用,
就是这个 fork 调用让 UNIX 进程和 Windows 进程截然不同,也正是因为这个 fork 调用,使二者没有兼容的余地。这个 fork 调用的根源有久远的
历史。早在 UNIX 之前的大型操作系统中,它就存在了,UNIX 刚出现的 1969 年,其实并未引入 fork 调用,当时之有两个固定的进程连接两个终端。当
fork 调用引入后,进程的数量便快速增加了,注意,此时暂且还没有 exec 调用!
在理解 fork 背后的哲学之前,先看一下什么是 fork。fork 就是叉子,由同一个叉子柄逐渐分叉,变成一把叉子,也类似那种道生一,一生二,二生三,
三生万物。我们看到,有了 fork,理论上可以生成无数的进程,它们都可以向上回溯到相同的根!为何 UNIX 会采用这个模型?我们首先要理解,在还没有
“可执行文件”概念的时候,进程意味着什么。
试想程序最初是怎么录入到计算机的。今天它们理所当然地存在于磁盘上,作为“可执行文件”已经深入人心,可是在 1950-1960 年代初,程序都是现场录
入的,通过原始的纸带或者携带很重的磁带,文件系统还没有概念,整个纸带,磁带上的内容就是计算机要执行的程序,执行完了,想执行另一个程序,就要换介
质 … 人们写一个程序当然是为了做一件不止做一次的事,因此如果可以有多个“进程”同时执行纸带 / 磁带上的程序,系统的吞吐率将大大提高,注意,多个进
程执行的是同一个程序!这是最朴素的分时系统进程模型。fork 在伯克利分时系统应运而生!fork 提供了复制当前执行流的手段,fork 出来的所有子进
程可以方便地执行相同的代码。
这个著名的 fork 调用深深影响了人们如何解释分时系统!自然而然在 1970 年代初引入了朴素的 UNIX,说 fork 调用著名,就是因为它跟随
UNIX(以及类 UNIX,比如 Linux) 至今,直接影响了 UNIX 的进程模型。现在总结 UNIX 为何采用 fork 调用来生成进程。我们知道从 0 到 1 很
难,从 1 到 2 相对容易,也比较难,从 2 到 3 … 就很简单了。这就是道生一,… 三生万物!1969 年的 UNIX 中已经有了两个进程,使用 fork 可以
超级简单地实现二生三,三生万物,于是,也许是一种巧合,早先的伯克利分时系统的 fork 正好就在那里,便被托马斯引入了 UNIX。
我想说一下为何是三生万物而不是二生万物。道生一这个是最难的,我们都知道。0 和 1 是两个极其特殊的数字,0 更加特殊。2 也比较特殊,但是 3 就很一般了,
为何 2 特殊呢?我不想用博弈理论来描述,只是举一个例子,2 个人在一起,闻到一股屁味,每个人都肯定能百分百确定是谁放的,如果是我,那我肯定知道,如果
我没有放,那肯定是对方,当然两人一起放的几率也是有的。但是 3 个人在一起的时候,除了真正放屁的那个人之外的 2 个人根本无法判断这个屁到底是谁放的。这
就是 3 和 0,1,2 的本质区别。所以三生万物。
1.UNIX 进程模型
在 UNIX 伊始,进程的概念和其史前前辈是一致的,那个时
候文件系统相当不成熟,程序员关注的是执行好不容易写好的任务而不是编写任务本身 (首先是没有那么大的需求,其次是信息存储是一个问题,没有互联网,可以
对比一下如今的 AppStore…)。fork 调用便直接将 UNIX 的进程组织成了 tree,于是:
1.0 号 swap/sched 进程和 1 号 init 进程便有了特殊地位;
2. 形成了谁 fork 谁 wait 并回收的模型,在 tree 组织中这个很重要,便于资源回收;
3. 如果父进程先退出,将所有子进程过继给 init,这导致 init 必须存在且不容退出,总之,任何进程不能脱离整个进程 tree。
总之,朴素的 UNIX 进程就是处在一棵树的某个节点的可执行对象。注意,它是可执行对象。
UNIX 进程模型就是在上述基本原则上构建的,除此之外,在外围,UNIX 延续了歇菜的 Multics 项目的 shell 思想,为每一个终端开放了一个
shell。shell 是 UNIX 系统的第二个重要特征 (如果先不说文件抽象的话!),它需要 fork 出来的进程 exec 出一个新的不同的执行流。从以上
fork/exec 的历史上看,它们从一开始就是分离的,这就构建了完整的 UNIX 进程模型:fork+exec。
我们看一下 UNIX 的进程模型可以构建哪些东西。早期的 UNIX 将进程进行了组织,伙同终端的概念,UNIX 给出了进程组,会话的概念。
进程组是相关联的一组进程的集合,比如管道符连接的各个命令。更多的是它们之间的关联由用户来解释。会话则是进程组的集合,会话的意义在于用户可以方便地
让多个进程组以某种形式共享终端访问权。因为坐在一个终端前的是一个人,他每次执行一个操作,这个操作作用给谁就是一个问题。他可以创建一个会话,该会话
内创建多个进程组,他以自己的方式让不同的进程组轮流成为前台进程组从而操作它。会话和进程组的概念可以理解成由操作员控制的分时系统,只是调度者不再是
操作系统,而成了终端前的操作员。和每个 CPU 同时只能有一个进程运行类似,每一个终端会话同时只能有一个前台进程组。
我们可以看到,UNIX 进程模型构建的进程组织自然而然形成了一个分级的分时调度层次,最底层是进程,由操作系统内核调度,然后是进程组,协作完成一个任
务,组织多个进程,由创建所属会话的操作员调度。在这个分级的层次底层,所有的进程组织成一棵 tree。这就是完整的 UNIX 进程模型构建的图景。之所以
可以构建如此美丽的图景,fork+exec 是基本原则,fork 和 exec 之间,给了进程更多的控制自己的空间,如何控制自己属于哪一个组或者会话,由
进程自己决定而不是调用者决定,相反的例子请看一下 Win32
API 的 CreateProcess。现在麻烦来了,线程出现了,该怎么办?如果你想知道 Linux 是怎么创造历史的,请直接跳到最后。
我之所以没有提及任何 UNIX 版本对上述构建的实现,是因为思想远比实现更重要,实现反而会拖累你构建新的模型。本文的最后,我会说明 Linux 是如何调和不同的进程模型之间的语义的,同时印证了 UNIX 进程模型的先进性。
2. 提供资源环境的进程模型
Windows
NT 虽然在很多方面都借鉴了 UNIX 的思想,但是在进程模型上却采用了一种截然不同的思路。Windows
NT 出生的 1990 年代,应用已经开始遍地开花,文件系统也已经非常成熟,可执行文件的概念延续自 MS-DOS 时代 (其实 UNIXv6 版本就有可执行文件
的概念,在 UNIX 引入 exec 调用之后,可执行文件仅仅是进程的后备资源,仅此而已 ),人们可以基于 Win32
API 开发大量不同的程序,然后让它们分别运行,如果你想让一个程序执行多次,多点击它几次便是了。
在这样的时代,正如本文最初所说的,执行的粒度细化到了一个程序的内部。一个应用程序要完成一项任务,需要做不同的几件事,可能需要同时进行这几件事,类
似数学中的统筹方法。进程,在 WinNT 中也可以等同于从可执行文件中抽取出来的命名资源集合,已经不再适合作为可执行的对象,真正可执行的对象成了线
程。此时的进程只是提供了一个资源环境,线程使用这些可以共享的资源共同完成具体的事情。这种提供资源环境的进程模型我称为资源模型。
在本小节,我虽然以 WinNT 作为例子来描述另外一种进程模型,只是因为它作为这种模型的代表比较纯粹。实际上,很多的 UNIX 版本也在努力融合 fork 模型和资源模型这两者,企图既能继承 UNIX 的语义,又能实现多线程调度。
3. 两种模型的调和
首先,fork 模型和资源模型的冲突是明显的,典型体现于以下两个方面:
1. 信号问题:到底哪个线程执行信号处理;
2.fork 语义:假设已经运行了一个线程,在其中执行了 fork,如何来解释 fork 的是哪个执行流;
其中第一个问题比较好解决,规定如果不是线程自身引发的异常导致的信号,就由任意线程来处理,反之由引发异常的线程来处理。第二个问题比较棘手,棘手之处在于某个 UNIX 是怎么实现进程模型的。
在进程结构体或者 u 区中维护一个链表,保存线程控制块指针!Oh,NO!这是怎么回事啊!UNIX 怎么会忘了可执行的对象是进程啊!如此一来,进程岂不成
了线程的容器?直接倒向了资源模型,然而自己确实是纯正的 UNIX!设计 LWP 是一个好方案吗?可能是,但是它引入很多的高层抽象,显得复杂了,如果几年
后再引入一个新的什么什么程呢?总之,任何修改朴素 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 最新规范。
4.Linux 的对 UNIX 进程模型的实现
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。如果该 task_struct 所属的进程拥有多个线程,它就是进程 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 调用创建,那么保持 1 的初始化数值不变;
3. 如果该 task_struct 不是一个进程的第一个线程,即由带有 CLONE_VM 等的 clone 调用创建,那么将当前调用者的 PIDTYPE_TGID 标识的 ID 覆盖新 task_struct 的 PIDTYPE_TGID 标识的 ID;
4. 关于进程组 ID 以及会话 ID 的设置,有专门的 setpgid,setpgrp,setsid 等系统调用来完成,实现很类似上述进程和线程;
5. 每个 task_struct 中有 4 个 pid 结构体,将这些 pid 结构体而不是 task_struct 本身用链表连接起来,指示谁是进程,谁是哪个进程的线程,谁是哪个进程组当头的组成员 …
总之,在 Linux 中,不管是线程,还是进程,都是使用 task_struct 这个结构体,由其 PID type 的值的连接方式指示如何构建 UNIX 进程模型的图景,这真的是太帅了。个人认为还是用一张图表示连接方式比较直观,文字表达在这方面弱爆了:
如
果理解了上面的图,就会明白 Linux 在实现 UNIX 进程模型方面做的是多么帅。如此精简的一个模型和 Linux 如此精简的实现正好搭配,不知为何被传统
的 UNIX 引到了那么复杂的方向 …Linux 的实现明显洞察到了 UNIX 进程模型的层次化结构,即进程,进程组,会话这三个层次,如果再往下延伸一个
层次,将 task_struct 向下移动到最底层,就基本绘制出了上面的图景。
关于 UNIX 中的进程及线程模型是怎样的就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。