第二十章 多任务和多线程(多任务的各种模式)

来源:互联网 发布:田径女神走红网络 编辑:程序博客网 时间:2024/06/05 10:39

多任务是一个操作系统可以同时执行多个程序的能力。基本上,操作系统使用一个硬件时钟为同时执行的每个程序配置「时间片段」。如果时间片段够小,并且机器也没有由于太多的程序而超出负荷时,那么在使用者看来,所有的这些程序似乎在同时执行着。

多任务并不是什么新的东西。在大型计算机上,多任务是必然的。这些大型主机通常有几十甚至几百个终端机和它连结,而每个终端机使用者都应该感觉到他或者她独占了整个计算机。另外,大型主机的操作系统通常允许使用者「提交工作到背景」,这些背景作业可以在使用者进行其它工作时,由机器执行完成。

个人计算机上的多任务花了更长的时间才普及化。但是现在PC多任务也被认为是很正常的了。我马上就会讨论到,Microsoft Windows的16位版本支持有限度的多任务,Windows的32位版本支持真正的多任务,而且,还多了一种额外的优点,多线程。

多线程是在一个程序内部实作多任务的能力。程序可以把它自己分隔为各自独立的「线程」,这些线程似乎也同时在执行着。这一概念初看起来似乎没有什么用处,但是它可以让程序使用多执行绪在背景执行冗长作业,从而让使用者不必长时间地无法使用其计算机进行其它工作(有时这也许不是人们所希望的,不过这种时候去冲冲凉或者到冰箱去看看总是很不错的)!但是,即使在计算机繁忙的时候,使用者也应该能够使用它。

多任务的各种模式

在PC的早期,有人曾经提倡未来应该朝多任务的方向前进,但是大多数的人还是很迷惑:在一个单使用者的个人计算机上,多任务有什么用呢?好了,最后事实表示即使是不知道这一概念的使用者也都需要多任务的。

DOS下的多任务

在最初PC上的Intel 8088微处理器并不是为多任务而设计的。部分原因(我在 上一章中讨论过)是内存管理不够强。当启动和结束多个程序时,多任务的操作系统通常需要移动内存块以收集空闲内存。在8088上是不可能透明于应用系统来做到这一点的。

DOS本身对多任务没有太大的帮助,它的设计目的是尽可能小巧,并且与独立于应用程序之外,因此,除了加载程序以及对程序提供文件系统的存取功能,它几乎没有提供任何支持。

不过,有创意的程序写作者仍然在DOS的早期就找到了一种克服这些缺陷的方法,大多数是使用常驻(TSR:terminate-and-stay-resident)程序。有些TSR,比如背景打印队列程序等,透过拦截硬件时钟中断来执行真正的背景处理。其它的TSR,诸如SideKick等弹出式工具,可以执行某种型态的工作切换-暂停目前的应用程序,执行弹出式工具。DOS也逐渐有所增强以便提供对TSR的支持。

一些软件厂商试图在DOS之上架构出工作切换或者多任务的外壳程序(shell)(诸如Quarterdeck的DesqView),但是在这些环境中,仅有其中一个占据了大部分市场,当然,这就是Windows。

非优先权式的多任务

当Microsoft在1985年发表Windows 1.0时,它是最成熟的解决方案,目的是突破DOS的局限。Windows在实际模式下执行。但是即使这样,它已可以在物理内存中移动内存块。这是多任务的前提,虽然移动的方法尚未完全透明于应用程序,但是几乎可以忍受了。

在图形窗口环境中,多任务比在一种命令列单使用者操作系统中显得更有意义。例如,在传统的命令列UNIX中,可以在命令列之外执行程序,让它们在背景执行。然而,程序的所有显示输出必须被重新转向到一个文件中,否则输出将和使用者正在做的事情混在一起。

窗口环境允许多个程序在相同屏幕上一起执行,前后切换非常容易,并且还可以快速地将数据从一个程序移动到另一个程序中。例如,将绘图程序中建立的图片嵌入由文书处理程序编辑的文本文件中。在Windows中,以多种方式支持数据转移,首先是使用剪贴簿,后来又使用动态数据交换(DDE),而现在则是透过对象连结和嵌入(OLE)。

