进程与线程:线程



2.2 线程

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


2.2.1 线程的使用(Thread Usage)

为什么人们需要在一个进程中再有一类进程 ?有若干理由说明产生这些迷你进程 (称为线程) 的必 要性。下面我们来讨论其中一些理由。人们需要多线程的主要原因是,在许多应用中同时发生着多种活 动。其中某些活动随着时间的推移会被阻塞。通过将这些应用程序分解成可 以准并行运行的多个颐序线 程,程序设计模型会变得更简单 。

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

第二个关于需要多线程的理由是,由于线程比进程更轻量级 ,所以它们比进程更容易 (即更快)创建 ,也更容易撤销 。在许多系统中,创建一个线程较创建一个进程要快 10-100倍。在有大量线程需要动态和快速修改时,具有这一特性是很有用的。

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

最后,在多CPU系统中 ,多线程是有益的,在这样的系统中 ,真正的并行有了实现的可能,第8章 将讨论这个主题。

通过考察一些典型例子 ,我们可以更清楚地看出引入多线程的好处。作为第一个例子 ,考虑一个字处理软件。字处理软件通常按照出现在打印页上的格式在屏幕上精确显示文档 。特别地,所有的行分隔符和页分隔符都在正确的最终位置上 ,这样在需要时用户可以检查和修改文档 (比如,消除孤行一一在 一页上不完整的顶部行和底部行,因为这些行不甚美观)。

假设用户正在写一本书。从作者的观点来看 ,最容易的方法是把整本书作为 一个文件 ,这样一来 , 查询内容、完成全局替换等都非常容易。另一种方能是,把每一章都处理成单独一个文件。但是,在把每 个小节和子小节都分成单个的文件之后,若必须对全书进行全局的修改时,那就真是麻烦了,因为有成百 个文件必须一个个地编辑。例如,如果所建议的某个标准 x x x x 正好在书付印之前被批准了 ,于是“标 准草案 x × × ×” 一类的字眼就必须改为 “标准 × × × ×”。如果整本书是一个文件 ,那么只要一个命令 就可以完成全部的替换处理。相反,如果一本书分成了300个文件 ,那么就必须分别对每个文件进行编辑。

现在考虑 ,如果有一个用户突然在一个有800页的文件的第一页上删掉了一个语句之后 ,会发生什 么情形。在检查了所修改的页面并确认正确后 ,这个用户现在打算接着在第 600页上进行另一个修改 , 并键入一条命令通知字处理软件转到该页面 (可能要查阅只在那里出现的一个短语)。于是字处理软件 被强制对整本书的前600页重新进行格式处理 ,这是因为在排列该页前面的所有页面之前,字处理软件 并不知道第600页的第一行应该在哪里 。而在第600页的页面可以真正在屏幕上显示出来之前 ,计算机可 能要拖延相当一段时间 ,从而令用户不甚满意。

多线程在这里可以发挥作用。假设字处理软件被编写成含有两个线程的程序。一个线程与用户交互 , 而另一个在后台重新进行格式处理。一旦在第1页中的语句被删除掉 ,交互线程就立即通知格式化线程 对整本书重新进行处理 。同时,交互线程继续监控键盘和 鼠标,并响应诸如攘动第1页之类的简单命令 , 此刻 ,另一个线程正在后台疯狂地运算 。如果有点运气的话,重新格式化会在用户请求查看第600页之前完成 ,这样,第600页页面就立即可以在屏幕上显示出来 。

如果已经做到了这一步,那么为什么不再进一步增加一个线程呢?许多字处理软件都有每隔若干分钟自动在磁盘上保存整个文件的特点 ,用于避免囱于程序崩惯 、系统崩捕或电掠故障而造成用户一整天 的工作丢失的情况。第三个线程可以处理磁盘备份 ,而不必干扰其他两个线程。拥有三个线程的情形 , 如图2-7所示。
在这里插入图片描述
如果程序是单线程的,那么在进行磁盘备份时,来自键盘和鼠标的命令就会被忽略,直到备份工作完成为止。用户当然会认为性能很差。另一个方法是,为了获得好的性能,可以让键盘和鼠标事件中断磁盘备份,但这样去引入了复杂的中断驱动程序设计模型。
如果使用三个线程,程序设计模型就很简单了。第1个线程只是和用户交互;第2个线程在得到通知时进行文档的重新格式化;第3个线程周期性地将RAM中的内容写到磁盘上。

很显然,在这里用三个不同的进程是不能工作的,这是因为三个线程都需要对同一个文件进行操作。由于多个线程可以共享公共内存,所以通过用三个线程代替三个进程,使得他们可以访问同一个正在编辑的文件,而三个进程是做不到的。

许多其他的交互式程序中也存在类似的情形。例如,电子表格是允许用户维护矩阵的一种程序,矩阵中的一些元素是用户提供的数据;另一些元素是通过所输入的数据运用可能比较复杂的公式而得出的计算结果。当用户改变一个元素时,许多其他元素就必须重新计算。通过一个后台线程进行重新计算的方式,交互式线程就能够再进行计算的时候,让用户从事更多的工作。类似的,第三个线程可以在磁盘上进行周期性的备份工作。

