计算机系统(二):进程与线程(上篇)

目录

引言

2.1 进程

2.1.1 进程模型

2.1.2 进程的创建

2.1.3 进程的终止

2.1.4 进程的层次结构

2.1.5 进程的状态

2.1.6 进程的实现

2.1.7 多道程序设计模型

2.2 线程

2.2.1 线程的使用

2.2.2 经典的线程模型

2.2.3 POSIX线程

2.2.4 在用户空间中实现线程

2.2.5 在内核中实现线程

2.2.6 混合实现


引言

操作系统中最核心的概念是进程:这是对正在运行程序的一个抽象。操作系统的其他所有内容都是围绕着进程的概念展开的。它是操作系统提供的最古老的也是最重要的抽象概念之一。即使可以使用的CPU只有一个,但它们也具有支持(伪)并发操作的能力,它们将一个单独的CPU变换成多个虚拟的CPU。没有进程的抽象,现代计算将不复存在。我们会通过大量的细节去探究进程,以及它们的第一个亲戚——线程。
 

2.1 进程

所有现代的计算机经常会在同一时间做许多件事,这就需要一些方法去模拟并控制这种并发。进程(特别是线程)在这里就可以发挥作用。

现在考虑只有一个用户的PC。当启动系统时,会秘密启动许多进程。这些活动都需要管理,于是一个支持多进程的多道程序系统在这里就显得很有用了。

多道程序设计(伪并行)

在任何多道程序设计系统中,CPU由一个进程快速切换至另一个进程,使每个进程各运行几十或几百毫秒。严格地说,在某一个瞬间,CPU只能运行一个进程。但在1秒钟内,它可能运行多个进程,这样就产生并行的错觉。有时人们所说的伪并行就是指这种情形。

之所以称之为伪并行,是为了区分多处理器系统(该系统有两个或多个CPU共享同一个物理内存)的真正硬件并行。

由于很难对多个并行活动进行跟踪,所以操作系统的设计者开发了用于描述并行的一种概念模型(顺序进程),使得并行更容易处理。有关该模型的使用以及它的影响正是下面的主题。

2.1.1 进程模型

在进程模型中,计算机上所有可运行的软件,通常也包括操作系统,被组织成若干顺序进程(sequential process), 简称进程(process)。一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。从概念上说,每个进程拥有它自己的虚拟CPU。而实际上,真正的CPU在各进程之间来回切换。但为了理解这种系统,考虑在(伪)并行情况下运行的进程集,要比试图跟踪CPU如何在程序间来回切换简单得多(这种快速的切换称作多道程序设计)。

在图a) 中可以看到,在一台多道程序计算机的内存中有4道程序。

在图b) 中,这4道程序被抽象为4个各自拥有自已控制流程(即每个程序自己的逻辑程序计数器)的进程,并且每个程序都独立地运行。当然,实际上只有一个物理程序计数器,所以在每个程序运行时,它的逻辑程序计数器被装入实际的程序计数器中。当该程序执行结束(或暂停执行)时,物理程序计数器被保存在内存中该进程的逻辑程序计数器中。

在图c) 中可以看到,在观察足够长的一段时间后,所有的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正在运行。

这里,我们假设只有一个CPU。当然,现代芯片经常是多核的,但是现只考虑一个CPU。由于CPU在各进程之间来回快速切换,所以每个进程执行其运算的速度是不确定的。而且当同一进程再次运行时,其运算速度通常也不可再现。所以,在对进程编程时决不能对时序做任何想当然的假设。如果你在设计进程时,写了一些特定事件一定要在所指定的若干毫秒内发生,那么必须采取特殊措施以保证它们一定在这段时间中发生。

通常大多数进程并不受CPU多道程序设计或其他进程相对速度的影响。进程和程序间的区别是很微妙的,但非常重要。用一个比喻可以更容易理解这一点:

一位计算机科学家正在烘制蛋糕。他有食谱,厨房里有所需的原料。做蛋糕的食谱就是程序(即用适当形式描述的算法),计算机科学家就是处理器(CPU), 而各种原料就是输入数据。进程就是厨师阅读食谱、取来各种原料以及烘制蛋糕等一系列动作的总和。