不过,早期Windows的多任务实作还不是多使用者操作系统中传统的优先权式的分时多任务。这些操作系统使用系统时钟周期性地中断一个工作并开始另一个工作。Windows的这些16位版本支持一种被称为「非优先权式的多任务」,由于Windows消息驱动的架构而使这种型态的多任务成为可能。通常情况下,一个Windows程序将在内存中睡眠,直到它收到一个消息为止。这些消息通常是使用者的键盘或鼠标输入的直接或间接结果。当处理完消息之后,程序将控制权返回给Windows。

Windows的16位版本不会绝对地依据一个timer tick将控制权从一个Windows程序切换到另一个,任何的工作切换都发生在当程序完成对消息的处理后将控制权返回给Windows时。这种非优先权式的多任务也被称为「合作式的多任务」,因为它要求来自应用程序方面的一些合作。一个Windows程序可以占用整个系统,如果它要花很长一段时间来处理消息的话。

虽然非优先权式的多任务是16位Windows的一般规则,但仍然出现了某些形式的优先权式多任务。Windows使用优先权式多任务来执行DOS程序,而且,为了实作多媒体,还允许动态链接库接收硬件时钟中断。

16位Windows包括几个功能特性来帮助程序写作者解决(或者,至少可以说是对付)非优先权式多任务中的局限,最显著的当然是时钟式鼠标光标。当然,这并非一种解决方案,而仅仅是让使用者知道一个程序正在忙于处理一件冗长作业,因而让使用者在一段时间内无法使用系统。另一种解决方案是Windows定时器,它允许程序周期性地接收消息并完成一些工作。定时器通常用于时钟应用和动画。

针对非优先权式多任务的另一种解决方案是PeekMessage函数呼叫,我们曾在第五章中的RANDRECT程序里看到过。一个程序通常使用GetMessage呼叫从它的消息队列中找寻下一个消息,不过,如果在消息队列中没有消息,那么GetMessage不会传回,一直到出现一个消息为止。而另一方面,PeekMessage将控制权传回程序,即使没有等待的消息。这样,一个程序可以执行一个冗长作业,并在程序代码中混入PeekMessage呼叫。只要没有这个程序或其它任何程序的消息要处理,那么这个冗长作业将继续执行。

Presentation Manager和序列化的消息队列

Microsoft在一种半DOS/半Windows的环境下实作多任务的第一个尝试(和IBM合作)是OS/2和Presentation Manager(缩写成PM )。虽然OS/2明确地支持优先权式多任务,但是这种多任务方式似乎并未在Presentation Manager中得以落实。问题在于PM序列化来自键盘和鼠标的使用者输入消息。这意味着,在前一个使用者输入消息被完全处理以前,PM不会将一个键盘或者鼠标消息传送给程序。

尽管键盘和鼠标消息只是一个PM(或者Windows)程序可以接收的许多消息中的几个,大多数的其它消息都是键盘或者鼠标事件的结果。例如,菜单命令消息是使用者使用键盘或者鼠标进行菜单选择的结果。在处理菜单命令消息时,键盘或者鼠标消息并未完全被处理。

序列化消息队列的主要原因是允许使用者的预先「键入」键盘按键和预先「按入」鼠标按钮。如果一个键盘或者鼠标消息导致输入焦点从一个窗口切换到另一个窗口,那么接下来的键盘消息应该进入拥有新的输入焦点的窗口中去。因此,系统不知道将下一个使用者输入消息发送到何处,直到前一个消息被处理完为止。

目前的共识是不应该让一个应用系统有可能占用整个系统,而这需要非序列化的消息队列,32位版本的Windows支持这种消息队列。如果一个程序正在忙着处理一项冗长作业,那么您可以将输入焦点切换到另一个程序中。

多线程解决方案

我讨论OS/2的Presentation Manager,只是因为它是第一个为早期的Windows程序写作者(比如我自己)介绍多线程的环境。有趣的是,PM实作多线程的局限为程序写作者提供了应该如何架构多线程程序的必要线索。即使这些限制在32位的Windows中已经大幅减少,但是从更有限的环境中学到的经验仍然是非常有效的。因此,让我们继续讨论下去。

