windows核心编程笔记(6-10)

第6章线程的基础知识

理解线程是非常关键的,因为每个进程至少需要一个线程。与进程内核对象一样,线程内核对象也拥有属性,本章要介绍许多用于查询和修改这些属性的函数。此外还要介绍可以在进程中创建和生成更多的线程时所用的函数。
第4章介绍了进程是由两个部分构成的,一个是进程内核对象,另一个是地址空间。同样,线程也是由两个部分组成的:
• 一个是线程的内核对象,操作系统用它来对线程实施管理。内核对象也是系统用来存放线程统计信息的地方。
• 另一个是线程堆栈,它用于维护线程在执行代码时需要的所有函数参数和局部变量。

第4章中讲过,进程是不活泼的。进程从来不执行任何东西,它只是线程的容器。线程总是在某个进程环境中创建的,而且它的整个寿命期都在该进程中。这意味着线程在它的进程地址空间中执行代码,并且在进程的地址空间中对数据进行操作。因此,如果在单进程环境中,你有两个或多个线程正在运行,那么这两个线程将共享单个地址空间。这些线程能够执行相同的代码,对相同的数据进行操作。这些线程还能共享内核对象句柄,因为句柄表依赖于每个进程而不是每个线程存在。

如你所见,进程使用的系统资源比线程多得多,原因是它需要更多的地址空间。为进程创建一个虚拟地址空间需要许多系统资源。系统中要保留大量的记录,这要占用大量的内存。另外,由于. e x e和. d l l文件要加载到一个地址空间,因此也需要文件资源。而线程使用的系统资源要少得多。实际上,线程只有一个内核对象和一个堆栈,保留的记录很少,因此需要很少的内存。
由于线程需要的开销比进程少,因此始终都应该设法用增加线程来解决编程问题,而要避免创建新的进程。但是,这个建议并不是一成不变的。许多程序设计用多个进程来实现会更好些。应该懂得权衡利弊,经验会指导你的编程实践。

在详细介绍线程之前,首先花一点时间讲一讲如何正确地在应用程序结构中使用线程。

6.1 何时创建线程

线程用于描述进程中的运行路径。每当进程被初始化时,系统就要创建一个主线程。该线程与C / C + +运行期库的启动代码一道开始运行,启动代码则调用进入点函数( m a i n、w m a i n、Wi n M a i n或w Wi n M a i n),并且继续运行直到进入点函数返回并且C / C + +运行期库的启动代码调用E x i t P r o c e s s为止。对于许多应用程序来说,这个主线程是应用程序需要的唯一线程。不过,进程能够创建更多的线程来帮助执行它们的操作。
一个简单的例子就是,We b浏览器可以在后台与它们的服务器进行通信。因此,在来自当前We b站点的结果输入之前,用户可以缩放浏览器的窗口或者转到另一个We b站点。
设计一个拥有多线程的应用程序,就会扩大该应用程序的功能。我们在下一章中可以看到,每个线程被分配了一个C P U。因此,如果你的计算机拥有两个C P U,你的应用程序中有两个线程,那么两个C P U都将处于繁忙状态。实际上,你是让两个任务在执行一个任务的时间内完成操作。

6.2 何时不能创建线程

线程确实是非常有用的,但是,当使用线程时,在解决原有的问题时可能产生新的问题。例如,你开发了一个文字处理应用程序,并且想要让打印函数作为它自己的线程来运行。这听起来是个很好的主意,因为用户可以在打印文档时立即回头着手编辑文档。但是,这意味着文档中的数据可能在文档打印时变更。也许最好是不要让打印操作在它自己的线程中发生,不过这种“方案”看起来有点儿极端。如果你让用户编辑另一个文档,但是锁定正在打印的文档,使得打印结束前该文档不能修改,那将会怎样呢?这里还有第三种思路,将文档拷贝到一个临时文件,然后打印该临时文件的内容,并让用户修改原始文档。当包含该文档的临时文件结束打印时,删除临时文件。
本节的实质就是提醒大家应该慎重地使用多线程。不要想用就用。仅仅使用赋予进程的主线程,就能够编写出许多非常有用的和功能强大的应用程序。

6.3 编写第一个线程函数

每个线程必须拥有一个进入点函数,线程从这个进入点开始运行。前面已经介绍了主线程的进入点函数:即m a i n、w m a i n、Wi n M a i n或w Wi n M a i n。如果想要在你的进程中创建一个辅助线程,它必定也是个进入点函数,类似下面的样子:
DWORD WINAPI THREADFUNC(PVOID PVParam)
{ DWORD dwresult=0;

Return(dwresult);
}
下面对线程函数的几个问题作一说明:
• 主线程的进入点函数的名字必须是m a i n、w m a i n、Wi n M a i n或w Wi n M a i n,与这些函数不同的是,线程函数可以使用任何名字。
• 由于给你的主线程的进入点函数传递了字符串参数,因此可以使用A N S I / U n i c o d e版本的进入点函数: m a i n / w m a i n和Wi n M a i n / w Wi n M a i n。
• 线程函数必须返回一个值,它将成为该线程的退出代码。
• 线程函数(实际上是你的所有函数)应该尽可能使用函数参数和局部变量。

下面讲述如何让操作系统来创建能够执行线程函数的线程。

6.4 CreateThread函数

