20.1 多任务的模型

来源:互联网 发布:java停顿用法 编辑:程序博客网 时间:2024/06/05 15:10

摘录于《Windows程序(第5版,珍藏版).CHarles.Petzold 著》P923

        在 PC 早期,有些人提倡多任务,说那是发展方向。但更多的人则是挠着头不明白:在一台单用户的个人计算机上,多任务能有什么用?可结果是多任务还是用户想要的,尽管他们并不完全明白它究竟是什么。

20.1.1  DOS 下的多任务

        最早的 PC 使用的 Intel 8088 微处理器并不是为多任务而制造的。部分原因是由于内存管理机制的限制。当有多个程序被启动和终止时,常常需要支持多任务的操作系统来搬移内存块,以把空闲内存整合在一起。而在 8088 里要想透明于应用程序的方式来做到这件事情是不可能的。

        DOS 本身也帮不上多大忙,它被设计得很小,而且跟应用程序相对独立。除了加载程序和给它们提供文件系统访问之外,DOS 提供的支持很少。

        但是,富有创新精神的早期 DOS 程序员们却找到了克服这些障碍的方法,他们大部分都使用终止并驻留(Terminate-and-Stay-Resident, TSR)程序。有些 TSR,如打印服务(print spooler),被挂接到硬件时钟中断来进行真正的后台处理。其他的如 SideKick 这样的弹出式工具,能够进行一种任务切换——在弹出程序运行时暂停一个应用。DOS 也被逐步加强来给 TSR 提供支持。

        有些软件供应商曾试图在 DOS 上面加一个外壳(如 Quarterdeck 的 DesqView)来实现任务切换和多任务的环境,但这些实现汇总只有一个最终得到了广泛的市场认可,那,当然就是 Windows。

20.1.2  非抢占式的多任务

        当微软在 1985 年推出 Windows 1.0 时,它是当时用于超越 DOS 局限的一种最复杂的解决方案。那时候,Windows 运行在实模式(real mode)下,尽管这样,它能够在物理内存中移动内存块——多任务的必要条件之一——这是一种对应用程序虽不是很透明,但是基本可以忍受的方式。

        和命令行环境比起来,图形化的窗口环境更容易显现出多任务系统的好处。比如,在标准的 UNIX 命令行下,可以用命令行执行程序,让它们在后台运行。但是,程序的任何屏幕输出必须被重定向到一个文件中,不然,它的输出就会跟用户的其他操作混在一起。

        窗口环境能让多个程序在同一个屏幕下一起运行。切来切去变得很简单,还可以快速地从一个程序移动数据到另一个去;比如把画图程序生成的图片嵌入到一个被字处理程序维护的文本文件中。在 Windows 中,有很多方式支持数据传输,先有剪切板,后来是动态数据交换(Dynamic Data Exchange,DDE),现在是通过对象链接和嵌入(Object Linking and Embedding,OLE)

        然而早期版本的 Windows 中,多任务的实现方式并不是传统的多用户操作系统的抢占式时间分片方式。在这种抢占式方式下,操作系统用一个系统时钟来周期性地中断一个任务和重启另一个任务。16 位的 Windows 支持一种被称作“非抢占式多任务”的方式。这种方式建立在 Windows 的消息处理的体系结构上。在通常情况下,一个 Windows 程序在内存中处于休眠状态,知道它收到一条消息。这些消息经常是直接或间接地由用户通过键盘或鼠标输入引发的。在处理完相应消息后,程序会把控制权返回给 Windows。

        16 位的 Windows 并不随意地根据计时器触发的信息(timer tick)而把控制从一个程序切换到另一个程序。相反,任何的任何切换只发生在一个程序已经处理完一条消息,并且主动把控制返回给了 Windows 之后。这种非抢占式的多任务又叫做“协同多任务”(cooperative multitasking),因为它需要应用程序方面的一些协作。一个 Windows 程序,如果处理一条消息需要很长时间的话,会占用整个系统。

        尽管 16 位的 Windows 通常是非抢占式的多任务,但它也有某些部分使用抢占式多任务。Windows 用抢占式多任务运行 DOS 程序,也让动态链接库接收硬件计时器中断以执行多媒体功能。

        16 位的 Windows 包括了好些功能,可以帮助程序员解决或者至少是能处理非抢占式多任务的局限。这其中人所共知的,当然是鼠标沙漏图标。它当然没有解决任何问题,只是提供了一种方法,让用户知道一个程序正在忙于运行一个需要很长时间的任务,因而系统在未来的一段时间内无法运行其他的任务。另一个部分解决办法是使用 Windows 计时器,它可以让程序定期地接受消息以完成一些周期性的任务。一个典型的应用就是动画。

        另一种解决非抢占式多任务局限的方法是用 PeekMessage 函数调用,就像第 5 章里的 RANDRECT 程序那样。通常一个程序调用 GetMessage 函数从它的消息队列里取得下一条消息。然而,如果消息队列里没有消息的话,GetMessage 在消息出现之前不会返回。PeekMessage 则不同,就算是没有待处理的消息,也会把控制返回给程序。因此,一个程序可以在运行的长时间任务中掺入 PeekMessage 调用。这样,我们既可以在没有新消息的时候保持长时间任务的运行,又可以对新的消息作出及时的反应