现在考虑另一个多线程发挥作用的例子:一个万维网服务器。对页面的请求发给服务器,而所请求的页面发回给客户机。再多数Web站点上,某些页面较其他页面相比有更多的访问。例如对Sony主页的访问就远远超过对深藏在页面树里的任何特定摄像机的技术说明书页面的访问。利用这一事实,Web服务器可以把获得大量访问的页面集合保存在内存中,避免到磁盘去调入这些页面,从而改善性能。这样的一种页面集合称为高速缓存(cache),高速缓存也运用在其他许多场合中。例如第1章中介绍的CPU缓存。

一种组织Web服务器的方式如图2-8所示。在这里,一个称为分派程序dispatcher)的线程从网络中读入工作请求。在检查请求之后,分派线程挑选一个空转的(即被阻塞的)工作线程worker thread),提交该请求,通常是在每个线程所配有的某个专门字中写入一个消息指针。接着分派线程唤醒睡眠的工作线程,将它从阻塞状态转为就绪状态。
在这里插入图片描述
当工作现场被唤醒之后,它检查有关的请求是否在Web页面高速缓存之中,这个高速缓存是所有线程都可以访问的。如果没有,该线程开始一个从磁盘调入页面的read操作,并且阻塞,直到该磁盘操作完成。当上述线程阻塞在磁盘操作上时,为了完成更多的工作,分派线程可能挑选另一个线程运行,也可能把另一个当前就绪的工作线程投入运行。

这种模型允许把服务器编写为顺序线程的一个集合。在分派线程的程序中包含一个无限循环,该循环用来获得工作请求,并且把工作请求派给工作线程。每个工作线程的代码包含一个从分派线程接收的请求,并且检查Web高速缓存中是否存在所需页面的无限循环。如果存在,就将该页面返回给客户机,接着该工作线程阻塞,等待一个新的请求。如果没有,工作线程就从磁盘调入该页面,将该页面返回给客户机,然后该工作线程阻塞,等待一个新的请求。

图2-9给出了有关代码的大致框架,如同本书的其他部分一样,这里假设TRUE为常数1。另外,buf和page分别是保存工作请求和Web页面的相应结构。
在这里插入图片描述
现在考虑在没有多线程的情形下 ,如何编写Web服务器。一种可能的方式是,使其像一个线程一样 运行。Web服务器的主循环获得请求 ,检查请求 ,并且在取下一个请求之前完成整个工作 。在等待磁盘 操作时,服务器就空转,并且不处理任何到来的其他请求。如果该Web 服务器运行在唯一的机器上,通常情形都是这样 ,那么在等待磁盘操作时CPU只能空转。结果导致每秒钟只有很少的请求被处理。可见 线程较好地改善了Web服务器的性能,而且每个线程是按通常方式顺序编程的 。

到现在为止 ,我们有了两个可能的设计方案 :多线程Web服务器和单线程Web服务器。假设没有多线程可用 ,而系统设计者又认为由于 单线程所造成的性能 降低是不能接受的,那么如果可以使用read 系统调用的非阻塞版本,还存在第三种可能的设计。在请求到来时,这个唯一的线程对请求进行考察。如果该请求能够在高速缓存中得到满足 ,那么一切都好 ,如果不能,则启动一个非阻塞的磁盘操作 。

服务器在表格中记录当前请求的状态 ,然后去处理下一个事件。下一个事件可能是一个新工作的请求,或是磁盘对先前操作的回答 。如果是新工作的请求 ,就开始该工作。如果是磁盘的回答,就从表格中取出对应的信息,并处理该回答 。对于非阻塞磁盘I/O而言,这种回答多数会以信号或中断的形式出现。

在这一设计中 ,前面两个例子中的 “顺序进程” 模型消失了。每次服务器从为某个请求工作的状态切换到另一个状态时,都必须显式地保存或重新装入相应的计算状态。事实上,我们以一种困难的方式 模拟了线程及其堆栈 。这里,每个计算都有一个被保存的状态 ,存在一个会发生且使得相关状态发生改变的事件集合 ,我们把这类设计称为 有限状态机finite-state machine ) 。有限状态机这一概念广泛地应 用在计算机科学中。

现在很清楚多线程必须提供的是什么了。多线程使得顺序进程的思想得 以保留下来 ,这种顺序进程 阻塞了系统调用 (如磁盘I/O ) ,但是仍旧实现了并行性。对系统调用进行阻塞使程序设计变的较为简单, 而且并行性改善了性能。单线程服务器虽然保留了阻塞系统调用的简易性 ,但是却放弃了性能。第三种 处理方法运用了非阻塞调用和中断 ,通过并行性实现 了高性能,但是给编程增加了困难。在图2-10 中绘出了上述模式的总结。

图2-10 构造服务器的三种方法:

序号模型(Model)特性(Characteristics)
1多线程并行性、阻塞系统调用
2单线程进程无并行性、阻塞系统调用
3有限状态机并行性、非阻塞系统调用、中断

有关多线程作用的第三个例子是那些必须处理极大量数据的应用 。通常的处理方式是,读进一块数据,对其处理,然后再写出数据。这里的问题是,如果只能使用阻塞系统调用 ,那么在数据进入和数据输出时,会阻塞进程。在有大量计算需要处理的时候 ,让CPU空转显然是浪费 ,应该尽可能避免 。

多线程提供了一种解决方案,有关的进程可以用一个输入线程、一个处理线程和一个输出线程构造。输入线程把数据读入到输入缓冲区中;处理线程从输入缓冲区中取出数据 ,处理数据 ,并把结果放到输出缓冲区中;输出线程把这些结果写到磁盘上 。按照这种工作方式,输入 、处理和输出可以全部同时进 行。当然,这种模型只有当系统调用只阻塞调用线程而不是阻塞整个进程时 ,才能正常工作。

2.2.2 经典的线程模型(The Classical Thread Model)

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

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

另一个概念是 ,进程拥有一个执行的线程 ,通常简写为线程thread ) 。在线程中有一个程序计数器 , 用来记录接着要执行哪 一条指令。线程拥有寄存器 ,用来保存线程当前的工作变量 。线程还拥有一个堆栈,用来记录执行历史 ,其中每一帧保存了一个已调用的但是还没有从中返回的过程 。尽管线程必须在 某个进程中执行,但是线程和它的进程是不同的概念 ,并且可以分别处理。进程用于把资源集中到一起, 而线程则是在CPU上被调度执行的实体。

线程给进程模型增加了一项内容 ,即在同一个进程环境中 ,允许彼此之间有较大独立性的多个线程 执行。在同一个进程中并行运行多个线程 ,是对在同一台计算机上并行运行多个进程的模拟 。在前一种 情形下 ,多个线程共享同一个地址空间和其他资源。而在后一种情形中 ,多个进程共享物理内存、磁盘、 打印机和其他资源 。由于线程具有进程的某些性质,所以有时被称为轻量级进程 ( lightweight process )。 多线程这个术语 ,也用来描述在同一个进程中允许多个线程的情形 。正如在第1章中看到的 ,一些CPU 已经有直接硬件支持多线程,并允许线程切换在纳秒级完成。

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

当多线程进程在单CPU系统中运行时,线程轮流运行 。从图2-1中,我们已经看到了进程的多道程序设计是如何工作的。通过在多个进程之间来回切换,系统制造了不同的顺序进程并行运行的假象。多线程的工作方式也是类似的。CPU在线程之间的快速切换 ,制造了线程并行运行的假象 ,好似它们在一个比实际CPU慢一些的CPU上同时运行。在一个有三个计算密集型线程的进程中 ,线程以并行方式运行 , 每个线程在一个CPU上得到了真实CPU速度的三分之一 。
在这里插入图片描述
进程中的不同线程不像不同进程之间那样存在很大的独立性 。所有的线程都有完全一样的地址空间 , 这意味着它们也共享同样的全局变量。由于各个线程都可以访问进程地址空间中的每一个内存地址 ,所以一个线程可以读、写或甚至清除另一个线程的堆栈 。线程之间是没有保护的 ,原因是:1)不可能 , 2)也没有必要。这与不同进程是有差别的。不同的进程会来自不同的用户,它们彼此之间可能有敌意。 一个进程总是由某个用户所拥有 ,该用户创建多个线程应该是为了它们之间的合作而不是彼此间争斗 。 除了共享地址空间之外 ,所有线程还共享同一个打开文件集 、子进程 、定时器以及相关信号等 ,如图2-12所示。这样,对于三个没有关系的线程而言 ,应该使用图2-11(a)的结构,而在三个线程实际完成同 一 个作业 ,并彼此积极密切合作的情形中,图2-11(b)则比较合适。

图2-12 第1列给出了在一个进程中所有线程共享的内容 ,第2列给出了每个线程自己的内容:

序号每个进程中的内容(Per-process items)每个线程中的内容(Per-thread items)
1地址空间(Address space)程序计数器(Program counter)
2全局变量(Global variables)寄存器(Registers)
3打开文件(Open files)堆栈(Stack)
4子进程(Child processes)状态(State)
5即将发生的定时器(Pending alarms)
6信号与信号处理程序(Signals and signal handlers)
7账户信息(Accounting infor mation)

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

和传统进程一样 (即只有一个线程的进程),线程可以处于若干种状态的任何一个 :运行、阻塞、 就绪或终止。正在运行的线程拥有 CPU并且是活跃的。被阻塞的线程正在等待某个释放它的事件。例如,当一个线程执行从键盘读入数据 的系统调用时,该线程就被阻塞直到键入了输入为止 。线程可以被阻塞,以便等待某个外部事件的发生或者等待其他线程来释放它 。就绪线程可被调度运行,并且只要轮到它就很快可以运行 。线程状态之间的转换和进程状态之间的转换是一样 的,如图2-2所示 。