如果想要创建一个或多个辅助函数,只需要让一个已经在运行的线程来调用C r e a t e T h r e a d:当C r e a t e T h r e a d被调用时,系统创建一个线程内核对象。该线程内核对象不是线程本身,而是操作系统用来管理线程的较小的数据结构。可以将线程内核对象视为由关于线程的统计信息组成的一个小型数据结构。这与进程和进程内核对象之间的关系是相同的。系统从进程的地址空间中分配内存,供线程的堆栈使用。新线程运行的进程环境与创建线程的环境相同。因此,新线程可以访问进程的内核对象的所有句柄、进程中的所有内存和在这个相同的进程中的所有其他线程的堆栈。这使得单个进程中的多个线程确实能够非常容易地互相通信。注意C r e a t e T h r e a d函数是用来创建线程的Wi n d o w s函数。不过,如果你正在编写C / C + +代码,决不应该调用C r e a t e T h r e a d。相反,应该使用Visual C++运行期库函数_ b e g i n t h r e a d e x。如果不使用M i c r o s o f t的Visual C++编译器,你的编译器供应商有它自己的C r e a t e T h r e d替代函数。不管这个替代函数是什么,你都必须使用。本章后面将要介绍_ b e g i n t h r e a d e x能够做什么,它的重要性何在。
这就是Create Thread函数的概述,下面各节将要具体介绍C r e a t e T h r e a d的每个参数。

6.5 终止线程的运行

若要终止线程的运行,可以使用下面的方法:
• 线程函数返回(最好使用这种方法)。
• 通过调用E x i t T h r e a d函数,线程将自行撤消(最好不要使用这种方法)。
• 同一个进程或另一个进程中的线程调用Te r m i n a t e T h r e a d函数(应该避免使用这种方法)。
• 包含线程的进程终止运行(应该避免使用这种方法)。
始终都应该将线程设计成线程函数返回的形式,即当想要线程终止运行时,它们就能够返回。这是确保所有线程资源被正确地清除的唯一办法。
如果线程能够返回,就可以确保下列事项的实现:
• 在线程函数中创建的所有C + +对象均将通过它们的撤消函数正确地撤消。
• 操作系统将正确地释放线程堆栈使用的内存。
• 系统将线程的退出代码(在线程的内核对象中维护)设置为线程函数的返回值。
• 系统将递减线程内核对象的使用计数。

第7章 线程的调度、优先级和亲缘性


抢先式操作系统必须使用某种算法来确定哪些线程应该在何时调度和运行多长时间。在这一章中,我们将要介绍Microsoft Windows 98和Windows 2000使用的一些算法。
在第6章中,我们介绍了每个线程是如何拥有一个上下文结构的,这个结构维护在线程的内核对象中。这个上下文结构反映了线程上次运行时该线程的CPU寄存器的状态。每隔20毫秒左右,Windows要查看当前存在的所有线程内核对象。在这些对象中,只有某些对象被视为可以调度的对象。Windows选择可调度的线程内核对象中的一个,将它加载到CPU的寄存器中,它的值是上次保存在线程的环境中的值。这项操作称为上下文转换。Windows实际上保存了一个记录,它说明每个线程获得了多少个运行机会。这时,线程正在执行代码,并对它的进程的地址空间中的数据进行操作。再过20毫秒左右,Windows就将CPU的寄存器重新保存到线程的上下文中。线程不再运行。系统再次查看其余的可调度线程内核对象,选定另一个线程的内核对象,将该线程的上下文加载到CPU的寄存器中,然后继续运行。当系统引导时,便开始加载线程的上下文,让线程运行,保存上下文和重复这些操作,直到系统关闭。这就是系统对线程进行调度的过程。请记住,你无法保证你的线程总是能够运行,也不能保证你的线程能够得到整个进程,并且无法保证其他线程不允许运行,等等。

我想强调这样一个概念,即系统只调度可以调度的线程,但是实际情况是,系统中的大多数线程是不可调度的线程。例如,如果你运行Notepad,但是并不键入任何数据。那么Notepad的线程就没有什么事情要做。系统不给无事可做的线程分配CPU时间。

7.1 暂停和恢复线程的运行
在线程内核对象的内部有一个值,用于指明线程的暂停计数。当你调用CreateProcess或CreateThread函数时,就创建了线程的内核对象,并且它的暂停计数被初始化为1。这可以防止线程被调度到CPU中。当然,这是必须的,因为线程的初始化需要时间,你不希望在系统做好充分的准备之前就开始执行线程。
当线程完全初始化好了之后,CreateProcess或CreateThread要查看你是否已经传递CREATE_SUSPENDED标志。如果你已经传递了这个标志,那么这些函数就返回,同时新线程处于暂停状态。如果你尚未传递该标志,那么该函数将线程的暂停计数递减为0。当线程的暂停计数是0的时候,除非线程正在等待其他某种事情的发生,否则该线程就处于可调度状态。
在暂停状态中创建一个线程,你就能够在线程有机会执行任何代码之前改变线程的运行环境(如优先级,本章后面将要介绍)。一旦你改变了线程的环境,你必须使线程成为可调度线程。如果要进行这项操作,你可以调用ResumeThread,再将调用CreateThread函数时返回的线程句柄传递给它.当你创建线程时,除了使用CREATE_SUSPENDED外,你也可以调用SuspendThread函数来暂停线程的运行。

7.2 暂停和恢复进程的运行
对于Windows来说,不存在暂停或恢复进程的概念,因为进程从来不会被安排获得CPU时间。但是, Windows确实允许一个进程暂停另一个进程中的所有线程的运行,但是从事暂停操作的进程必须是个调试程序。特别是,进程必须调用WaitForDebugEvent和ContinueDebugEvent之类的函数。
虽然你无法创建绝对完美的SuspendProcess函数,但是你可以创建一个该函数的实现代码,它能够在许多条件下出色地运行。收中介绍了SuspendProcess函数的实现代码。

7.3 睡眠方式
线程也能告诉系统,它不想在某个时间段内被调度。这是通过调用Sleep函数来实现的:
VOID Sleep(DWORD dwMilliseconds);
该函数可使线程暂停自己的运行,直到dwMilliseconds过去为止。
关于Sleep函数,有下面几个重要问题值得注意:
* 调用Sleep,可使线程自愿放弃它剩余的时间片。
* 系统将在大约的指定毫秒数内使线程不可调度。
* 你可以调用Sleep,并且为dwMilliseconds参数传递INFINITE。这将告诉系统永远不要调度该线程。这不是一件值得去做的事情。最好是让线程退出,并还原它的堆栈和内核对象。
* 你可以将0传递给Sleep。这将告诉系统,调用线程将释放剩余的时间片,并迫使系统调度另一个线程。但是,系统可以对刚刚调用Sleep的线程重新调度。如果不存在多个拥有相同优先级的可调度线程,就会出现这种情况。