现在假设计算机科学家的儿子哭着跑进来说他被蜜蜂蛰 了。计算机科学家就记录下他照着食谱做到哪儿了(保存进程的当前状态),然后拿出一本急救手册,按照其中的指示处理蛰伤。这里,CPU从一个进程(做蛋糕)切换到另一个高优先级的进程(实施医疗救治),每个进程拥有各自的程序(食谱和急救手册)。当蜜蜂蛰伤处理完之后,这位计算机科学家又回来做蛋糕,从他离开时的那一步继续做下去。

这里的关键思想是:一个进程是某种类型的一个活动,它有程序、输入、输出以及状态。单个处理器可以被若干进程共享,它使用某种调度算法决定何时停止一个进程的工作,并转而为另一个进程提供服务。

如果一个程序运行了两遍,则算作两个进程。因为它们是同一个程序不同的进程。操作系统能够使它们共享代码,因此只有一个副本放在内存中,但那只是一个技术性的细节,不会改变有两个进程正在运行的概念。

2.1.2 进程的创建

操作系统需要有一种方式来创建进程。一些非常简单的系统,即那种只为运行一个应用程序设计的系统,可能在系统启动之时,以后所需要的所有进程都已存在。然而,在通用系统中,需要有某种方法在运行时按需要创建或撤销进程。

四种主要事件会导致进程的创建:

  • 系统初始化。
  • 正在运行的程序执行了创建进程的系统调用。
  • 用户请求创建一个新进程。
  • 一个批处理作业的初始化。

系统初始化

启动操作系统时,通常会创建若干个进程。其中有些是前台进程,也就是和用户交互并且替他们完成工作的那些进程。其他的是后台进程,这些进程与用户没有关系,相反,却具有某些专门的功能。例如,设计一个后台进程来接收发来的电子邮件,这个进程在一天的大部分时间都在睡眠,但是当电子邮件到达时就突然被唤醒了。停留在后台处理诸如电子邮件、Web页面、新闻、打印之类活动的进程称为守护进程(daemon)。 在大型系统中通常有很多守护进程。

正在运行的程序执行了创建进程的系统调用

除了在启动阶段创建进程之外,新的进程也可以以后创建。一个正在运行的进程经常发出系统调用,以便创建一个或多个新进程协助其工作。如果一个进程的工作可以容易地划分成若干相关的但没有相互作用的进程时,创建新的进程就特别有效果。在多处理机中,让每个进程在不同的CPU上运行会使整个作业运行得更快。

用户请求创建一个新进程

在交互式系统中,键入一个命令或者双击一个图标就可以启动一个程序。这两个动作中的任何一个都会开始一个新的进程,并在其中运行所选择的程序。

一个批处理作业的初始化

最后一种创建进程的情形仅在大型机的批处理系统中应用。用户在这种系统中(可能是远程地)提交批处理作业。在操作系统认为有资源可运行另一个作业时,它创建一个新的进程,并运行其输入队列中的下一个作业。

从技术上看,在所有这些情形中,新进程都是由于一个已存在的进程执行了一个用于创建进程的系统调用而创建的。这个进程可以是一个运行的用户进程、一个由键盘或鼠标启动的系统进程或者一个批处理管理进程。这个进程所做的工作是,执行一个用来创建新进程的系统调用。这个系统调用通知操作系统创建一个新进程,并且直接或间接地指定在该进程中运行的程序。

fork

UNIX系统中,只有一个系统调用可以用来创建新进程:fork。这个系统调用会创建一个与调用进程相同的副本。在调用了fork后,这两个进程(父进程和子进程)拥有相同的内存映像、同样的环境字符串和同样的打开文件。

通常,子进程接着执行execve或一个类似的系统调用,以修改其内存映像并运行一个新的程序。之所以要安排两步建立进程,是为了在fork之后但在execve之前允许该子进程处理其文件描述符,这样可以完成对标准输入文件、标准输出文件和标准错误文件的重定向。

在Windows中,进程创建之后,父进程和子进程有各自不同的地址空间。如果其中某个进程在其地址空间中修改了一个字,这个修改对其他进程而言是不可见的。在UNIX中,子进程的初始地址空间是父进程的一个副本,但是这里涉及两个不同的地址空间,不可写的内存区是共享的。