认识到每个线程有其自己的堆栈很重要 ,如图2-13所示。每个线程的堆栈有一帧,供各个被调用但是还没有从中返回的过程使用。在该栈帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。例如,如果过程X调用过程Y,而Y又调用Z ,那么当Z执行时 ,供X 、Y和Z使用的栈帧 会全部存在堆栈中。通常每个线程会调用不同的过程 ,从而有一个各自不同的执行历史 ,这就是为什么每个线程需要有自己的堆栈的原因。
在这里插入图片描述
在多线程的情况下 ,进程通常会从当前的单个线程开始。这个线程有能力通过调用 一个库函数 (如 thread_create) 创建新的线程 。thread_create 的参数专门指定了新线程要运行的过程名。这里 ,没有必要 对新线程的地址空间加以规定 ,因为新线程会自动在创建线程的地址空间中运行 。有时,线程是有层次的,它们具有一种父子关系 ,但是,通常不存在这样一种关系 ,所有的线程都是平等的。不t有无层次关系 ,创建线程通常都返回一个线程标识符 ,该标识符就是新线程的名字 。

当一个线程完成工作后 ,可以通过调用一个库过程 ( 如thread_exit ) 退出。该线程接着消失,不再可调度。在某些线程系统中,通过调用一个过程,例如thread_join ,一个线程可以等待一个(特定) 线程退出。这个过程阻塞调用线程直到那个 (特定) 线程退出。在这种情况下,线程的创建和终止非常类 似于进程的创建和终止 ,并且也有着同样的选项。

另一个常见的线程调用是thread_yield ,它允许线程自动放弃CPU从而让另一个线程运行 。这样一个 调用是很重要的,因为不同于进程,(线程库)无法利用时钟中断强制线程让出CPU 。所以设法使线程行为 “高尚” 起来 ,并且随着时间的推移自动交出 CPU ,以便让其他线程有机会运行 ,就变得非常重要 。 有的调用允许某个线程等待另一个线程完成某些任务 ,或等待一个线程宣称它已经完成了 有关的工作等。 通常而言 ,线程是有益的 ,但是线程也在程序设计模式中引入了某种程度的复杂性 。考虑一下 UNIX中的fork 系统调用。如果父进程有多个线程,那么它的子进程也应该拥有这些线程吗 ?如果不是 ,则该子进程可能会工作不正常,因为在该子进程中的线程都是绝对必要的 。

然而 ,如果子进程拥有了与父进程 一样的多个线程 ,如果父进程在read 系统调用 (比如键盘) 上被阻塞了会发生什么情况 ?是两个线程被阻塞在键盘上 (一个属于父进程 ,另一个属于子进程)吗?在键 入一行输入之后 ,这两个线程都得到该输入的副本吗?还是仅有父进程得到该输入的副本?或是仅有子 进程得到?类似的问题在进行网络连接时 也会出现。

另一类问题和线程共享许多数据结构 的事实有关。如果一个线程关闭了某个文件 ,而另一个线程还 在该文件上进行读操作时会怎样 ?假设有一个线程注意到几乎没有内存了,并开始分配更多的内存。在 工作一半的时候,发生线程切换 ,新线程也注意到几乎没有 内存了 ,并且也开始分配更多的内存。这样 , 内存可能会被分配两次 。不过这些问题通过努力是可以解决的。总之,要使多线程的程序正确工作,就 需要仔细思考和设计。

2.2.3 POSIX线程(POSIX Threads)

为实现可移植的线程程序 ,IEEE在IEEE标准1003.lc;中定义了线程的标准。它定义的线程包叫作 pthread 。大部分UNIX 系统都支持该标准。这个标准定义了超过60个函数调用,如果在这里列举一遍就 太多了。这里仅描述一些主要的函数,以说明它是如何工作的。图2-14中列举了这些函数调用 。
所有pthread 线程都有某些特性。每一个都含有一个标识符、一组寄存器 (包括程序计数器) 和一组 存储在结构中 的属性。这些属性包括堆技大小 、调度参数以及其他线程需要的项目。

图2-14 一些pthread 的函数调用:

序号线程调用(Thread call)描述(Description)
1pthread_create()创建一个新线程
2pthread_exit()终止调用的线程
3pthread_join()等待一个特定的线程退出
4pthread_yield()释放CPU来运行另外一个线程
5pthread_attr_init()创建并初始化一个线程的属性结构
6pthread_attr_destroy()删除一个线程的属性结构

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

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

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

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

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

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

为了更好地了解pthread是如何工作的,考虑图2-15提供的简单例子。这里主程序在宣布它的意图之 后,循环NUMBER_OF_THREADS 次,每次创建一个新的线程 。如果线程创建失败 ,会打印出一条错 误信息然后退出。在创建完所有线程之后 ,主程序退出。