7.4 转换到另一个线程
系统提供了一个称为SwitchToThread的函数,它使得另一个可调度线程能够运行。当你调用这个函数的时候,系统要查看是否存在一个迫切需要CPU时间的线程。如果没有线程迫切需要CPU时间,SwitchToThread就会立即返回。如果存在一个迫切需要CPU时间的线程,SwitchToThread就对该线程进行调度(该线程的优先级可能低于调用SwitchToThread的线程)。这个迫切需要CPU时间的线程可以运行一个时间段,然后系统调度程序照常运行。
该函数允许一个需要资源的线程强制另一个优先级较低、而目前却拥有该资源的线程放弃该资源。如果调用SwitchToThread函数时没有其他线程能够运行,那么该函数返回FALSE,否则返回一个非0值。
调用SwitchtoThread函数与调用Sleep是相似的,并且传递给它一个0毫秒的超时。差别是SwitchToThread允许优先级较低的线程运行。即使低优先级线程迫切需要CPU时间,Sleep也能够立即对调用线程重新进行调度。

* Windows 98没有配备该函数的非常有用的实现代码。

7.5 线程的运行时间
有时你想要计算线程执行某个任务需要多长的时间。还好Windows提供了一个称为GetThreadTimes的函数,它能返回这些信息:
BOOL GetThreadTimes(
Handle HPROCESS;
Pfiletime Pftcreationtime;
Pfiletime Pftexittime;
Pfiletime Pftkeneltime;
Pfiletime Pftusertime;
)

GetThreadTimes函数返回4个不同的时间值,这些值如下表所示。

时间值           含义
创建时间         用英国格林威治时间1601年1月1日午夜后100ns的时间间隔表
                 示的英国绝对值,用于指明线程创建的时间。
退出时间         用英国格林威治时间1601年1月1日午夜后100ns的时间间隔表
                 示的英国绝对值,用于指明线程退出的时间。如果线程仍然在运行,
                 退出时间则未定义。
内核时间         一个相对值,用于指明线程执行操作系统代码已经经过了多少个100ns的CPU时间。
用户时间         一个相对值,用于指明线程执行应用程序代码已经经过了多少个100ns的CPU时间。
                
请注意,GetProcessTimes是个类似GetThreadTimes的函数,它适用于进程中的所有线程:GetProcessTimes返回的时间适用于某个进程中的所有线程(甚至是已经终止运行的线程)。例如,返回的内核时间是所有进程的线程在内核代码中经过的全部时间的总和。

遗憾的是GetThreadTimes和GetProcessTimes这两个函数在Windows 98中不起作用。在Windows 98中,没有一个可靠的机制可供应用程序来确定线程或进程已经使用了多少CPU时间。

对于高分辨率的显示来说,GetThreadTimes不是太好。书中提到了两个高分辨率性能函数。

7.6 综合运用上下文环境
现在你应该懂得上下文结构在线程调度中所起的重要作用了。上下文结构使得系统能够记住线程的状态,这样,当下次线程拥有可以运行的CPU时,它就能够找到它上次中断运行的地方。
Windows允许你查看线程内核对象的内部情况,以便抓取它当前的一组CPU寄存器。若要进行这项操作,你只需要调用GetThreadContext函数。
在调用GetThreadContext函数之前,你应该调用SuspendThread,否则,线程可能被调度,而且线程的上下文可能与你收回的不同。一个线程实际上有两个上下文。一个是用户方式,一个是内核方式。GetThreadContext只能返回线程的用户方式上下文。如果你调用SuspendThread来停止线程的运行,但是该线程目前正在用内核方式运行,那么,即使SuspendThread实际上尚未暂停该线程的运行,它的用户方式仍然处于稳定状态。但是,线程在恢复用户方式之前,它无法执行更多的用户方式代码,因此你可以放心地将线程视为处于暂停状态,并且GetThreadContext函数将能正常运行。

Windows使你能够修改CONTEXT结构中的成员,然后通过调用SetThreadContext将新寄存器值放回线程的内核对象中。同样,你修改其上下文的线程应该首先暂停,否则其结果将无法预测。
GetThreadContext和SetThreadContext函数使你能够对线程进行许多方面的控制,但是你在使用它们时应该小心。实际上,调用这些函数的应用程序根本就不多。增加这些函数是为了增强调试程序和其他工具的功能。不过任何应用程序都可以调用它们。
我将在第24章中更加详细地介绍CONTEXT结构。

7.7 线程的优先级
在本章的开头,我讲述了CPU是如何只使线程运行20毫秒,然后调度程序将另一个可调度的线程分配给CPU的。如果所有线程具有相同的优先级,那么就会发生这种情况,但是,在现实环境中,线程被赋予许多不同的优先级,这会影响到调度程序将哪个线程取出来作为下一个运行的线程。
每个线程都会被赋予一个从0(最低)到31(最高)的优先级号码。当系统要确定将哪个线程分配给CPU时,它首先观察优先级为31的线程,并以循环方式对它们进行调度。如果优先级为31的线程可以调度,那么就将该线程赋予一个CPU。在该线程的时间片结束时,系统要查看是否还有另一个优先级为31的线程可以运行,如果有,它将允许该线程被赋予一个CPU。
只有优先级为31的线程才可以调度,系统将绝对不会将优先级为0到30的线程分配给CPU。这种情况称为渴求调度(starvation)。当高优先级线程使用如此多的CPU时间,从而使得低优先级线程无法运行时,便会出现渴求情况。
现在我想提醒你注意一个问题。高优先级线程将抢在低优先级线程之前运行,不管低优先级线程正在运行什么。
顺便我要指出,当系统引导时,它会创建一个特殊的线程,称为0页线程。该线程被赋予优先级0,它是整个系统中唯一的一个在优先级0上运行的线程。当系统中没有任何线程需要执行操作时,0页线程负责将系统中的所有空闲RAM页面置0。

