首页 > 代码库 > 朴素的UNIX之-进程/线程模型
朴素的UNIX之-进程/线程模型
UNIX的传统倾向于将一个任务交给一个进程全权受理,但是一个任务内部也不仅仅是一个执行绪,比如一个公司的所有成员,大家都在做同一件事,每个人却只负责一部分,粒度减小之后,所有的事情便可以同时进行,不管怎样,大家还都共享着所有的资源。因此就出现了线程。线程其实就是共享资源的不同的执行绪。线程的语义和朴素的UNIX进程是不同的。
在理解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.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进程模型的先进性。
在这样的时代,正如本文最初所说的,执行的粒度细化到了一个程序的内部。一个应用程序要完成一项任务,需要做不同的几件事,可能需要同时进行这几件事,类似数学中的统筹方法。进程,在WinNT中也可以等同于从可执行文件中抽取出来的命名资源集合,已经不再适合作为可执行的对象,真正可执行的对象成了线程。此时的进程只是提供了一个资源环境,线程使用这些可以共享的资源共同完成具体的事情。这种提供资源环境的进程模型我称为资源模型。
在本小节,我虽然以WinNT作为例子来描述另外一种进程模型,只是因为它作为这种模型的代表比较纯粹。实际上,很多的UNIX版本也在努力融合fork模型和资源模型这两者,企图既能继承UNIX的语义,又能实现多线程调度。
1.信号问题:到底哪个线程执行信号处理;
2.fork语义:假设已经运行了一个线程,在其中执行了fork,如何来解释fork的是哪个执行流;
其中第一个问题比较好解决,规定如果不是线程自身引发的异常导致的信号,就由任意线程来处理,反之由引发异常的线程来处理。第二个问题比较棘手,棘手之处在于某个UNIX是怎么实现进程模型的。
在进程结构体或者u区中维护一个链表,保存线程控制块指针!Oh,NO!这是怎么回事啊!UNIX怎么会忘了可执行的对象是进程啊!如此一来,进程岂不成了线程的容器?直接倒向了资源模型,然而自己确实是纯正的UNIX!设计LWP是一个好方案吗?可能是,但是它引入很多的高层抽象,显得复杂了,如果几年后再引入一个新的什么什么程呢?总之,任何修改朴素UNIX进程模型的方法都不是好方法。那么用户库级别的线程呢?这不属于内核的范畴,但表现了内核的无能为力。
抛开实现,回到思想。我们再来看看进程,进程组,会话之间的关系,最基本的可执行对象是进程,上面的进程组,会话都是以某种组织形式对进程集合的封装,每个集合都有一系列的资源可供这个集合中的进程共享。比如会话的环境变量,进程组的命令行变量等,线程是什么呢,线程不就是一组执行流的集合共享内存地址空间吗?明白了些什么吗?如果不明白,我们可以把UNIX进程模型图景中的进程改成调度实体,只需要在这个图景的基础上往下走一层,线程自然而然就被支持了:
线程,线程集合,进程组,会话...
换成调度实体的说法,就是:
调度实体,调度实体组,进程组,会话...
就像进程组里面可以只有一个进程,组ID等于进程ID一样,进程里面也可以只有一个线程,线程ID就是进程ID。一切都统一到这个UNIX进程模型的图景中了,如果一个线程集合只有一个线程,那么我们就称其为进程,如果拥有不止一个线程,我们就称这个集合为进程,而集合的元素为线程。其实,此时此刻,怎么称呼已经无所谓了。
现在还缺什么?缺的是如何实现线程集合共享内存地址空间。传统的UNIX fork模型无疑无法做到这一点,因为它没有任何参数用来指示实现这种行为。于是需要稍微修改一下fork语义,引入一个clone调用,含有用户可以控制的参数:
用户不但可以控制用户栈的位置,还可以有诸多的flags可供选择,如果要共享调用者的内存,CLONE_VM这个标志无疑是需要的,当然想clone线程不仅仅需要这一个标志,这里就不细说了,具体可以参考NPTL最新规范。
其中:
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进程模型的图景,这真的是太帅了。个人认为还是用一张图表示连接方式比较直观,文字表达在这方面弱爆了:
One of the comforting things about old memories is their tendencyto 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 andlacking 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 helpingto create the improvements that made life better.In ten years, I hope we can look back with the same mixed impressionof progress combined with continuity.
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进程模型的图景,这真的是太帅了。个人认为还是用一张图表示连接方式比较直观,文字表达在这方面弱爆了:
5.一段富有诗意的话
丹尼斯.里奇在回顾UNIX的发展史时,在最后说了一段话,这段话简直出自诗人之口,此诗意只有真情实感真性情方可抒发,可见丹尼斯.里奇对UNIX的感情是多么特殊:One of the comforting things about old memories is their tendencyto 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 andlacking 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 helpingto create the improvements that made life better.In ten years, I hope we can look back with the same mixed impressionof progress combined with continuity.
朴素的UNIX之-进程/线程模型
声明:以上内容来自用户投稿及互联网公开渠道收集整理发布,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任,若内容有误或涉及侵权可进行投诉: 投诉/举报 工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。