第7章 线程的调度、优先级和亲缘性
抢占式操作系统必须使用某种算法来确定哪些线程应该在何时调度和运行多长时间。本章将要介绍Microsoft Windows 98和Windows 2000使用的一些算法。
上一章介绍了每个线程是如何拥有一个上下文结构的, 这个结构维护在线程的内核对象中。这个上下文结构反映了线程上次运行时该线程的 C P U寄存器的状态。每隔2 0 m s左右,Wi n d o w s要查看当前存在的所有线程内核对象。在这些对象中,只有某些对象被视为可以调度的对象。Wi n d o w s选择可调度的线程内核对象中的一个,将它加载到 C P U的寄存器中,它的值是上次保存在线程的环境中的值。这项操作称为上下文转换。 Wi n d o w s实际上保存了一个记录,它说明每个线程获得了多少个运行机会。使用M i c r o s o f t S p y + +这个工具,就可以了解这个情况。图7 - 1显示了一个线程的属性。注意,该线程已经被调度了37 379次。
目前,线程正在执行代码,并对它的进程的地址空间中的数据进行操作。再过 2 0 m s左右,Wi n d o w s就将C P U的寄存器重新保存到线程的上下文中。线程不再运行。系统再次查看其余的可调度线程内核对象, 选定另一个线程的内核对象,将该线程的上下文加载到C P U的寄存器中,然后继续运行。当系统引导时,便开始加载线程的上下文,让线程运行,保存上下文和重复这些操作,直到系统关闭。
总之,这就是系统对线程进行调度的过程。这很简单,是不是? Wi n d o w s被称为抢占式多线程操作系统,因为一个线程可以随时停止运行,随后另一个线程可进行调度。如你所见,可以对它进行一定程度的控制,但是不能太多。记住,无法保证线程总是能够运行,也不能保证线程能够得到整个进程,无法保证其他线程不被允许运行等等。
其实我自己以外发现vs上自带这个工具,在工具里面。界面如下(vs2012的)
注意 程序员常常问我,如何才能保证线程在某个事件的某个时间段内开始运行,比如,如何才能确保某个线程在数据从串行端口传送过来的 1 m s内开始运行呢?我的回答是,办不到。实时操作系统才能作出这样的承诺,但是Wi n d o w s不是实时操作系统。实时操作系统必须清楚地知道它是在什么硬件上运行,这样它才能知道它的硬盘控制器和键盘等的等待时间。M i c r o s o f t对Wi n d o w s规定的目标是,使它能够在各种不同的硬件上运行,即能够在不同的 C P U、不同的驱动器和不同的网络上运行。简而言之,Wi n d o w s没有设计成为一种实时操作系统。
尽管应强调这样一个概念,即系统只调度可以调度的线程,但是实际情况是,系统中的大多数线程是不可调度的线程。例如,有些线程对象的暂停计数大于 1。这意味着该线程已经暂停运行,不应该给它安排任何 C P U时间。通过调用使用 C R E AT E _ S U S P E N D E D标志的C r e a t e P r o c e s s或C r e a t e T h r e a d函数,可以创建一个暂停的线程。 (本章后面还要介绍 S u s p e n dT h r e a d和R e s u m e T h r e a d函数。 )
除了暂停的线程外,其他许多线程也是不可调度的线程,因为它们正在等待某些事情的发生。例如,如果运行N o t e p a d,但是并不键入任何数据,那么N o t e p a d的线程就没有什么事情要做。系统不给无事可做的线程分配C P U时间。当移动N o t e p a d的窗口时,或者N o t e p a d的窗口需要刷新它的内容,或者将数据键入N o t e p a d,系统就会自动使N o t e p a d的线程成为可调度的线程。
这并不意味着N o t e p a d的线程立即获得了C P U时间。它只是表示N o t e p a d的线程有事情可做,系统将设法在某个时间(不久的将来)对它进行调度。
7.1 暂停和恢复线程的运行
在线程内核对象的内部有一个值,用于指明线程的暂停计数。当调用 C r e a t e P r o c e s s或C r e a t e T h r e a d函数时,就创建了线程的内核对象,并且它的暂停计数被初始化为 1。这可以防止线程被调度到C P U中。当然,这是很有用的,因为线程的初始化需要时间,你不希望在系统做好充分的准备之前就开始执行线程。
当线程完全初始化好了之后, C r e a t e P r o c e s s或C r e a t e T h r e a d要查看是否已经传递了C R E ATE_ SUSPENDED标志。如果已经传递了这个标志,那么这些函数就返回,同时新线程处于暂停状态。如果尚未传递该标志,那么该函数将线程的暂停计数递减为 0。当线程的暂停计数是 0的时候,除非线程正在等待其他某种事情的发生,否则该线程就处于可调度状态。
在暂停状态中创建一个线程,就能够在线程有机会执行任何代码之前改变线程的运行环境(如优先级) 。一旦改变了线程的环境,必须使线程成为可调度线程。要进行这项操作,可以调用R e s u m 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 P r o c e s s的p p i P r o c I n f o参数指向的线程句柄传递给它) :
DWORD ResumeThread(HANDLE hThread);
如果 R e s u m e T h r e a d函数运行成功,它将返回线程的前一个暂停计数,否则返回0 x F F F F F F F F。
单个线程可以暂停若干次。如果一个线程暂停了 3次,它必须恢复3次,然后它才可以被分配给一个C P U。当创建线程时,除了使用 C R E AT E _ S U S P E N D E D外,也可以调用 S u s p e n dT h r e a d函数来暂停线程的运行:
DWORD SuspendThread(HANDLE hThread);
任何线程都可以调用该函数来暂停另一个线程的运行(只要拥有线程的句柄) 。不用说,线程可以自行暂停运行,但是不能自行恢复运行。与 R e s u m e T h r e a d一样,S u s p e n d T h r e a d返回的是线程的前一个暂停计数。线程暂停的最多次数可以是 M A X I M U M _ S U S P E N D _ C O U N T次(在Wi n N T. h中定义为1 2 7) 。注意,S u s p e n d T h r e a d与内核方式的执行是异步进行的,但是在线程恢复运行之前,不会发生用户方式的执行。
在实际环境中,调用S u s p e n d T h r e a d时必须小心,因为不知道暂停线程运行时它在进行什么操作。如果线程试图从堆栈中分配内存,那么该线程将在该堆栈上设置一个锁。当其他线程试图访问该堆栈时,这些线程的访问就被停止,直到第一个线程恢复运行。只有确切知道目标线程是什么(或者目标线程正在做什么) ,并且采取强有力的措施来避免因暂停线程的运行而带来的问题或死锁状态,S u s p e n d T h r e a d才是安全的(死锁和其他线程同步问题将在第8、9和1 0章介绍) 。
7.2 暂停和恢复进程的运行
对于Wi n d o w s来说,不存在暂停或恢复进程的概念,因为进程从来不会被安排获得 C P U时间。但是,曾经有人无数次问我如何暂停进程中的所有线程的运行。 Wi n d o w s确实允许一个进程暂停另一个进程中的所有线程的运行,但是从事暂停操作的进程必须是个调试程序。特别是,进程必须调用Wa i t F o r D e b u g E v e n t和C o n t i n u e D e b u g E v e n t之类的函数。
由于竞争的原因,Wi n d o w s没有提供其他方法来暂停进程中所有线程的运行。例如,虽然许多线程已经暂停,但是仍然可以创建新线程。从某种意义上说,系统必须在这个时段内暂停所有新线程的运行。M i c r o s o f t已经将这项功能纳入了系统的调试机制。
虽然无法创建绝对完美的S u s p e n d P r o c e s s函数,但是可以创建一个该函数的实现代码,它能够在许多条件下出色地运行。下面是我的S u s p e n d P r o c e s s函数的实现代码:
我的S u s p e n d P r o c e s s函数使用To o l H e l p函数来枚举系统中的线程列表。当我找到作为指定进程的组成部分的线程时,我调用O p e n T h r e a d:
这个新Windows 2000函数负责找出带有匹配的线程 I D的线程内核对象,对内核对象的使用计数进行递增,然后返回对象的句柄。运用这个句柄,我调用 S u s p e n d T h r e a d (或R e s u m e T h r e a d )。由于O p e n T h r e a d在Windows 2000中是个新函数,因此我的S u s p e n d P r o c e s s函数在Windows 95或Windows 98上无法运行,在Windows NT 4.0或更早的版本上也无法运行。
也许你懂得为什么S u s p e n d P r o c e s s不能总是运行,原因是当枚举线程组时,新线程可以被创建和撤消。因此,当我调用C r e a t e To o l h e l p 3 2 S n a p s h o t后,一个新线程可能会出现在目标进程中,我的函数将无法暂停这个新线程。过了一些时候,当调用 S u s p e n d P r o c e s s函数来恢复线程的运行时,它将恢复它从未暂停的一个线程的运行。更糟糕的是,当枚举线程 I D时,一个现有的线程可能被撤消,一个新线程可能被创建,这两个线程可能拥有相同的 I D。这将会导致该函数暂停任意些个(也许在目标进程之外的一个进程中的)线程的运行。
当然,这些情况不太可能出现。如果非常了解目标进程是如何运行的,那么这些问题也许根本不是问题。我提供这个函数供酌情使用。
7.3 睡眠方式
线程也能告诉系统,它不想在某个时间段内被调度。这是通过调用 S l e e p函数来实现的:
VOID Sleeo(DWORD dwMilliseconds);
该函数可使线程暂停自己的运行,直到 d w M i l l i s e c o n d s过去为止。关于S l e e p函数,有下面几个重要问题值得注意:
• 调用S l e e p,可使线程自愿放弃它剩余的时间片。
• 系统将在大约的指定毫秒数内使线程不可调度。不错,如果告诉系统,想睡眠 1 0 0 m s,那么可以睡眠大约这么长时间,但是也可能睡眠数秒钟或者数分钟。记住, Wi n d o w s不是个实时操作系统。虽然线程可能在规定的时间被唤醒,但是它能否做到,取决于系统中还有什么操作正在进行。
• 可以调用S l e e p,并且为d w M i l l i s e c o n d s参数传递I N F I N I T E。这将告诉系统永远不要调度该线程。这不是一件值得去做的事情。最好是让线程退出,并还原它的堆栈和内核对象。
• 可以将0传递给S l e e p。这将告诉系统,调用线程将释放剩余的时间片,并迫使系统调度另一个线程。但是,系统可以对刚刚调用 S l e e p的线程重新调度。如果不存在多个拥有相同优先级的可调度线程,就会出现这种情况。
7.4 转换到另一个线程
系统提供了一个称为 S w i t c h To T h r e a d的函数,使得另一个可调度线程(如果存在能够运行) :
BOOL SwitchThread();
当调用这个函数的时候,系统要查看是否存在一个迫切需要 C P U时间的线程。如果没有线程迫切需要C P U时间,S w i t c h To T h r e a d就会立即返回。如果存在一个迫切需要C P U时间的线程,S w i t c h To T h r e a d就对该线程进行调度(该线程的优先级可能低于调用 S w i t c h To T h r e a d的线程) 。这个迫切需要C P U时间的线程可以运行一个时间段,然后系统调度程序照常运行。
该函数允许一个需要资源的线程强制另一个优先级较低、而目前却拥有该资源的线程放弃该资源。如果调用S w i t c h To T h r e a d函数时没有其他线程能够运行,那么该函数返回 FA L S E,否则返回一个非0值。
调用S w i t c h To T h r e a d函数与调用S l e e p是相似的,并且传递给它一个 0 m s的超时。差别是S w i t c h To T h r e a d允许优先级较低的线程运行。即使低优先级线程迫切需要 C P U时间,S l e e p也能够立即对调用线程重新进行调度。
Windows 98 Windows 98没有配备该函数的非常有用的实现代码。
7.5 线程的运行时间
有时想要计算线程执行某个任务需要多长的时间。许多人采取的办法是编写类似下面的代码:
这个代码做了一个简单的假设:即它不会被中断。但是,在抢占式操作系统中,永远无法知道线程何时被赋予C P U时间。当取消线程的C P U时间时,就更难计算线程执行不同任务时所用的时间。我们需要一个函数,以便返回线程得到的 C P U时间的数量。幸运的是,Wi n d o w s提供了一个称为G e t T h r e a d Ti m e s的函数,它能返回这些信息:
使用这个函数,可以通过使用下面的代码确定执行复杂的算法时需要的时间量:
注意,G e t P r o c e s s Ti m e s是个类似G e t T h r e a d Ti m e s的函数,适用于进程中的所有线程:
G e t P r o c e s s Ti m e s返回的时间适用于某个进程中的所有线程(甚至是已经终止运行的线程) 。例如,返回的内核时间是所有进程的线程在内核代码中经过的全部时间的总和。
Windows 98 遗憾的是,G e t T h r e a d Ti m e s和G e t P r o c e s s Ti m e s这两个函数在Wi n d o w s9 8中不起作用。在Windows 98中,没有一个可靠的机制可供应用程序来确定线程或进程已经使用了多少C P U时间。
对于高分辨率的配置文件来说,G e t T h r e a d Ti m e s并不完美。Wi n d o w s确实提供了一些高分辨率性能函数:
虽然这些函数认为,正在执行的线程并没有得到抢占的机会,但是高分辨率的配置文件是为短期存在的代码块设置的。为了使这些函数运行起来更加容易一些,我创建了下面这个 C + +类:
使用这个类如下: