2.2 线程

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

2.2.1 线程的使用

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

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

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

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

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

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

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

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

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

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

如果我们已经做到了这一步,那么为什么不再进一步增加一个线程呢?许多字处理软件都有每隔若干分钟自动在磁盘上保存整个文件的特点,用于避免由于程序崩溃、系统崩溃或电源故障而造成用户一整天的工作丢失的情况。第三个线程可以处理磁盘备份,而不必干扰其他两个线程。拥有三个线程的情形,如图2-7所示。

阅读 ‧ 电子书库
图 2-7 有三个线程的字处理软件

如果程序是单线程的,那么在进行磁盘备份时,来自键盘和鼠标的命令就会被忽略,直到备份工作完成为止。用户当然会认为性能很差。另一个方法是,为了获得好的性能,可以让键盘和鼠标事件中断磁盘备份,但这样却引入了复杂的中断驱动程序设计模型。如果使用三个线程,程序设计模型就很简单了。第一个线程只是和用户交互;第二个线程在得到通知时进行文档的重新格式化;第三个线程周期性地将RAM中的内容写到磁盘上。

很显然,在这里用三个不同的进程是不能工作的,这是因为三个线程都需要在同一个文件上进行操作。通过让三个线程代替三个进程,三个线程共享公共内存,于是它们都可以访问同一个正在编辑的文件。

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

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

一种组织Web服务器的方式如图2-8所示。在这里,一个称为分派程序(dispatcher)的线程从网络中读入工作请求。在检查请求之后,分派线程挑选一个空转的(即被阻塞的)工作线程(worker thread),提交该请求,通常是在每个线程所配有的某个专门字中写入一个消息指针。接着分派线程唤醒睡眠的工作线程,将它从阻塞状态转为就绪状态。

阅读 ‧ 电子书库
图 2-8 一个多线程的Web服务器

在工作线程被唤醒之后,它检查有关的请求是否在Web页面高速缓存之中,这个高速缓存是所有线程都可以访问的。如果没有,该线程开始一个从磁盘调入页面的read操作,并且阻塞直到该磁盘操作完成。当上述线程阻塞在磁盘操作上时,为了完成更多的工作,分派线程可能挑选另一个线程运行,也可能把另一个当前就绪的工作线程投入运行。

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

图2-9给出了有关代码的大致框架。如同本书的其他部分一样,这里假设TRUE为常数1。另外,buf和page分别是保存工作请求和Web页面的相应结构。

阅读 ‧ 电子书库
图 2-9 对应图2-8的代码概要:a)分派线程;b)工作线程

现在考虑在没有多线程的情形下,如何编写Web服务器。一种可能的方式是,使其像一个线程一样运行。Web服务器的主循环获得请求,检查请求,并且在取下一个请求之前完成整个工作。在等待磁盘操作时,服务器就空转,并且不处理任何到来的其他请求。如果该Web服务器运行在惟一的机器上,通常情形都是这样,那么在等待磁盘操作时CPU只能空转。结果导致每秒钟只有很少的请求被处理。可见线程较好地改善了Web服务器的性能,而且每个线程是按通常方式顺序编程的。

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

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

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

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

阅读 ‧ 电子书库
图 2-10 构造服务器的三种方法

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

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