图2-15 使用线程的一个例子程序 :

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NUMBER OF THREADS  10
void * print_hello_world(void * tid)
{
	/* This function prints the thread’s identifier and then exits. */
	/* 本函数输出线程的标识符,然后退出. */
	printf("Hello World. Greetings from thread %d\n", tid);
	pthread_exit(NULL);
}
int main(int argc, char * argv[])
{
	/* The main program creates 10 threads and then exits. */
	/* 主程序创建 10个线程,然后退出. */
	pthread_t threads[NUMBER_OF_THREADS];
	int status, i;
	
	for(i=0; i < NUMBER_OF_THREADS; i++) {
		printf("Main here. Creating thread %d\n", i);
		status = pthread_create(&threads[i], NULL, print_hello_world, (void * )i);
		if (status != 0) {
			printf("Oops. pthread create returned error code %d\n", status);
			exit(-1);
		}
	}
	exit(NULL);
}

当创建一个线程时 ,它打印一条一行的发布信息 ,然后退出。这些不同信息交错的顺序是不确定的, 并且可能在连续运行程序的情况下发生变化。

pthread调用不只是前面介绍的这几个 ,还有许多的pthread调用会在讨论 “进程与线程同步” 之后 再介绍。

2.2.4 在用户空间中实现线程(Implementing Threads in User Space)

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

第一种方法是把整个线程包放在用户空间中 ,内核对线程包一无所知。从内核角度考虑 ,就是按正常的方式管理 ,即单线程进程。这种方法第一个也是最明显的优点是,用户级线程包可以在不支持线程 的操作系统上实现 。过去所有的操作系统都属于这个范围,即使现在也有一些操作系统还是不支持线程。通过这一方怯 ,可以用函数库实现线程 。

所有的这类实现都有同样 的通用结构,如图2-16(a)所示。线程在一个运行时系统的上层运行 ,该运行时系统是一个管理线程的过程的集合。前面已经介绍过其中的四个过程:pthread_create,ptbread_exit,pthread_join和pthread_yield。不过 ,一般还会有更多的过程 。

图2-16 (a) 用户级线程包 (b) 由内核管理的线程包 :
在这里插入图片描述
在用户空间管理线程时,每个进程需要有其专用的线程表 (thread table),用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性 ,如每个线程的程序计数器 、堆栈指针、寄存器和状态等 。该线程表由运行时系统管理。当一个线程转换到就绪状态或阻塞状态 时,在该 线程表中存放重新启动该线程所需的信息,与内核在进程表中存放进程的信息完全 一样。

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

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

用户级线程还有另一个优点 。它允许每个进程有自己定制 的调度算法。例如,在某些应用程序中,那些有垃圾收集线程的应用程序就不用担心线程会在不合适的时刻停止,这是一个长处 。用户级线程还具有较好的可扩展性 ,这是因为在 内核空间中内核线程需要一些固定表格空间和堆栈空间 ,如果内核线 程的数量非常大 ,就会出现问题。

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

系统调用可以全部改成非阻塞的(例如,如果没有被缓冲的字符,对键盘的read操作可以只返回0字节) ,但是这需要修改操作系统 ,所以这个办法也不吸引人。而且 ,用户级线程的一个长处就是它可 以在现有的操作系统上运行 。另外,改变read操作的语义需要修改许多用户程序。

在这个过程中 ,还有一种可能的替代方案,就是如果某个调用会阻塞 ,就提前通知。在某些UNIX 版本中,有一个系统调用select可以允许调用者通知预期的read是否会阻塞。若有这个调用 ,那么库过程read就可以被新的操作替代,首先进行select调用 ,然后只有在安全的情形下(即不会阻塞) 才进行 read 调用。如果read调用会被阻塞 ,有关的调用就不进行 ,代之以运行另一个线程 。到了下次有关的运 行系统取得控制权之后 ,就可以再次检查看看现在进行 read调用是否安全。这个处理方法需要重写部分 系统调用库,所以效率不高也不优雅 ,不过没有其他的可选方案了 。在系统调用周围从事检查的这类代 码称为包装器(jacket或wrapper ) 。

与阻塞系统调用问题有些类似的是缺页中断问题,我们将在第3章讨论这些问题 。此刻可以认为 , 把计算机设置成这样一种工作方式 ,即并不是所有的程序都一次性放在内存中 。如果某个程序调用或者 跳转到了一条不在内存的指令上 ,就会发生页面故障 ,而操作系统将到磁盘上取回这个丢失的指令 (和该指令的 “邻居们”) ,这就称为页面故障 。在对所需的指令进行定位和读入时 ,相关的进程就被阻塞 。 如果有一个线程引起页面故障 ,内核由于甚至不知道有线程存在 ,通常会把整个进程阻塞直到磁盘I/O完成为止 ,尽管其他的线程是可以运行的。

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

对线程永久运行问题的一个可能的解决方案是让运行时系统请求每秒一次的时钟信号 (中断) ,但是这样对程序也是生硬和无序的 。不可能总是高频率地发生周期性的时钟中断,即使可能,总的开销也是可观的。而且,线程可能也需要时钟中断,这就会扰乱运行时系统使用 的时钟。