20.1.3  PM 和串行消息队列

        微软在与 DOS/Windows 相类似的环境下试图(与 IBM 合作)实现多任务的最初尝试,就是 OS/2 和图形表示管理器(Presentation Manager, PM)。尽管 OS/2 确实支持抢占式多任务,但经常这种抢占式好像并没被加入 PM。这里的问题在于 PM 把用户的键盘和鼠标输入消息串行化了。也就是说 PM 在上一个用户输入消息被完全处理之前,不会把下一个键盘或鼠标的消息发送给程序。

        尽管键盘和鼠标消息只是 PM(或 Windows)程序能接收的很多消息中的一部分,但是绝大部分其他消息都是键盘或鼠标事件的结果。例如,一个菜单命令消息是用户用键盘或鼠标进行菜单选择的结果。在菜单命令消息被处理之后,键盘和鼠标消息才算被完全处理了。

        串行化消息队列的主要原因是为了让用户的“预敲键”和“鼠标预按键”动作可以预料。比如,如果一条键盘或鼠标消息造成输入焦点从一个窗口转到了另一个窗口,那么紧接着的键盘消息就应该送给新输入焦点的窗口。因此,除非是前面的消息已经被处理了,否则系统就不知道该把用户后续输入的消息送给谁。

        现今的常识是不能让一个程序占用整个系统,这样的话就需要一个非串行的消息队列,Windows 32 位的版本支持这个功能。如果一个程序在忙于运行一个长任务,你可以把输入焦点切换到另一个程序。

20.1.4  多线程解决方案

        我一直在谈论 OS/2 图形表示管理器,只是因为它是第一个给资深 Windows 程序员(比如我自己)提供了解多线程的环境。有意思的是,PM 里多线程实现的局限给程序员提供了很重要的线索,那就是应该怎样去设计一个多线程序的架构。尽管这些局限已经在 32 位的 Windows 版本得到了很大的改善,但从以前更具局限性的环境学到经验仍然很有用。所以让我们接着讨论它。

        在一个多线程的环境中,程序能把它们自己分成几块,叫做“执行线程”,它们并行地运行。对线程的支持最终成了解决 PM 中串行化消息队列问题的最佳办法,而且在 Windows 里也有很大作用。

        从代码角度来讲,在程序中线程只是简单地由函数来表示,该函数可能会调用其他函数。一个程序首先执行它的主线程,在传统的 C 程序中它就是 main 函数,在 Windows 中则是 WinMain。一旦执行,程序会进行一个系统调用(CreateThread),给出初始线程函数的名字,从而生成新的执行线程。操作系统在这些线程中作抢占式的控制切换,就像在进程中作控制切换一样。

        在 OS/2 PM 中,每个线程都可以创建或不创建消息队列。一个 PM 线程如果想从该线程中创建窗口的话,就必须创建消息队列。而如果它只是做数据运算或图形输出的话,就不需要创建消息队列。因为没有消息队列的线程并不处理消息,所以它们不会造成系统不反应。唯一的限制就是没有消息队列的线程不能向一个有消息队列的窗口发送消息,也不能调用任何会造成消息发送的函数。(但是它们可以给消息队列线程发送消息。)

        因此,PM 程序员学会了把他们的程序分成一个消息队列线程(用来生成所有的窗口并处理发送给它们的消息)和一个或多个非消息队列线程(用以运行很长的后台任务)。PM 程序员还学到了“1/10 秒规则”。简单来说,他们得到建议,一个消息队列线程处理一条消息的时候不应该花费 1/10 秒以上的时间任何超过这个时间的处理都应该在另一个线程中完成。如果所有程序员都遵循这个规则,一个 PM 程序顶多只能让系统不反应 1/10 秒。