7.8 对优先级的抽象说明
Windows支持6个优先级类:即空闲,低于正常,正常,高于正常,高和实时。当然,正常优先级是最常用的优先级类,99%的应用程序均使用这个优先级类。下面这个表描述了这些优先级类。

       优先级类       描述
       实时           进程中的线程必须立即对事件作出响应,以便执行关键时间的任
                      务。该进程中的线程还会抢先于操作系统组件之前运行。使用本
                      优先级类时必须极端小心。
       高             进程中的线程必须立即对事件作出响应,以便执行关键时间的任
                      务。Task Manager(任务管理器)在这个类上运行,因此用户可
                      以撤消脱离控制的进程。
       高于正常       进程中的线程在正常优先级与高优先级之间运行(这是Windows
                      2000中的新优先级类)。
       正常           进程中的线程没有特殊的调度需求。
       低于正常       进程中的线程在正常优先级与空闲优先级之间运行(这是Windows
                      2000中的新优先级类)。
       空闲           进程中的线程在系统空闲时运行。该进程通常由屏幕保护程序或
                      后台实用程序和搜集统计数据的软件使用。

当然,大多数进程都属于正常优先级类。低于正常和高于正常的优先级类是Windows 2000中的新增优先级。Microsoft增加这些优先级类的原因是,有若干家公司抱怨现有的优先级类无法提供足够的灵活性。
一旦你选定了应该优先级类之后,你就不必考虑你的应用程序如何与其他应用程序之间的关系,而只需要集中考虑你的应用程序中的各个线程。Windows甚至支持7个相对的线程优先级:即空闲,最低,低于正常,正常,高于正常,最高,和关键时间优先级。这些优先级是相对于进程的优先级类而言的。同样,大多数线程都使用正常线程优先级。下面这个表描述了这些相对的线程优先级。

     相对线程优先级     描述
     关键时间优先级     对于实时优先级类来说,线程在优先级31上运行,
                        对于其他优先级类来说,线程在优先级15上运行。
     最高优先级         线程在高于正常优先级的上两级上运行。
     高于正常优先级     线程在正常优先级的上一级上运行。
     正常优先级         线程在进程的优先级类上正常运行。
     低于正常优先级     线程在低于正常优先级的下一级上运行。
     最低优先级         线程在低于正常优先级的下二级上运行。
     空闲               对于实时优先级类来说,线程在优先级16上运行
                        对于其他优先级类来说,线程在优先级1上运行。

概括起来说,你的进程是优先级类的一个组成部分,你为进程中的线程赋予相对线程优先级。你将会注意到,我还没有讲到关于0到31的优先级的任何情况。应用程序开发人员从来不必具体设置优先级。相反,系统负责将进程的优先级类和线程的相对优先级映射到一个优先级上。正是这种映射方式,Microsoft不想拘泥不变。实际上这种映射方式是随着系统的版本的升级而变化的。
下面这个表显示了这种映射方式是如何用于Windows 2000的。

     相对线程                低于               高于
     优先级        空闲      正常      正常     正常       高       实时
    
     关键时间      15        15        15       15         15       31
     最高          6         8         10       12         15       26
     高于正常      5         7         9        11         14       25
     正常          4         6         8        10         13       24
     低于正常      3         5         7        9          12       23
     最低          2         4         6        8          11       22
     空闲          1         1         1        1          1        16

请注意,上面这个表并没有显示优先级的等级为0的线程。这是因为0优先级保留供零页线程使用,系统不允许任何其他线程拥有0优先级。另外,下列优先级等级是无法使用的:17,18,19,20,21,27,28,29,和30。如果你编写一个以内核方式运行的设备驱动程序,你可以获得这些优先级等级,而用户方式的应用程序则不能。另外请注意,实时优先级类中的线程不能低于优先级等级16。同样,非实时优先级类中的线程的等级不能高于15。

说明            有些人常常搞不清进程优先级类的概念。他们认为这可能意味着进程是
            可以调度的。但是进程是根本不能调度的,只有线程才能被调度。进程优先
            级类是个抽象概念,Microsoft提出这个概念的目的,是为了帮助你将它与调
            度程序的内部运行情况区分开来。它没有其他目的。

说明            一般来说,大多数时候高优先级的线程不应该处于可调度状态。当线程
            要进行某种操作时,它能迅速获得CPU时间。这时线程应该尽可能少地执行
            CPU指令,并返回睡眠状态,等待再次变成可调度状态。相反,低优先级的
            线程可以保持可调度状态,执行大量的CPU指令来进行它的操作。如果你按
            照这些原则来办,整个操作系统就能正确地对用户作出响应。

7.9 程序的优先级
那么进程是如何被赋予优先级类的呢?当你调用CreateProcess时,你可以在fdwCreate参数中传递需要的优先级类。下面这个表显示了优先级类的标识符。

       优先级类           标识符
       实时               REALTIME_PRIORITY_CLASS
       高                 HIGH_PRIORITY_CLASS
       高于正常           ABOVE_NORMAL_PRIORITY_CLASS
       正常               NORMAL_PRIORITY_CLASS
       低于正常           BELOW_NOMAL_PRIORITY_CLASS
       空闲               IDLE_PRIORITY_CLASS