再者 ,也许针对用户级线程的最大负面争论意见是 ,程序员通常在经常发生线程阻塞的应用中才希望使用多个线程。例如,在多线程Web服务器里。这些线程持续地进行系统调用,而一旦发生内核陷阱进行系统调用,如果原有的线程已经阻塞 ,就很难让内核进行线程的切换,如果要让内核消除这种情形 ,就要持续进行select系统调用 ,以便检查read系统调用是否安全 。对于那些基本上是CPU密集型而且极少有阻塞的应用程序而言,使用多线程的目的又何在呢?由于这样的做法并不能得到任何益处 ,所以没有人会真正提出使用多线程来计算前n个素数或者下象棋等一类工作。

2.2.5 在内核中实现线程(Implementing Threads in the Kernel)

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

内核的线程表保存了每个线程的寄存器 、状态和其他信息。这些信息和在用户空间中 (在运行时系统中) 的线程是一样的 ,但是现在保存在内核中。这些信息是传统内核所维护的每个单线程进程信息(即进程状态) 的子集。另外,内核还维护了传统的进程表,以便跟踪进程的状态。

所有能够阻塞线程的调用都以系统调用的形式实现,这与运行时系统过程相比 ,代价是相当可观的。当一个线程阻塞时 ,内核根据其选择 ,可以运行同一个进程中的另一个线程 (若有一个就绪线程) 或者 运行另一个进程中的线程。而在用户级线程中 ,运行时系统始终运行自己进程中 的线程,直到内核剥夺 它的CPU ( 或者没有可运行的线程存在了) 为止。

由于在内核中创建或撤销线程的代价比较大 ,某些系统采取 “环保” 的处理方式,回收其线程。当某个线程被撤销时 ,就把它标志为不可运行的,但是其内核数据结构没有受到影响 。稍后,在必须创建 一个新线程时 ,就重新启动某个旧线程 ,从而节省了一些开销。在用户级线程中线程回收也是可能的 , 但是由于其线程管理的代价很小 ,所以没有必要进行这项工作 。

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

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

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

2.2.6 混合实现(Hybrid Implementations)

人们已经研究了各种试图将用户级线程的优点和内核级线程的优点结合起来的方法 。一种方也是使用内核级线程 ,然后将用户级线程与某些或者全部内核线程多路复用起来 ,如图2-17所示。如果采用这种方法 ,编程人员可以决定有多少个内核级线程和多少个用户级线程彼此多路复用 。这一模型带来最 大的灵活度。

图2-17 用户级线程与内核线程多路复用 :
在这里插入图片描述
采用这种方法 ,内核只识别内核级线程 ,并 对其进行调度 。其中一些内核级线程会被多个用 户级线程多路复用 。如同在没有多线程能力操作 系统中某个进程中的用户级线程一样 ,可以创建、 撤销和调度这些用户级线程 。在这种模型中,每 个内核级线程有 一个可以轮流使用的用户级线程集合。

2.2.7 调度程序激活机制(Scheduler Activations)

尽管内核级线程在一些关键点上优于用户级线程 ,但无可争议的是内核级线程的速度慢 。因此,研究人员一直在寻找在保持其优良特性 的前提下改进其速度的方法 。下面将介绍Anderson 等人 ( 1992) 设 计的一种方法 ,称为调度程序激活 ( scheduler activation ) 机制。Edler等人 ( 1988 ) 以及Scott等人 ( I 990 ) 就相关的工作进行了深入讨论。

调度程序撒活工作的目标是模拟内核线程的功能,但是为线程包提供通常在用户空间中才能实现的 更好的性能和更大的灵活性。特别地,如果用户线程从事某种系统调用时是安全的 ,那就不应该进行专 门的非阻塞调用或者进行提前检查 。无论如何,如果线程阻塞在某个系统调用或页面故障上 ,只要在同 一个进程中有任何就绪 的线程,就应该有可能运行其他 的线程。

由于避免了在用户空间和内核空间之间的不必要转换 ,从而提高了效率。例如,如果某个线程由于 等待另一个线程的工作而阻塞 ,此时没有理由请求内核,这样就减少了内核一用户转换的开销。用户空 间的运行时系统可以阻塞同步的线程而另外调度一个新线程 。

当使用调度程序激活机制时,内核给每个进程安排一定数量的虚拟处理器 ,并且让(用户空间) 运 行时系统将线程分配到处理器上 。这一机制也可以用在多处理器中 ,此时虚拟处理器可能成为真实的 CPU 。分配给一个进程的虚拟处理器的初始数量是一个 ,但是该进程可以申请更多的处理器并且在不用 时退回 。内核也可以取回已经分配出去的虚拟处理器 ,以便把它们分给需要更多处理器的进程 。

使该机制工作的基本思路是 ,当内核了解到一个线程被阻塞之后 (例如,由于执行了一个阻塞系统 调用或者产生了一个页面故障),内核通知该进程的运行时系统 ,并且在堆栈中以参数形式传递有问题 的线程编号和所发生事件的一个描述。内核通过在一个已知的起始地址启动运行时系统 ,从而发出了通 知,这是对UNIX 中信号的一种粗略模拟。这个机制称为上行调用 (upcall ) 。