某些UNIX的实现使程序正文在两者间共享,因为它不能被修改。或者,子进程共享父进程的所有内存,但这种情况下内存通过写时复制(copy-on-write)共享,这意味着一旦两者之一想要修改部分内存,则这块内存首先被明确地复制,以确保修改发生在私有内存区域。再次强调,可写的内存是不可以共享的。但是,对于一个新创建的进程而言,确实有可能共享其创建者的其他资源,诸如打开的文件等。在Windows中,从一开始父进程的地址空间和子进程的地址空间就是不同的。
 

2.1.3 进程的终止

进程在创建之后,它开始运行,完成其工作。但永恒是不存在的,进程也一样。迟早这个新的进程会终止,通常由下列条件引起:

  • 正常退出(自愿的)
  • 出错退出(自愿的)
  • 严重错误(非自愿)
  • 被其他进程杀死(非自愿)

正常退出(自愿的)

多数进程是由于完成了它们的工作而终止。当编译器完成了所给定程序的编译之后,编译器执行一个系统调用,通知操作系统它的工作已经完成。

出错退出(自愿的)

进程终止的第二个原因是进程发现了严重错误。例如,如果用户键入命令:

GCC foo.c

要编译程序foo.c,但是该文件并不存在,于是编译器就会退出。

严重错误(非自愿)

进程终止的第三个原因是由进程引起的错误,通常是由于程序中的错误所致。例如,执行了一条非法指令、引用不存在的内存,或除数是零等。有些系统中(如UNIX), 进程可以通知操作系统,它希望自行处理某些类型的错误,在这类错误中,进程会收到信号(被中断), 而不是在这类错误出现时终止。

被其他进程杀死(非自愿)

第四种终止进程的原因是,某个进程执行一个系统调用通知操作系统杀死某个其他进程。

2.1.4 进程的层次结构

某些系统中,当进程创建了另一个进程后,父进程和子进程就以某种形式继续保持关联。子进程自身可以创建更多的进程,组成一个进程的层次结构。在UNIX中,进程和它的所有子进程以及后裔共同组成一个进程组。当用户从键盘发出一个信号时,该信号被送给当前与键盘相关的进程组中的所有成员。每个进程可以分别捕获该信号、忽略该信号或采取默认的动作,即被该信号杀死。

Windows中没有进程层次的概念,所有的进程都是地位相同的。唯一类似于进程层次的暗示是在创建进程的时候,父进程得到一一个特别的令牌(称为句柄),该句柄可以用来控制子进程。但是,它有权把这个令牌传送给某个其他进程,这样就不存在进程层次了。在UNIX中,进程就不能剥夺其子进程的“继承权”

2.1.5 进程的状态

尽管每个进程是一个独立的实体,有其自己的程序计数器和内部状态,但是,进程之间经常需要相互作用。一个进程的输出结果可能作为另一个进程的输入。

当一个进程在逻辑上不能继续运行时,它就会被阻塞,典型的例子是它在等待可以使用的输入。还可能有这样的情况:一个概念上能够运行的进程被迫停止,因为操作系统调度另一个进程占用了CPU。

这两种情况是完全不同的。在第一种情况下,进程挂起是程序自身固有的原因(等待键入命令)。第二种情况则是由系统技术上的原因引起的(由于没有足够的CPU)。下图可以看到显示进程的三种状态的状态图,这三种状态是:

运行态该时刻进程实际占用CPU
就绪态可运行,但因为其他进程正在运行而暂时停止
阻塞态除非某种外部事件发生,否则进程不能运行

前两种状态在逻辑上是类似的。处于这两种状态的进程都可以运行,只是对于第二种状态暂时没有CPU分配给它。第三种状态与前两种状态不同,处于该状态的进程不能运行,即使CPU空闲也不行。进程的三种状态之间有四种可能的转换关系,如上图所示:

在操作系统发现进程不能继续运行下去时转换 1
转换2和3是由进程调度程序引起的,进程调度程序是操作系统的一部分进程甚至感觉不到调度程序的存在系统认为一个运行进程占用处理器的时间已经过长,决定让其他进程使用CPU时间时转换 2
在系统已经让所有其他进程享有了它们应有的公平待遇而重新轮到第一个进程再次占用CPU运行时转换 3
如果没有其他进程运行,则立即触发转换3。否则该进程将处于就绪态,等待CPU空闲并且轮到它运行。当进程等待的一个外部事件发生时(如一些输入到达)转换 4

调度程序的主要工作就是决定应当运行哪个进程、何时运行及它应该运行多长时间,这是很重要的一点。目前已经提出了许多算法,这些算法力图在整体效率和进程的竞争公平性之间取得平衡。