在一个多线程环境中,程序可以将它们自己分隔为同时执行的片段(叫做执行绪)。对执行绪的支持是解决PM中存在的序列化消息队列的最好方法,并且在Windows中线程有更实际的意义。

就程序代码来说,一个线程简单地被表示为可能呼叫程序中其它函数的函数。程序从其主线程开始执行,这个主执行绪是在传统的C程序中叫做main的函数,而在Windows中是WinMain。一旦执行起来,程序可以通过在系统呼叫CreateThread中指定初始线程函数的名称来建立新的线程的执行。操作系统在执行绪之间优先权式地切换控件,和它在程序之间切换控制权的方法非常类似。

在OS/2的Presentation Manager中,每个线程可以建立一个消息队列,也可以不建立。如果希望从线程建立窗口,那么一个PM线程必须建立消息队列。否则,如果只是进行许多的数据处理或者图形输出,那么线程不需要建立消息队列。因为无消息队列的程序不处理消息,所以它们将不会当住系统。唯一的限制是一个无消息队列线程无法向一个消息队列线程中的窗口发送消息,或者呼叫任何发送消息的函数(不过,它们可以将消息递送给消息队列线程)。

这样,PM程序写作者学会了如何将它们的程序分隔为一个消息队列线程(在其中建立所有的窗口并处理传送给窗口的消息)和一个或者多个无消息队列线程,在其中执行冗长的背景工作。PM程序写作者还了解到「1/10秒规则」,大体上,程序写作者被告知,一个消息队列线程处理任何消息都不应该超过1/10秒,任何花费更长时间的事情都应该在另一个线程中完成。如果所有的程序写作者都遵循这一规则,那么将没有PM程序会将系统当住超过1/10秒。

多线程架构

我已经说过PM的限制让程序写作者理解如何在图形环境中执行的程序里头使用多个执行绪提供了必要的线索。因此在这里我将为您的程序建议一种架构:您的主执行绪建立您程序所需要的所有窗口,并在其中包含所有的窗口消息处理程序,以便处理这些窗口的所有消息;所有其它执行绪只进行一些背景处理,除了和主执行绪通讯,它们不和使用者进行交流。

可以把这种架构想象成:主线程处理使用者输入(和其它消息),并建立程序中的其它线程,这些附加的线程完成与使用者无关的工作。

换句话说,您程序的主线程是一个老板,而您的其它线程是老板的职员。老板将大的工作丢给职员处理,而他自己保持和外界的联系。因为那些线程仅仅是职员,所以其它线程不会举行它们自己的记者招待会。它们会认真地完成自己的工作,将结果报告给老板,并等待他们的下一个任务。

一个程序中的线程是同一程序的不同部分,因此他们共享程序的资源,如内存和打开的文件。因为线程共享程序的内存,所以他们还共享静态变量。然而,每个线程都有他们自己的堆栈,因此动态变量对每个线程是唯一的。每个线程还有各自的处理器状态(和数学协处理器状态),这个状态在进行线程切换期间被储存和恢复。

线程间的「争吵」

正确地设计、写作和测试一个复杂的多线程应用程序显然是Windows程序写作者可能遇到的最困难的工作之一。因为优先权式多任务系统可以在任何时刻中断一个线程,并将控制权切换到另一个线程中,在两个线程之间可能有无法预料的随机交互作用的情况。

多线程程序中的一个常见的错误被称为「竞争状态(race condition)」,这发生在程序写作者假设一个线程在另一个线程需要某资料之前已经完成了某些处理(如准备数据)的时候。为了帮助协调线程的活动,操作系统要求各种形式的同步。一种是同步信号(semaphore),它允许程序写作者在程序代码中的某一点阻止一个线程的执行,直到另一个执行绪发信号让它继续为止。类似于同步信号的是「临界区域(critical section)」,它是程序代码中不可中断的部分。

但是同步信号还可能产生称为「死锁(deadlock)」的常见线程错误,这发生在两个线程互相阻止了另一个的执行,而继续执行的唯一办法又是它们继续向前执行。

幸运的是,32位程序比16位程序更能抵抗线程所涉及的某些问题。例如,假定一个线程执行下面的简单叙述:

lCount++ ;

其中lCount是由其它线程使用的一个32位的long型态变量,C中的这个叙述被编译为两条机械码指令,第一条将变量的低16位加1,而第二条指令将任何可能的进位加到高16位上。假定操作系统在这两个机械码指令之间中断了线程。如果lCount在第一条机械码指令之前是0x0000FFFF,那么lCount在线程被中断时为0,而这正是另一个线程将看到的值。只有当线程继续执行时,lCount才会增加到正确的值0x00010000。

这是那些偶尔会导致操作问题的错误之一。在16位程序中,解决此问题正确的方法是将叙述包含在一个临界区域中,在这期间线程不会被中断。然而,在一个32位程序中,该叙述是正确的,因为它被编译为一条机械码指令。

Windows的好处

32位Windows版本(包括Windows NT和Windows 98)有一个非序列化的消息队列。这种实作似乎非常好:如果一个程序正在花费一段长时间处理一个消息,那么鼠标位于该程序的窗口上时,鼠标光标将呈现为一个时钟,但是当将鼠标移到另一个程序的窗口上时,鼠标光标将变为正常的箭头形状。只需按一下就可以将另一个窗口提到前面来。

然而,使用者仍然不能使用正在处理大量工作的那个程序,因为那些工作会阻止程序接收其它消息,这不是我们所希望的。一个程序应该总是能随时处理消息的,所以这时就需要使用从属线程了。

在Windows NT和Windows 98中,没有消息队列线程和无消息队列线程的区别,每个线程在建立时都会有它自己的消息队列,从而减少了PM程序中关于线程的一些不便规定(然而,在大多数情况下,您仍然想通过一条专门处理消息的线程中的消息程序处理输入,而将冗长作业交给那些不包含窗口的线程处理,这种结构几乎总是最容易理解的,我们将看到这一点)。

还有更好的事情:Windows NT和Windows 98中有个函数允许线程杀死同一程序中的另一个线程。当您开始编写多线程程序代码时,您将会发现这种功能在有时是很方便的。OS/2的早期版本没有「杀死线程」的函数。

最后的好消息(至少对这里的话题是好消息)是Windows NT和Windows 98实作了一些被称为「线程区域储存空间(TLS:thread local storage)」的功能。为了了解这一点,回顾一下我在前面提到过的,静态变量(对一个函数来说,既是整体又是区域变量)在线程之间是被共享的,因为它们位于程序的数据储存空间中。动态变量(对一个函数来说总是区域变量)对每一个线程则是唯一的,因为它们占据堆栈上的空间,而每个线程都有它自己的堆栈。

有时让两个或多个线程使用相同的函数,而让这些线程使用唯一于线程的静态变量,那会带来很大便利。这就是线程区域储存空间,其中涉及一些Windows函数呼叫,但是Microsoft还为C编译器进行扩展,使线程区域储存空间的使用更透明于程序写作者。

新改良过的!支持多线程了!

既然已经介绍了线程的现状,让我们来展望一下线程的未来。有时,有人会出现一种使用操作系统所提供的每一种功能特性的冲动。最坏的情况是,当您的老板走到您的桌前并说:「我听说这种新功能非常炫,让我们在自己的程序中用一些这种新功能吧。」然后您将花费一个星期的时间,试图去了解您的应用程序如何从这种新功能获益。

应该注意的是,在并不需要多线程的应用系统中加入多线程是没有任何意义的。如果您的程序显示沙漏光标的时间太长,或者如果它使用PeekMessage呼叫来避免沙漏光标的出现,那么请重新规划您的程序架构,使用多线程可能会是一个好主意。其它情形,您是在为难您自己,并可能会在程序代码中产生新的错误。

在某些情况下,沙漏光标的出现可能是完全适当的。我在前面提到过「1/10秒规则」,而将一个大文件加载内存可能会花费多于1/10秒的时间,这是否意味着文件加载例程应该在分离的线程中实作呢?没有必要。当使用者命令一个程序打开文件时,他或者她通常想立即完成该操作。将文件加载例程放在分离的线程中只会增加额外的负担。即使您想向您的朋友夸耀您在编写多线程程序,也完全不值得这样做!

原创粉丝点击