一旦如此激活 ,运行时系统就重新调度其线程 ,这个过程通常是这样的 :把当前线程标记为阻塞并 从就绪表中取出另一个线程 ,设置其寄存器,然后再启动之。稍后,当内核知道原来的线程又可运行时(例如,原先试图读取的管道中有了数据 ,或者已经从磁盘中读入了故障的页面) ,内核就又一次上行调 用运行时系统 ,通知它这一事件。此时该运行时系统按照自己的判断,或者立即重启动被阻塞的线程 , 或者把它放入就绪表中稍后运行。

在某个用户线程运行的同时发生一个硬件中断时,被中断的CPU切换进内核态。如果被中断的进程 对引起该中断的事件不感兴趣 ,比如,是另一个进程的I/O完成了 ,那么在中断处理程序结束之后,就把被中断的线程恢复到中断之前的状态。不过,如果该进程对中断感兴趣 ,比如,是该进程中的某个线 程所需要的页面到达了,那么被中断的线程就不再启动,代之为挂起被中断的线程。而运行时系统则启 动对应的虚拟CPU ,此时被中断线程的状态保存在堆栈中。随后,运行时系统决定在该CPU上调度哪个线程 :被中断的线程、新就绪的线程还是某个第三种选择。

调度程序激活机制 的一个目标是作为上行调用的信赖基础 ,这是一种违反分层次系统 内在结构的概 念。通常,n层提供n + 1层可调用的特定服务,但是n层不能调用n + 1层中的过程。上行调用并不遵守这个基本原理。

2.2.8 弹出式线程(Pop-Up Threads)

在分布式系统中经常使用 线程。一个有意义的例子是如何处理到来的消息,例如服务请求 。传统的 方法是将进程或线程阻塞在 一个receive 系统调用上 ,等待消息到来。当消息到达时 ,该系统调用接收 消息,并打开消息检查其内容,然后进行处理。

不过,也可能有另一种完全不同的处理方式 ,在该处理方式中 ,一个消息的到达导致系统创建一个处理该消息的线程,这种线程称为弹出式线程 ,如图2- 18 所示。弹出式线程的关键好处是 ,由于这种线程相当新 ,没有历史——没有必须存储的寄存器、堆栈诸如此类的内容,每个线程从全新开始 ,每一个线程彼此之间都完全一样。这样,就有可能快速创建这类线程 。对该新线程指定所要处理的消息。使用 弹出式线程的结果是,消息到达与处理开始之间的时间非常短。

图2-18 在消息到达时创建一个新的线程 :(a) 消息到达之前; (b) 消息到达之后
在这里插入图片描述
在使用弹出式线程之前 ,需要提前进行计划 。例如,哪个进程中的线程先运行?如果系统支持在 内核上下文中运行线程 ,线程就有可能在那里运行( 这是图2-18 中没有画出内核的原因) 。在内核空间中运行弹出式线程通常比在用户空间中容易且快捷 ,而且内核空间中的弹出式线程可以很容易访问所有的 表格和I/O设备 ,这些也许在中断处理时有用。而另一方面 ,出错的内核线程会比出错的用户线程造成更大的损害。例如,如果某个线程运行时间太长,又没有办法抢占它,就可能造成进来的信息丢失。


2.2.9 使单线程代码多线程化(Making Single-Threaded Code Multithreaded)

许多已有的程序是为单线程进程编写的 。把这些程序改写成多线程需要比直接写多线程程序更高的技巧。下面考察一些其中易犯的错误。

先考察代码 ,一个线程的代码就像进程一样,通常包含多个过程,会有局部变量、全局变量和过程参数。局部变量和参数不会引起任何问题 ,但是有一个问题是,对线程而言是全局变量 ,并不是对整个程序也是全局的。有许多变量之所以是全局的,是因为线程中的许多过程都使用它们 (如同它们也可能 使用任何全局变量一样) ,但是其他线程在逻辑上和这些变量无关 。

作为一个例子,考虑由UNIX维护的ermo变量。当进程(或线程) 进行系统调用失败时,错误码会放入errno。在图2-19 中,线程1执行系统调用accesst 人确定是否允许它访问某个特定文件 。操作系统把返回值放到全局变量errno里。当控制权返回到线程1之后 ,并在线程1读取errno之前,调度程序确认线程1此刻已用完CPU时间,并决定切换到线程2。线程2执行一个open调用 ,结果失败,导致重写erroo, 于是给线程1的返回值会永远丢失。随后在线程1执行时,它将读取错误的返回值并导致错误操作 。
在这里插入图片描述
对于这个问题有各种解决方案 。一种解决方案是全面禁止全局变量 。不过这个想法不一定合适 ,因为它同许多已有的软件冲突 。另一种解决方案是为每个线程赋予其私有的全局变量 ,如图2-20所示。在 这个方案中,每个线程有自己的errno以及其他全局变量 的私有副本 ,这样就避免了冲突 。在效果上 ,这个方案创建了新的作用域层 ,这些变量对一个线程中所有过程都是可见的。而在原先的作用域层里 ,变 量只对一个过程可见 ,并在程序中处处可见。