创建子进程的进程负责选择子进程运行的优先级类,这看起来有点奇怪。让我们以Explorer为例来说明这个问题。当你使用Explorer来运行一个应用程序时,新进程按正常优先级运行。Explorer不知道进程在做什么,也不知道隔多长时间它的线程需要进行调度。但是,一旦子进程运行,它就能够通过调用SetPriorityClass来改变它自己的优先级类。
你可以使用Start命令加一个开关来设定应用程序的起始优先级。例如,在命令外壳输入的下面这个命令: C:/START /LOW CALC.EXE可使系统启动Calculator,并在开始时按空闲优先级来运行它.Start命令还能识别/BELOWNORMAL,/NORMAL,/ABOVENORMAL,/HIGH和/REALTIME等开关,以便按它们各自的优先级启动执行一个应用程序。当然,一旦应用程序启动运行,它就可以调用SetPriorityClass函数,将它自己的优先级改为它选择的任何优先级。
当一个线程刚刚创建时,它的相对线程优先级总是设置为正常优先级。我总感到有些奇怪,CreateThread没有为调用者提供一个设置新线程的相对优先级的方法。若要设置和获得线程的相对优先级,你必须调用SetThreadPriority这个函数。
SetThreadPriority(
Handle hThread;
INT nPriority;


当然,hThread参数用于标识你想要改变优先级的单个线程,nPriority参数是下表列出的7个标识符之一。

线程相对优先级      标识符常量
关键时间优先级      THREAD_PRIORITY_TIME_CRITICAL
最高优先级          THREAD_PRIORITY_HIGHEST
高于正常优先级      THREAD_PRIORITY_ABOVE_NORMAL
正常优先级          THREAD_PRIORITY_NORMAL
低于正常优先级      THREAD_PRIORITY_BELOW_NORMAL
最低优先级          THREAD_PRIORITY_LOWEST
空闲优先级          THREAD_PRIORITY_IDLE

Windows还提供了检索线程的相对优先级的补充函数:GetThreadPriority。

             见原书P237的程序(2)

该函数返回上表列出的标识符之一。
若要创建一个带有相对优先级为空闲的线程,你可以执行类似下面的代码:

说明            Windows没有提供返回线程的优先级的函数。这种省略是故意的。请记
            住,Microsoft保留了随时修改调度算法的权利。你不应该设计需要调度算法
            的专门知识的应用程序。如果你坚持使用进程优先级类和相对线程优先级,
            你的应用程序不仅现在能够顺利地运行,而且在系统的将来版本上也能很好
            地运行。

7.9.1 动态提高线程的优先级等级
通过将线程的相对优先级与线程的进程优先级类综合起来考虑,系统就可以确定线程的优先级等级。有时这称为线程的基本优先级等级。系统常常要提高线程的优先级等级,以便对窗口消息或读取磁盘等某些I/O事件作出响应。
请注意,线程的当前优先级等级决不会低于线程的基本优先级等级。
系统只能为基本优先级等级在1至15之间的线程提高其优先级等级。实际上这是因为这个范围称为动态优先级范围。此外,系统决不会将线程的优先级等级提高到实时范围(高于15)。由于实时范围中的线程能够执行大多数操作系统的函数,因此给等级的提高规定一个范围,就可以防止应用程序干扰操作系统的运行。另外,系统决不会动态提高实时范围内的线程优先级等级。
有些编程人员抱怨说,系统动态提高线程优先级等级的功能对他们的线程性能会产生一种不良的影响,为此Microsoft增加了下面两个函数,这样你就能够使系统的动态提高线程优先级等级的功能不起作用:SetProcessPriorityBoost负责告诉系统激活或停用进行中的所有线程的优先级提高功能,而SetThreadPriorityBoost则让你激活或停用各个线程的优先级提高功能。

另一种情况也会导致系统动态地提高线程的优先级等级。比如有一个优先级为4的线程准备运行,但是却不能运行,因为一个优先级为8的线程正连续被调度。在这种情况下,优先级为4的线程就非常渴望得到CPU时间。当系统发现一个线程在大约3至4秒钟内一直渴望得到CPU时间,它就将这个渴望得到CPU时间的线程的优先级动态提高到15,并让该线程运行两倍于它的时间量。当到了两倍时间量的时候,该线程的优先级立即返回到它的基本优先级。

7.9.2 为前台进程调整调度程序
当用户对进程的窗口进行操作时,该进程就称为前台进程,所有其他进程则称为后台进程。当然,用户希望他正在使用的进程比后台进程具有更强响应性的行为特性。为了提高前台进程的响应特性,Windows能够为前台进程中的线程调整其调度算法。对于Windows 2000来说,系统可以为前台进程的线程提供比通常多的CPU时间量。这种调整只能在前台进程属于正常优先级类的进程时才能进行。如果它属于其他任何优先级类,就无法进行任何调整。
Windows 2000实际上允许用户对这种调整进行相应的配置。在System Priorities(系统属性)对话框的Advanced选项卡上,用户可以单击Performance Options(性能选项)按钮,打开对话框。如果用户选择优化应用程序的性能,系统就执行配置的调整。如果用户选择优化后台服务程序的性能,系统就不进程调整。
Windows 98没有提供允许用户配置这种调整手段的任何用户界面。
将进程改为前台进程的原因是,使它们能够对用户的输入更快地作出响应。

7.10 亲缘性
按照默认设置,当系统将线程分配给处理器时,Windows 2000使用软亲缘性来进行操作。这意味着如果所有其他因素相同的话,它将设法在它上次运行的那个处理器上运行线程。让线程留在单个处理器上,有助于重复使用仍然在处理器的内存高速缓存中的数据。
有一种新的计算机结构,称为NUMA(非统一内存访问),在该结构中,计算机包含若干块插件板,每个插件板上有4个CPU和它自己的内存区。下面这个插图显示了一台配有3块插件板的计算机,总共有12个CPU,这样,任何一个线程都可以在12个CPU中的任何一个CPU上运行。
当CPU访问的内存是它自己的插件板上的内存时,NUMA系统运行的性能最好。如果CPU需要访问位于另一个插件板上的内存时,就会产生巨大的性能降低。在这样的环境中,就需要来自一个进程中的线程在CPU 0至3上运行,让另一个进程中的线程在CPU 4至7上运行,依次类推。为了适应这种计算机结构的需要,Windows 2000允许你设置进程和线程的亲缘性。换句话说,你可以控制哪个CPU能够运行某些线程。这称为硬亲缘性。
请注意,子进程可以继承进程的亲缘性。
为此windows提供了设置亲缘性的函数。SetProcessAffinityMask。 当然,还有一个函数能够返回进程的亲缘性位屏蔽,它就是GetProcessAffinityMask。
有时你可能想要将进程中的一个线程限制到一组CPU上去运行。可以通过调用SetThreadAffinityMask,你就能为各个线程设置亲缘性屏蔽。
最后作以下几点说明:
(1)Windows98 无论计算机中实际拥有多少个CPU,Windows 98只使用一个CPU。
(2)在大多数环境中,改变线程的亲缘性就会影响调度程序有效地在各个CPU之间移植线程的能力,而这种能力可以最有效地使用CPU时间。
(3)有时强制将一个线程分配给特定的CPU的做法是不妥当的。
(4)当Windows 2000在x86计算机上引导时,你可以限制系统能够使用的CPU的数量。只要在Boot.ini文件的[operating systems]栏改动如下示:
multi(0)disk(0)rdisk(0)partition(1)/WINDOWS="Microsoft Windows XP Home Edition" /fastdetect /noguiboot /NumProcs=1

第8章 用户方式中线程的同步

当所有的线程在互相之间不需要进行通信的情况下就能够顺利地运行时,Microsoft Windows的运行性能最好。但是,线程很少能够在所有的时间都独立地进行操作。通常情况下,要生成一些线程来处理某个任务。当这个任务完成时,另一个线程必须了解这个情况。
线程需要在下面两种情况下互相进行通信:
* 当你有多个线程访问共享资源而不使资源被破坏时
* 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时

Windows提供了许多方法,可以非常容易地实现线程的同步。

8.1 原子访问:互锁的函数家族
线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。书中例举了一个简单例子说明互锁函数的重要性。(祥见书)。那么互锁函数是如何运行的呢?答案要取决于你运行的是何种CPU平台。对于x86家族的CPU来说,互锁函数会对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址。在Alpha平台上,互锁函数能够执行下列操作:
1. 打开CPU中的一个特殊的位标志,并注明被访问的内存地址。
2. 将内存的值读入一个寄存器。
3. 修改该寄存器。
4. 如果CPU中的特殊位标志是关闭的,则转入第二步。否则,特殊位标志仍然是打开的,寄存器的值重新存入内存。

你也许会问,执行第4步时CPU中的特殊位标志是如何关闭的呢?答案是:如果系统中的另一个CPU试图修改同一个内存地址,那么它就能够关闭CPU的特殊位标志,从而导致互锁函数返回第二步。

下面是书中介绍的几个互锁函数:InterlockedExchangeAdd、InterlockedExchange和InterlockedExchangePointer,InterlockedCompareExchange,InterlockedCompareExchangePointer。InterlockedExchange和InterlockedExchangePointer能够以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值。如果是32位应用程序,两个函数都能用另一个32位值取代一个32位值。但是,如果是个64位应用程序,那么InterlockedExchange能够取代一个32位值,而InterlockedExchangePointer则取代64位值。两个函数都返回原始值。当你实现一个循环锁时,InterlockedExchange是非常有用的。
你应该避免在单个CPU计算机上使用循环锁。如果一个线程正在循环运行,它就会浪费前一个CPU时间,这将防止另一个线程修改该值。
循环锁认为,受保护的资源总是被访问较短的时间。这使它能够更加有效地循环运行,然后转为内核方式并进入等待状态。许多编程人员循环运行一定的次数(比如400次),如果对资源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待(不消耗CPU时间),直到该资源变为可供使用为止。这就是关键部分实现的方法。
循环锁在多处理器计算机上非常有用,因为当一个线程循环运行的时候,另一个线程可以在另一个CPU上运行。但是,即使在这种情况下,你也必须小心。你不应该让线程循环运行太长的时间,也不能浪费更多的CPU时间。

8.2 高速缓存行
如果你想创建一个能够在多处理器计算机上运行的高性能应用程序,你必须懂得CPU的高速缓存行。当一个CPU从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。高速缓存行由32或64个字节组成(视CPU而定),并且始终在第32个字节或第64个字节的边界上对齐。高速缓存行的作用是为了提高CPU运行的性能。通常情况下,应用程序只能对一组相邻的字节进行处理。如果这些字节在高速缓存中,那么CPU就不必访问内存总线,而访问内存总线需要多得多的时间。
但是,在多处理器环境中,高速缓存行使得内存的更新更加困难。所以最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个CPU访问这些数据(使用线程亲缘性)。如果你采取其中的一种方法,你就能够完全避免高速缓存行的各种问题。

8.3 高级线程同步
CPU时间非常宝贵,决不应该浪费。因此我们需要一种机制,使线程在等待访问共享资源时不浪费CPU时间。方法如下:
当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用一个操作系统函数,给它传递一些参数,以指明该线程正在等待什么。如果操作系统发现资源可供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程保持可调度状态。如果资源不能使用,或者特殊事件还没有发生,那么系统便使该线程处于等待状态,使该线程无法调度。这可以防止线程浪费CPU时间。当线程处于等待状态时,系统作为一个代理,代表你的线程来执行操作。系统能够记住你的线程需要什么,当资源可供使用的时候,便自动使该线程退出等待状态,该线程的运行将与特殊事件实现同步。

8.4 关键代码段
关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,我是指该代码知道没有别的线程要访问该资源。当然,系统仍然能够抑制你的线程的运行,而抢先安排其他线程的运行。不过,在你的线程退出关键代码段之前,系统将不给想要访问相同资源的其他任何线程进行调度。作者举了个形象的例子(上厕所)来说明这个问题。

注意    最难记住的一件事情是,你编写的需要使用共享资源的任何代码都必须封装在EnterCriticalSection和LeaveCriticalSection函数中。如果你忘记将代码封装在一个位置,共享资源就可能遭到破坏。
              
最后要注意的几点:
(1)InitializeCriticalSection函数的运行可能失败。
(2)每个共享资源使用一个CRITICAL_SECTION变量。
(3)同时访问多个资源时防止死锁状态发生。
(4)不要长时间运行关键代码段。

第9章 线程与内核对象的同步

上一章介绍了如何使用允许线程保留在用户方式中的机制来实现线程同步的方法。用户方式同步的优点是它的同步速度非常快。但是它也有其局限性。对于许多应用程序来说,这种机制是不适用的。本章将要介绍如何使用内核对象来实现线程的同步。你将会看到,内核对象机制的适应性远远优于用户方式机制。实际上,内核对象机制的唯一不足之处是它的速度比较慢。
内核对象中的每种对象都可以说是处于已通知或未通知的状态之中。这种状态的切换是由M i c r o s o f t为每个对象建立的一套规则来决定的。内核对象总是在未通知状态中创建的。比如,当进程正在运行的时候,进程内核对象处于未通知状态,当进程终止运行的时候,它就变为已通知状态。内核对象中是个布尔值,当对象创建时,该值被初始化为FA L S E(未通知状态)。当进程终止运行时,操作系统自动将对应的对象布尔值改为T R U E,表示该对象已经得到通知。当进程等待的对象处于未通知状态中时,这些进程不可调度。但是一旦对象变为已通知状态,进程看到该标志变为可调度状态,并且很快恢复运行。

9.1 等待函数
等待函数可使线程自愿进入等待状态,直到一个特定的内核对象变为已通知状态为止。这些等待函数中最常用的是WaitForSingleObject 和WaitForMultipleObjects。两个函数很相似,区别在于后者允许调用线程同时查看若干个内核对象(最多64个)的已通知状态。

9.2 成功等待的副作用
对于有些内核对象来说,成功地调用WaitForSingleObject 和WaitForMultipleObjects,实际上会改变对象的状态。成功地调用是指函数发现对象已经得到通知并且返回一个相对于WAIT_OBJECT_0的值。如果函数返回WA I T _ T I M E O U T或WA I T _ FA I L E D,那么调用就没有成功。如果函数调用没有成功,对象的状态就不可能改变。当一个对象的状态改变时,我称之为成功等待的副作用。例如,有一个线程正在等待自动清除事件对象(本章后面将要介绍)。当事件对象变为已通知状态时,函数就会发现这个情况,并将WA I T _ O B J E C T _ 0返回给调用线程。但是就在函数返回之前,该事件将被置为未通知状态,这就是成功等待的副作用。

9.3 事件内核对象
在所有的内核对象中,事件内核对象是个最基本的对象。它们包含一个使用计数(与所有内核对象一样),一个用于指明该事件是个自动重置的事件还是一个人工重置的事件的布尔值,另一个用于指明该事件处于已通知状态还是未通知状态的布尔值。
事件能够通知一个操作已经完成。有两种不同类型的事件对象。一种是人工重置的事件,另一种是自动重置的事件。当人工重置的事件得到通知时,等待该事件的所有线程均变为可调度线程。当一个自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线程。
当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件使用得最多。事件初始化为未通知状态,然后,当该线程完成它的初始化操作后,它就将事件设置为已通知状态。这时,一直在等待该事件的另一个线程发现该事件已经得到通知,因此它就变成可调度线程。这第二个线程知道第一个线程已经完成了它的操作。

9.4 等待定时器内核对象
等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象。它们通常用来在某个时间执行某个操作。若要创建等待定时器,只需要调用CreateWaitableTimer函数。当然,进程可以获得它自己的与进程相关的现有等待定时器的句柄,方法是调用OpenWaitableTimer函数。
等待定时器对象总是在未通知状态中创建。必须调用SetWaitableTimer函数来告诉定时器你想在何时让它成为已通知状态。
除了上面介绍的定时器函数外,最后还有一个CancelWaitableTimer函数。这个简单的函数用于取出定时器的句柄并将它撤消,这样,除非接着调用SetWaitableTimer函数以便重新设置定时器,否则定时器决不会进行报时。如果想要改变定时器的报时条件,不必在调用SetWaitableTimer函数之前调用CancelWaitableTimer函数。每次调用SetWaitableTimer函数,都会在设置新的报时条件之前撤消定时器原来的报时条件。

9.4.1 让等待定时器给A P C项排队
Microsoft还允许定时器在定时器得到通知信号时调用SetWaitableTimer函数的线程的异步过程调用(A P C)进行排队。一般来说,当调用SetWaitableTimer函数时,你将同时为pfnCompletionRoutine和pvArgCompletionRoutine参数传递N U L L。当SetWaitableTimer函数看到这些参数的N U L L时,它就知道,当规定的时间到来时,就向定时器发出通知信号。但是,如果到了规定的时间,你愿意让定时器给一个A P C排队,那么你必须传递定时器A P C例程的地址,而这个例程是你必须实现的。
最后要说明的是,线程不应该等待定时器的句柄,也不应该以待命的方式等待定时器。通常没有理由使用带有等待定时器的A P C例程,因为你始终都可以等待定时器变为已通知状态,然后做你想要做的事情。

9.4.2 定时器的松散特性
如果你发现自己创建和管理了若干个定时器对象,那么应该观察一下CreateTimerQueueTimer这个函数,它能够为你处理所有的操作,以减少应用程序的开销。
如果对等待定时器与用户定时器(用SetTimer函数进行设置)进行比较。可以发现它们之间的最大差别是,用户定时器需要在应用程序中设置许多附加的用户界面结构,这使定时器变得资源更加密集。另外,等待定时器属于内核对象,这意味着它们可以供多个线程共享,并且是安全的。
用户定时器能够生成WM_TIMER消息,这些消息将返回给调用SetTimer(用于回调定时器)的线程和创建窗口(用于基于窗口的定时器)的线程。因此,当用户定时器报时的时候,只有一个线程得到通知。另一方面,多个线程可以在等待定时器上进行等待,如果定时器是个人工重置的定时器,则可以调度若干个线程。如果要执行与用户界面相关的事件,以便对定时器作出响应,那么使用用户定时器来组织代码结构可能更加容易些,因为使用等待定时器时,线程必须既要等待各种消息,又要等待内核对象(如果要改变代码的结构,可以使用MsgWaitForMultipleObjects函数)。最后,运用等待定时器,当到了规定时间的时候,更有可能得到通知。WM_TIMER消息始终属于最低优先级的消息,当线程的队列中没有其他消息时,才检索该消息。等待定时器的处理方法与其他内核对象没有什么差别,如果定时器发出报时信息,而你的线程正在等待之中,那么你的线程就会醒来。

9.5 信标内核对象
信标内核对象用于对资源进行计数。它们与所有内核对象一样,包含一个使用数量,但是它们也包含另外两个带符号的3 2位值,一个是最大资源数量,一个是当前资源数量。最大资源数量用于标识信标能够控制的资源的最大数量,而当前资源数量则用于标识当前可以使用的资源的数量。
信标的使用规则如下:
• 如果当前资源的数量大于0,则发出信标信号。
• 如果当前资源数量是0,则不发出信标信号。
• 系统决不允许当前资源的数量为负值。
• 当前资源数量决不能大于最大资源数量。
CreateSemaphore函数用于创建信标内核对象。通过调用OpenSemaphore函数,另一个进程可以获得它自己的进程与现有信标相关的句柄.通过调用ReleaseSemaphore函数,线程就能够对信标的当前资源数量进行递增。

9.6 互斥对象内核对象
互斥对象(m u t e x)内核对象能够确保线程拥有对单个资源的互斥访问权。实际上互斥对象是因此而得名的。互斥对象包含一个使用数量,一个线程I D和一个递归计数器。互斥对象的行为特性与关键代码段相同,但是互斥对象属于内核对象,而关键代码段则属于用户方式对象。这意味着互斥对象的运行速度比关键代码段要慢。但是这也意味着不同进程中的多个线程能够访问单个互斥对象,并且这意味着线程在等待访问资源时可以设定一个超时值。I D用于标识系统中的哪个线程当前拥有互斥对象,递归计数器用于指明该线程拥有互斥对象的次数。互斥对象有许多用途,属于最常用的内核对象之一。通常来说,它们用于保护由多个线程访问的内存块。如果多个线程要同时访问内存块,内存块中的数据就可能遭到破坏。互斥对象能够保证访问内存块的任何线程拥有对该内存块的独占访问权,这样就能够保证数据的完整性。
互斥对象的使用规则如下:
• 如果线程I D是0(这是个无效I D),互斥对象不被任何线程所拥有,并且发出该互斥对象的通知信号。
• 如果I D是个非0数字,那么一个线程就拥有互斥对象,并且不发出该互斥对象的通知信号。
• 与所有其他内核对象不同, 互斥对象在操作系统中拥有特殊的代码,允许它们违反正常的规则。
若要使用互斥对象,必须有一个进程首先调用CreateMutex,以便创建互斥对象。当然,通过调用OpenM utex,另一个进程可以获得它自己进程与现有互斥对象相关的句柄。一旦线程成功地等待到一个互斥对象,该线程就知道它已经拥有对受保护资源的独占访问权。试图访问该资源的任何其他线程(通过等待相同的互斥对象)均被置于等待状态中。当目前拥有对资源的访问权的线程不再需要它的访问权时,它必须调用ReleseMutex函数来释放该互斥对象。

9.6.1 释放问题
互斥对象不同于所有其他内核对象,因为互斥对象有一个“线程所有权”的概念。本章介绍的其他内核对象中,没有一种对象能够记住哪个线程成功地等待到该对象,只有互斥对象能够对此保持跟踪。互斥对象的线程所有权概念是互斥对象为什么会拥有特殊异常规则的原因,这个异常规则使得线程能够获取该互斥对象,尽管它没有发出通知。这个异常规则不仅适用于试图获取互斥对象的线程,而且适用于试图释放互斥对象的线程。当一个线程调用ReleseMutex函数时,该函数要查看调用线程的I D是否与互斥对象中的线程I D相匹配。如果两个I D相匹配,递归计数器就会像前面介绍的那样递减。如果两个线程的I D不匹配,那么ReleseMutex函数将不进行任何操作,而是将FA L S E(表示失败)返回给调用者。

9.7 其他的线程同步函数
除了WaitForSingleObject和WaitForMultipleObjects,Windows还提供了另外几个稍有不同的函数。异步设备I/O,WaitForInputIdle,MsgWaitForMultipleObjects(Ex),WaitForDebugEvent,SingleObjectAndWait。如果理解了WaitForSingleObject和WaitForMultipleObjects函数,那么要理解其他函数如何运行,就不会遇到什么困难。这里就不再细述。
用waitforsingleobject()可以做一个延时的程序.但这个占用的系统资源比SLEEP多,但是它做出来的程序的精确度比SLEEP高~
HANDLE hThread = ::CreateThread(...)
::WaitForSingleObject( hThread, INFINITE );
就可以了,这样只有等那个你创建的线程结束之后,这个wait的后面的语句才被执行。

第10章是线程同步工具包


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值