使用进程模型使得我们易于想象系统内部的操作状况。一些进程正在运行执行用户键入命令所对应的程序。另一些进程是系统的一部分。当发生一个磁盘中断时,系统会做出决定,停止运行当前进程,转而运行磁盘进程,该进程在此之前因等待中断而处于阻塞态。这样就可以不再考虑中断,而只是考虑用户进程、磁盘进程、终端进程等。这些进程在等待时总是处于阻塞状态。在已经读入磁盘或键入字符后,等待它们的进程就被解除阻塞,并成为可调度运行的进程。从这个观点引出了下图所示的模型:

图中,操作系统的最底层是调度程序,在它上面有许多进程。所有关于中断处理、启动进程和停止进程的具体细节都隐藏在调度程序中。实际上,调度程序是一段非常短小的程序。操作系统的其他部分被简单地组织成进程的形式。不过,很少有真实的系统是以这样的理想方式构造的。

2.1.6 进程的实现

为了实现进程模型,操作系统维护着一张表格(一个结构数组),即进程表(process table)。 每个进程占用一个进程表项。该表项包含了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其他在进程由运行态转换到就绪态或阻塞态时必须保存的信息,从而保证该进程随后能再次启动,就像从未被中断过一样。

下图中展示了在一个典型系统中的关键字段:

第一列中的字段与进程管理有关。其他两列分别与存储管理和文件管理有关。应该注意到进程表中的字段是与系统密切相关的,不过该图给出了所需要信息的大致介绍。

2.1.7 多道程序设计模型

采用多道程序设计可以提高CPU的利用率。严格地说,如果进程用于计算的平均时间是进程在内存中停留时间的20%,且内存中同时有5个进程,则CPU将一直满负载运行。然而,这个模型在现实中过于乐观,因为它假设这5个进程不会同时等待I/O。

更好的模型是从概率的角度来看CPU的利用率。假设一个进程等待I/O操作的时间与其停留在内存中时间的比为p。当内存中同时有n个进程时,则所有n个进程都在等待I/O(此时CPU空转)的概率是p^{n}。CPU的利用率由下面的公式给出:

CPU(use ratio)= 1-p^{n}

下图以n为变量的函数表示了CPU的利用率:

注:n是多道程序设计的道数(degree of multiprogramming)。从上图中可以看到,如果进程花费80%的时间等待I/O,为使CPU的浪费低于10%,至少要有10个进程同时在内存中。

从完全精确的角度考虑,应该指出此概率模型只是描述了一个大致的状况。它假设所有n个进程是独立的,即内存中的5个进程中,3个运行,2个等待,是完全可接受的。但在单CPU中,不能同时运行3个进程,所以当CPU忙时,已就绪的进程也必须等待CPU。因而,进程不是独立的。更精确的模型应该用排队论构建,但我们的模型(当进程就绪时,给进程分配CPU,否则让CPU空转)仍然是有效的,即使真实曲线会与图中所画的略有不同。

虽然上图的模型很简单、很粗略,它依然对预测CPU的性能很有效。例如,假设计算机有8GB内存,操作系统及相关表格占用2GB,每个用户程序也占用2GB。这些内存空间允许3个用户程序同时驻留在内存中。若80%的时间用于I/O等待,则CPU的利用率(忽略操作系统开销)大约是49%。在增加8GB字节的内存后,可从3道程序设计提高到7道程序设计,因而CPU利用率提高到79%。换言之,第二个8GB内存提高了30%的吞吐量。增加第三个8GB内存只能将CPU利用率从79%提高到91%,吞吐量的提高仅为12%。通过这一模型,计算机用户可以确定,第一次增加内存是一个划算的投资,而第二个则不是。

2.2 线程

在传统操作系统中,每个进程有一个地址空间和一个控制线程。事实上,这几乎就是进程的定义。不过,经常存在在同一个地址空间中准并行运行多个控制线程的情形,这些线程就像,(差不多)分离的进程(共享地址空间除外)。下面,我们将讨论这些情形及其实现。

2.2.1 线程的使用

为什么人们需要在一个进程中再有一类进程?有若干理由说明产生这些线程的必要性。下面我们来讨论其中一些理由。人们需要多线程的主要原因是:

程序设计模型会变得更简单