访问私有的全局变量需要有些技巧 ,不过 ,多数程序设计语言具有表示局部变量和全局变量的方式 , 而没有中间的形式。有可能为全局变量分配一块内存,并将它转送给线程中的每个过程作为额外的参数。 尽管这不是一个漂亮的方案 ,但却是一个可用的方案。

还有另一种方案 ,可以引入新的库过程 ,以便创建 、设置和读取这些线程范围的全局变量。首先一 个调用也许是这样的:

create global("bufptr");

该调用在堆上或在专 门为调用线程所保留的特殊存储区上替 一个名为bufptr的指针分配存储空间 。无论 该存储空间分配在何处 ,只有调用线程才可访问其全局变量 。如果另一个线程创建了 同名的全局变量 , 由于它在不同的存储单元上 ,所以不会与已有的那个变量产生冲突 。

访问全局变量需要两个调用:一个用于写入全局变量 ,另一个用于读取全局变量。对于写入,类似有

set global("bufptr", &buf);

它把指针的值保存在先前通过调用create_global创建的存储单元中 。如果要读出一个全局变量,调用的 形式类似于

bufptr = read_global("bufptr");

这个调用返回一个存储在全局变量中 的地址 ,这样就可以访问其中的数据 了。 试图将单一线程程序转为多线程程序 的另一个问题是 ,有许多库过程并不是可重入的。也就是说,它们不是被设计成下列工作方式的:对于任何给定的过程 ,当前面的调用尚没有结束之前 ,可以进行第 二次调用。例如,可以将通过网络发送消息恰当地设计为 ,在库内部的一个固定缓冲区 中进行消息组合 , 然后陷入内核将其发送。但是,如果一个线程在缓冲区 中编好了消息,然后被时钟中断强迫切换到第 二个线程 ,而第二个线程立即用它自己的消息重写了该缓冲区 ,那会怎样呢?

类似的还有内存分配过程 ,例如UNIX中的malloc ,它维护着内存使用情况的关键表格 ,如可用内存块链表 。在malloc 忙于更新表格时 ,有可能暂时处于一种不一致的状态 ,指针的指向不定。如果在表 格处于一种不一致的状态时发生了线程切换 ,并且从一个不同的线程中来了一个新的调用,就可能会由 于使用了一个无效指针从而导致程序崩溃 。要有效解决这些问题意味着重写整个库 ,而这有可能引入一 些微妙的错误,所以这么做是一件很复杂的事情。

另一种解决方案是 ,为每个过程提供一个包装器,该包装器设置一个二进制位从而标志某个库处于 使用中。在先前的调用还没有完成之前 ,任何试图使用该库 的其他线程都会被阻塞。尽管这个方式可以 工作,但是它会极大地降低系统潜在的并行性。

接着考虑信号 。有些信号逻辑上是线程专用的,但是另一些却不是。例如,如果某个线程调用 alarm ,信号送往进行该调用的结程是有意义的 。但是,当线程完全在用户空间实现时 ,内核根本不知道有线程存在 ,因此很难将信号发送给正确的线程。如果一个进程一次仅有一个警报信号等待处理 ,而 其中的多个线程又独立地调用alarm ,那么情况就更加复杂了 。

有些信号,如键盘中断 ,则不是线程专用的。谁应该捕捉它们?一个指定的线程?所有的线程?还是新创建的弹出式结程?进而 ,如果某个线程修改了信号处理程序 ,而没有通知其他线程 ,会出现什么情况?如果某个线程想捕捉 一个特定的信号 (比如,用户击键CTRL+C ) ,而另一个线程却想用这个信 号终止进程 ,又会发生什么情况?如果有一个或多个线程运行标准的库过程以及其他用户编写的过程 , 那么情况还会更复杂 。很显然 ,这些想法是不兼容的。一般而言,在单线程环境中信号已经是很难管理 的了,到了多线程环境中并不会使这一情况变得容易处理 。

由多线程引入的最后一个问题是堆栈的管理。在很多系统中,当一个进程的堆栈溢出时,内核只是自动为该进程提供更多的堆栈。当一个进程有多个线程时 ,就必须有多个堆栈。如果内核不了解所有的堆栈,就不能使它们自动增长,直到造成堆栈出错。事实上,内核有可能还没有意识到内存错误是和某个线程线的增长有关系的。

这些问题当然不是不可克服的,但是却说明了给已有的系统引入线程而不进行实质性的重新设计系统是根本不行的。至少可能需要重新定义系统调用的语义,并且不得不重写库 。而且所有这些工作必须与在一个进程中有一个线程的原有程序向后兼容。有关线程的其他信息,可以参阅Hauser 等人( 1993) 和Marsh等人 (1991 )。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值