20.1.5  多线程架构

        我说过,PM 的局限为程序员怎样在图形环境下运行的程序中使用多个执行线程提供了重要线索。我建议你的程序架构应该像这样主线程创建程序所需要的所有窗口,包括这些窗口的所有窗口过程,并处理这些窗口的消息。任何其他的线程都应该是简单的后台运算。除了通过与主线程通信外,它们不和用户打交道

        你可以这样来理解:主线程处理用户输入(和其他消息),在此过程中或许会生成其他的二级线程。这些额外的线程处理跟用户不相关的任务。

        也就是说,你的程序的主线程是“州长”,而你的其他二级线程是州长的“职员”。州长把所有的大任务都分派给他的职员,并保持和外界的联系。二级线程,因为都是职员,没法举行他们的记者招待会。他们小心地完成他们的工作,向州长汇报,并等着被分派新任务。

        在一个特定的程序中,所有的线程都是同一个进程的一部分,因此它们共享进程的资源,如内存及打开的文件等。因为线程共享程序的内存,所以它们也共享静态变量。然而,每个线程都有它自己的堆栈,所以每个线程的自动变量都是唯一的。每个线程也有它自己的处理器状态(和数学协处理器状态),这些在线程切换时会被自动保存和恢复。

20.1.6  线程的麻烦

        要正确地设计、编码和调试一个复杂的多线程应用程序,显然是一个 Windows 程序员会碰到的最困难的工作之一。因为一个抢占式多任务系统能够在任一点中断一个线程,把控制切换到另一个线程,因此这两个线程之间的任何不正常交互可能并不明显,而且可能只是偶尔才出现,看起来更像是随机的。

        多线程程序中的一个常见的缺陷叫做“竞态条件”。当程序员假定一个线程会完成某件事——比如准备一些数据——并且是在另一个线程需要这些数据之前时,就会发生这种情况。为了帮助协调线程活动,操作系统需要各种形式的同步。其中一种叫信号灯(Semaphore),能让程序员在代码的某一特定点上,暂停一个线程的执行,直到另一个线程发信号让它继续。跟信号灯相似的还有“临界区”(Critical Section),它们指的是不能被中断的代码段。

        但信号灯又会造成另一种常见的跟线程相关的缺陷,叫做“死锁”。它发生在当两个线程已经中断了彼此的运行,但是它们只有继续进行才能解除对方被中断的运行的时候。

        幸运的是,对某些与线程相关的问题,32 位的程序比 16 位的程序更有免疫力。比如,假设一个线程执行以下的简单语句:

lCount++;
其中 lCount 是一个长的 32 位的全局变量,它被其他的线程使用。在一个 16 位的程序中,这一条 C 语句被编译为两条机器代码指令,第一条对低 16 位加 1,第二条把任何进位加到高 16 位中。假如操作系统在这两条机器代码指令中中断了线程。如果 lCount 在第一条机器代码指令之前时 0x0000FFFF,bane lCount 在线程被中断时会是 0,这也会是另一个线程看到的值。只有在线程恢复执行后,lCount 才会被增加到它的正确值 0x00010000。

        像这样的缺陷,它们极少情况下才会造成操作性的错误,因此在测试中几乎不会被发现。在 16 位的程序中,要想解决这个问题,正确的方式是把语句放在临界区里,在其中线程不能被中断。在 32 位程序中这条语句却没有问题,因为它会被编译为一条机器代码指令。

20.1.7  Windows 的好处

        32 位的 Windows(包括 Windows NT 和 Windows 98)有一个非串行的消息队列。这个实现看起来很好:如果一个程序在花很长时间处理一条消息,那么当鼠标在该程序的窗口上时,鼠标指针会显示为沙漏,而当它在另一个程序的窗口上时,就变成正常的箭头了。简单的单击就能把另外的这个窗口调到前台。

        然而,用户仍然不能在运行这个大任务的窗口工作,因为这个大任务阻止了程序接收其他消息。这不是我们想要的。一个程序应该总是能接收消息,而这常常就需要用到二级线程

        在 Windows NT 和 Windows 98 中,没有消息队列线程和非消息队列线程的区分。每个线程在被生成时都有它自己的消息队列。这样就减少了 PM 程序中关于线程的一些别扭的规矩。(然而,在绝大多数情况下,你应该在一个线程中通过消息过程处理输入,而把长时间运行的任务转给其他不带窗口的线程。我们很快就会看到,这种结构几乎总是最合理的。)

        还有更多的好消息:Windows NT 和 Windows 98 有一个函数能让一个线程终止同一个进程中的另一个线程。当你开始写多线程的代码时,你会发现这有时候很方便。OS/2 的早期版本没有包括一个“杀掉线程”的函数。

        最后一个好消息(至少在当前话题下)是 Windows NT 和 Windows 98 实现了一种叫做“线程本地存储”(Thread Local Storage,TLS)的东西。要想理解它,还记得我早前提到过的静态变量吗?静态变量不管是全局的还是局部的,都被线程共享,因为它们位于进程的数据内存空间。而在函数中自动变量总是局部的,因为它们占据堆栈空间,而每个线程都有它自己的堆栈,所以对每个线程来说它们是独立的。

        让两个或多个线程使用同一个函数,但是每个线程使用它们自己的静态变量,这有时候会很方便。这种线程内的独特的静态变量就叫做线程本地存储。它的使用牵涉到一些 Windows 函数调用,但微软也对 C 编译器进行了扩展,让 TLS 的使用对程序员更透明。

20.1.8  新的!改进过的!加了线程的!

        我已经说明了线程的好处,现在让我们来好好审视一下这个主题。有时候程序员总是倾向于使用操作系统能提供的每一个功能。但最糟糕的要数,你的老板跑到你的办公桌旁说“我听说这个新的什么什么很热门,让我们在程序中也集成一些什么什么吧。”然后你要花上一周的时间来琢磨这个什么什么对应用程序有没有好处,以及怎样才能用得上它。

        我的主要意思是说,如果应用程序不需要多线程的话,把它加进去根本就没道理。如果你的应用程序显示沙漏指针的时间太长都让人烦了,或者是它现在通过调用 PeekMessage 来避免沙漏指针,那么重新组织你的程序来用多线程是个好主意。否则,你只是在给自己找麻烦,还有可能在代码中引入新的缺陷。

        在有些情况下,沙漏指针可能会更合适。我早先提到过 1/10 秒原则。把一个大文件加载到内存会花多于 1/10 秒的时间。那这是不是意味着我们要把文件加载的过程用一个单独的线程来实现呢?没必要。当用户指示程序打开一个文件时,通常他想让操作马上完成。把文件加载过程放到独立的线程只会增加开销。就算是你跟朋友吹嘘你写了多线程的程序,这也不值得

0 0