在许多应用中同时发生着多种活动。其中某些活动随着时间的推移会被阻塞。通过将这些应用程序分解成可以准并行运行的多个顺序线程,程序设计模型会变得更简单。

拥有共享同一个地址空间和所有可用数据的能力

正如前面所讨论的,正是之前关于进程模型的讨论。有了这样的抽象,我们才不必考虑中断、定时器和上下文切换,而只需考察并行进程。类似地,只是在有了多线程概念之后,我们才加入了一种新的元素:并行实体拥有共享同一个地址空间和所有可用数据的能力。对于某些应用而言,这种能力是必需的,而这正是多进程模型(它们具有不同的地址空间)所无法表达的。

线程比进程更轻量级,更容易(即更快)创建,也更容易撒销

第二个关于需要多线程的理由是,由于线程比进程更轻量级,所以它们比进程更容易(即更快)创建,也更容易撒销。

提高性能

需要多线程的第三个原因涉及性能方面的讨论。若多个线程都是CPU密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的I/O处理,拥有多个线程允许这些活动彼此重叠进行,从而会加快应用程序执行的速度。

多CPU系统中,多线程是有益的

最后,在多CPU系统中,多线程是有益的,在这样的系统中,真正的并行有了实现的可能。

举一个典型例子,我们可以更清楚地看出引入多线程的好处。考虑一个字处理软件。如果将一本书看作一个文件,比把每一章都看作一个文件要好,因为章节与章节之间要协同编辑,但是整本书作为文件,查找等操作的效率太低,我们还需要进一步优化。多线程在这里可以发挥作用。

第一个线程只是和用户交互;第二个线程在得到通知时进行文档的重新格式化;第三个线程周期性地将RAM中的内容写到磁盘上。很显然,在这里用三个不同的进程是不能工作的,这是因为三个线程都需要对同一个文件进行操作。由于多个线程可以共享公共内存,所以通过用三个线程替换三个进程,使得它们可以访问同一个正在编辑的文件,而三个进程是做不到的。

2.2.2 经典的线程模型

既然已经清楚为什么线程会有用以及如何使用它们,不如让我们用更进一步的眼光来审查一下上面的想法。进程模型基于两种独立的概念:资源分组处理与执行。有时,将这两种概念分开会更好,这就引入了“线程"这一概念。下面先介绍经典的线程模型

理解进程的一个角度是,用某种方法把相关的资源集中在一起。进程有存放程序正文和数据以及其他资源的地址空间。这些资源中包括打开的文件、子进程、即将发生的定时器、信号处理程序、账号信息等。把它们都放到进程中可以更容易管理。

另一个概念是,进程拥有一个执行的线程,通常简写为线程(thread)。 在线程中有:

程序计数器用来记录接着要执行哪一条指令
寄存器用来保存线程当前的工作变量
堆栈用来记录执行历史,其中每一帧保存了一个已调用的但是还没有从中返回的过程
状态用来记录线程的当前状态

尽管线程必须在某个进程中执行,但是线程和它的进程是不同的概念,并且可以分别处理。进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。

线程给进程模型增加了一项内容,即在同一个进程环境中,允许彼此之间有较大独立性的多个线程执行。在同一个进程中并行运行多个线程,是对在同一台计算机上并行运行多个进程的模拟。在前一种情形下,多个线程共享同一个地址空间和其他资源。而在后一种情形中,多个进程共享物理内存、磁盘、打印机和其他资源。

由于线程具有进程的某些性质,所以有时被称为轻量级进程(lightweight process)。多线程这个术语,也用来描述在同一个进程中允许多个线程的情形。一些CPU已经有直接硬件支持多线程,并允许线程切换在纳秒级完成。

在下图中,可以看到三个传统的进程:

每个进程有自己的地址空间和单个控制线程。相反,在图b) 中,可以看到一个进程带有三个控制线程。尽管在两种情形中都有三个线程,但是在图a) 中,每一个线程都在不同的地址空间中运行,而在图b) 中,这三个线程全部在相同的地址空间中运行。

当多线程进程在单CPU系统中运行时,线程轮流运行。进程的多道程序设计通过在多个进程之间来回切换,系统制造了不同的顺序进程并行运行的假象。多线程的工作方式也是类似的。CPU在线程之间的快速切换,制造了线程并行运行的假象,好似它们在一个比实际CPU慢一些的CPU上同时运行。在一个有三个计算密集型线程的进程中,线程以并行方式运行,每个线程在一个CPU上得到了真实CPU速度的三分之一。

进程中的不同线程不像不同进程之间那样存在很大的独立性。所有的线程都有完全一样的地址空间,这意味着它们也共享同样的全局变量。由于各个线程都可以访问进程地址空间中的每一个内存地址,所以一个线程可以读、写或甚至清除另一个线程的堆栈。线程之间是没有保护的,原因是: 不可能,也没有必要。这与不同进程是有差别的。不同的进程会来自不同的用户,它们彼此之间可能有敌意,一个进程总是由某个用户所拥有,该用户创建多个线程应该是为了它们之间的合作而不是彼此间争斗。除了共享地址空间之外,所有线程还共享同一个打开文件集、子进程、定时器以及相关信号等,如下图所示。这样,对于三个没有关系的线程而言,应该使用上图a) 的结构,而在三个线程实际完成同一个作业,并彼此积极密切合作的情形中,上图b) 则比较合适。

第一列表项是进程的属性,而不是线程的属性。例如,如果一个线程打开了一个文件,该文件对该进程中的其他线程都可见,这些线程可以对该文件进行读写。由于资源管理的单位是进程而非线程,所以这种情形是合理的。如果每个线程有其自己的地址空间、打开文件、即将发生的定时器等,那么它们就应该是不同的进程了。线程概念试图实现的是,共享一组资源的多个线程的执行能力,以便这些线程可以为完成某一任务而共同工作。

和传统进程一样(即只有一个线程的进程),线程可以处于若干种状态的任何一个:运行、阻塞、就绪或终止。正在运行的线程拥有CPU并且是活跃的。被阻塞的线程正在等待某个释放它的事件。线程可以被阻塞,以便等待某个外部事件的发生或者等待其他线程来释放它。就绪线程可被调度运行,并且只要轮到它就很快可以运行。线程状态之间的转换和进程状态之间的转换是一样的。

认识到每个线程有其自己的堆栈很重要,如下图所示:

每个线程的堆栈有一帧,供各个被调用但是还没有从中返回的过程使用。在该栈帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。例如,如果过程X调用过程Y,而Y又调用了Z,那么当Z执行时,供X、Y和Z使用的栈帧会全部存在堆栈中。

通常每个线程会调用不同的过程,从而有一个各自不同的执行历史,这就是为什么每个线程需要有自己的堆栈的原因。在多线程的情况下,进程通常会从当前的单个线程开始。这个线程有能力通过调用一个库函数(如thread_create)创建新的线程。thread_create的参数专门指定了新线程要运行的过程名。这里,没有必要对新线程的地址空间加以规定,因为新线程会自动在创建线程的地址空间中运行。

有时,线程是有层次的,它们具有一种父子关系,但是,通常不存在这样一种关系, 所有的线程都是平等的。不论有无层次关系,创建线程通常都返回一个线程标识符,该标识符就是新线程的名字。当一个线程完成工作后,可以通过调用一个库过程(如thread exit)退出。该线程接着消失,不再可调度。

在某些线程系统中,通过调用一个过程,例如thread_ join,一个线程可以等待一个(特定)线程退出。这个过程阻塞调用线程直到那个(特定)线程退出。在这种情况下,线程的创建和终止非常类似于进程的创建和终止,并且也有着同样的选项。

另一个常见的线程调用是thread_yield, 它允许线程自动放弃CPU从而让另一个线程运行。这样一个调用是很重要的,因为不同于进程,(线程库)无法利用时钟中断强制线程让出CPU。所以设法使线程行为“高尚”起来,并且随着时间的推移自动交出CPU,以便让其他线程有机会运行,就变得非常重要。有的调用允许某个线程等待另一个线程完成某些任务,或等待一个线程宣称它已经完成了有关的工作等。

通常而言,线程是有益的,但是线程也在程序设计模式中引入了某种程度的复杂性。要使多线程的程序正确工作,就需要仔细思考和设计。

2.2.3 POSIX线程

为实现可移植的线程程序,IEEE在IEEE标准1003.1c中定义了线程的标准。它定义的线程包叫作pthread。大部分UNIX系统都支持该标准。这个标准定义了超过60个函数调用,如果在这里列举一遍就太多了。这里仅描述一些主要的函数,以说明它是如何工作的。下图中列举了这些函数调用:

所有pthread线程都有某些特性。每一个都含有一个标识符、一组寄存器(包括程序计数器)和一组存储在结构中的属性。这些属性包括堆栈大小、调度参数以及其他线程需要的项目。

pthread_create

创建一个新线程需要使用pthread_create调用。 新创建的线程的线程标识符会作为函数值返回。这种调用有意看起来很像fork系统调用,其中线程标识符起着PID的作用,而这么做的目的主要是为了标识在其他调用中引用的线程。

pthread_exit

当一个线程完成分配给它的工作时,可以通过调用pthread_exit来终止。这个调用终止该线程并释放它的栈。

pthread_join

一般一个线程在继续运行前需要等待另一个线程完成它的工作并退出。可以通过pthread_join线程调用来等待别的特定线程的终止。而要等待线程的线程标识符作为一个参数给出。

pthread_yield

有时会出现这种情况,一个线程逻辑上没有阻塞,但感觉上它已经运行了足够长时间并且希望给另外一个线程机会去运行。这时可以通过调用pthread_yield完成这一目标。而进程中没有这种调用,因为假设进程间会有激烈的竞争性,并且每一个进程都希望获得它所能得到的所有的CPU时间。但是,由于同一进程中的线程可以同时工作,并且它们的代码总是由同一个程序员编写的,因此,有时程序员希望它们能互相给对方一些机会去运行。

下面两个线程调用是处理属性的:

pthread_attr_init

pthread_attr_init建立关联一个线程的属性结构并初始化成默认值。这些值(例如优先级)可以通过修改属性结构中的域值来改变。

pthread_ attr_destroy

pthread_ attr_destroy 删除一个线程的属性结构,释放它占用的内存。它不会影响调用它的线程。这些线程会继续存在。

2.2.4 在用户空间中实现线程

有两种主要的方法实现线程包:在用户空间中和在内核中。这两种方法互有利弊,不过混合实现方式也是可能的。我们现在介绍这些方法,并分析它们的优点和缺点。

第一种方法是把整个线程包放在用户空间中,内核对线程包一无所知。从内核角度考虑,就是按正常的方式管理,即单线程进程。

优点

这种方法第一个也是最明显的优点是,用户级线程包可以在不支持线程的操作系统上实现

在用户空间管理线程时,每个进程需要有其专用的线程表(thread table),用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态等。该线程表由运行时系统管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程所需的信息,与内核在进程表中存放进程的信息完全一样。当某个线程做了一些会引起在本地阻塞的事情之后,例如,等待进程中另一个线程完成某项工作,它调用一个运行时系统的过程,这个过程检查该线程是否必须进入阻塞状态。如果是,它在线程表中保存该线程的寄存器(即它本身的),查看表中可运行的就绪线程,并把新线程的保存值重新装入机器的寄存器中。只要堆栈指针和程序计数器一被切换,新的线程就又自动投入运行。如果机器有一条保存所有寄存器的指令和另一条装入全部寄存器的指令,那么整个线程的切换可以在几条指令内完成。进行类似于这样的线程切换至少比陷入内核要快一个数量级(或许更多),这是使用用户级线程包的极大的优点。

线程与进程有一个关键的差别。在线程完成运行时,它可以调用线程调度程序来选择另一个要运行的线程。保存该线程状态的过程和调度程序都只是本地过程,所以启动它们比进行内核调用效率更高。另一方面,不需要陷入内核,不需要上下文切换,也不需要对内存高速缓存进行刷新,这就使得线程调度非常快捷

用户级线程还有另一个优点。它允许每个进程有自己定制的调度算法

用户级线程还具有较好的可扩展性,这是因为在内核空间中内核线程需要一些固定表格空间和堆栈空间,如果内核线程的数量非常大,就会出现问题。

缺点

尽管用户级线程包有更好的性能,但它也存在一些明显的问题。其中第一个问题是不能实现阻塞系统调用。假设在还没有任何击键之前,一个线程读取键盘。让该线程实际进行该系统调用是不可接受的,因为这会停止所有的线程。使用线程的一个主要目标是,首先要允许每个线程使用阻塞调用,但是还要避免被阻塞的线程影响其他的线程。有了阻塞系统调用,这个目标不是轻易地能够实现的(阻塞调用是指调用结果返回之前,当前线程会被挂起)。

这个意思就是内核只知道你是一个单线程进程,所以你需要读取键盘等外部输入的时候它就直接阻塞住你了,导致你里面其他可以运行的线程也被阻塞住了。

系统调用可以全部改成非阻塞的,但是这需要修改操作系统,所以这个办法也不吸引人。而且,用户级线程的一个长处就是它可以在现有的操作系统上运行。

在这个过程中,还有一种可能的替代方案。方法的具体实现不再赘述了,但是这个处理方法需要重写部分系统调用库,所以效率不高也不优雅,不过没有其他的可选方案了。在系统调用周围从事检查的这类代码称为包装器(jacket或wrapper)。

与阻塞系统调用问题类似的是缺页中断问题,如果有一个线程引起页面故障,内核由于甚至不知道有线程存在,通常会把整个进程阻塞直到磁盘I/O完成为止,尽管其他的线程是可以运行的。

用户级线程包的另一个问题是,如果一个线程开始运行,那么在该进程中的其他线程就不能运行,除非第一个线程自动放弃CPU。在一个单独的进程内部,没有时钟中断,所以不可能用轮转调度(轮流)的方式调度线程。除非某个线程能够按照自己的意志进人运行时系统,否则调度程序就没有任何机会。

2.2.5 在内核中实现线程

现在考虑内核支持和管理线程的情形。此时不再需要运行时系统了。另外,每个进程中也没有线程表。相反,在内核中有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时,它进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建或撤销工作。

内核的线程表保存了每个线程的寄存器、状态和其他信息。这些信息和在用户空间中(在运行时系统中)的线程是一样的,但是现在保存在内核中。这些信息是传统内核所维护的每个单线程进程信息(即进程状态)的子集。另外,内核还维护了传统的进程表,以便跟踪进程的状态。所有能够阻塞线程的调用都以系统调用的形式实现,这与运行时系统过程相比,代价是相当可观的。

当一个线程阻塞时,内核根据其选择,可以运行同一个进程中的另一个线程(若有一个就绪线程)或者运行另一个进程中的线程。而在用户级线程中,运行时系统始终运行自己进程中的线程,直到内核剥夺它的CPU(或者没有可运行的线程存在了)为止。由于在内核中创建或撤销线程的代价比较大,某些系统采取“环保"的处理方式,回收其线程。当某个线程被撤销时,就把它标志为不可运行的,但是其内核数据结构没有受到影响。稍后,在必须创建一个新线程时,就重新启动某个旧线程,从而节省了一些开销。在用户级线程中线程回收也是可能的,但是由于其线程管理的代价很小,所以没有必要进行这项工作。

内核线程不需要任何新的、非阻塞系统调用。另外,如果某个进程中的线程引起了页面故障,内核可以很方便地检查该进程是否有任何其他可运行的线程,如果有,在等待所需要的页面从磁盘读入时,就选择一个可运行的线程运行。这样做的主要缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止等)比较多,就会带来很大的开销。

虽然使用内核线程可以解决很多问题,但是也不会解决所有的问题。例如,当一个多线程进程创建新的进程时,会发生什么?新进程是拥有与原进程相同数量的线程,还是只有一个线程?在很多情况下,最好的选择取决于进程计划下一步做什么。如果它要调用exec来启动一个新的程序,或许一个线程是正确的选择;但是如果它继续执行,则最好复制所有的线程。

另一个话题是信号。回忆一下,信号是发给进程而不是线程的,至少在经典模型中是这样的。当一个信号到达时,应该由哪一个线程处理它?线程可以“注册”它们感兴趣的某些信号,因此当一个信号到达的时候,可把它交给需要它的线程。但是如果两个或更多的线程注册了相同的信号,会发生什么?这只是线程引起的问题中的两个,但是还有更多的问题。

2.2.6 混合实现

人们已经研究 了各种试图将用户级线程的优点和内核级线程的优点结合起来的方法。一种方法是使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来,如图所示:

如果采用这种方法,编程人员可以决定有多少个内核级线程和多少个用户级线程彼此多路复用。这一模型带来最大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。如同在没有多线程能力操作系统中某个进程中的用户级线程一样,可以创建、撤销和调度这些用户级线程。在这种模型中,每个内核级线程有一个可以轮流使用的用户级线程集合。

下篇,我们讲学习有关进程间通信和进程调度的相关知识。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值