WIN32下DELPHI中的多线程【线程的调度】(二)

来源:岁月联盟 编辑:zhuzhu 时间:2007-01-16

线程的调度

        每个线程是拥有一个上下文结构的,这个结构维护在线程的内核对象中。这个上下文结构反映了线程上次运行时该线程的C P U寄存器的状态。每隔20ms左右,Windows要查看当前存在的所有线程内核对象。在这些对象中,只有某些对象被视为可以调度的对象。Windows选择可调度的线程内核对象中的一个,将它加载到C P U的寄存器中,它的值是上次保存在线程的环境中的值。这项操作称为上下文转换。Windows实际上保存了一个记录,它说明每个线程获得了多少个运行机会。
         Windows被称为抢占式多线程操作系统,因为一个线程可以随时停止运行,随后另一个线程可进行调度。如你所见,可以对它进行一定程度的控制,但是不能太多。注意,无法保证线程总是能够运行,也不能保证线程能够得到整个进程,无法保证其他线程不被允许运行等等。
        我在编写串口通讯程序的时候,起初,我有一个天真的想法,“在win32平台下,如何能够保证从串口传送过来的数据,在数据到达后1MS内开始运行?”。为此,我曾经做了许多试验,但当我真正了解了一些win32平台的知识,我得到了答案,办不到。只有实时操作系统才能作出这样的承诺,但Windows不是实时操作系统。实时操作系统必须清楚地知道它是在什么硬件上运行,这样它才能知道它的硬盘控制器和键盘等的等待时间。Microsoft对Windows规定的目标是,使它能够在各种不同的硬件上运行,即能够在不同的CPU、不同的驱动器和不同的网络上运行。简而言之,Windows没有设计成为一种实时操作系统。
        Windows系统只调度可以调度的线程。那么什么是可以调度的线程,什么是不可以调度的线程呢?例如,有些线程对象的暂停计数大于1(记录在线程内核对象的上下文结构中)。这意味着该线程已经暂停运行,不应该给它安排任何C P U时间。还记得上文中曾经提到的CREATE_SUSPENDED标志吗?在创建一个线程的时候,createThread函数接收的倒数第二个参数中赋值CREATE_SUSPENDED就可以创建一个暂停的线程。除了暂停的线程外,其他许多线程也是不可调度的线程,因为它们正在等待某些事情的发生。例如,如果记事本程序,如果你不键入任何数据,那么它的线程就没有什么事情要做。系统不给无事可做的线程分配CPU时间。当移动它的窗口时,或者它的窗口需要刷新它的内容,或者将数据键入记事本,系统就会自动使它的线程成为可调度的线程。但切记,这并不意味着它的线程立即获得了CPU时间。它只是表示记事本的的线程有事情可做,系统将设法在某个时间(不久的将来)对它进行调度。

线程的暂停和执行
       我们前面说过,在线程内核对象的内部有一个值,用于指明线程的暂停计数。当调用CreateThread函数时,就创建了线程的内核对象,并且它的暂停计数被初始化为1。这可以防止线程被调度到CPU中。当然,这是很有用的,因为线程的初始化需要时间,你不希望在系统做好充分的准备之前就开始执行线程。当线程完全初始化好了之后, 要查看是否已经传递了CREATE_SUSPENDED标志。如果已经传递了这个标志,那么这些函数就返回,同时新线程处于暂停状态。如果尚未传递该标志,那么该函数将线程的暂停计数递减为0。当线程的暂停计数是0的时候,除非线程正在等待其他某种事情的发生,否则该线程就处于可调度状态。
       在暂停状态中创建一个线程,就能够在线程有机会执行任何代码之前改变线程的运行环境(如优先级)。一旦改变了线程的环境,必须使线程成为可调度线程。要进行这项操作,可以调用ResumeThread,将线程句柄传递给它,如果ResumeThread,函数运行成功,它将返回线程的前一个暂停计数,否则返回0xFFFFFFFF。注意这里,它返回的是前一个暂停计数。
        单个线程可以暂停若干次。如果一个线程暂停了3次,它必须恢复3次,然后它才可以被分配给一个C P U。当创建线程时,除了使用CREATE_SUSPENDED外,也可以调用SuspendThread函数来暂停线程的运行。任何线程都可以调用该函数来暂停另一个线程的运行(只要拥有线程的句柄)。不用说,线程可以自行暂停运行,但是不能自行恢复运行。SuspendThread返回的是线程的前一个暂停计数。线程暂停的最多次数可以是MAXIMUM_SUSPEND_COUNT次。值得注意的是,SuspendThread与内核方式的执行是异步进行的,但是在线程恢复运行之前,不会发生用户方式的执行。在实际环境中,调用SuspendThread时必须小心,因为不知道暂停线程运行时它在进行什么操作。如果线程试图从堆栈中分配内存,那么该线程将在该堆栈上设置一个锁。当其他线程试图访问该堆栈时,这些线程的访问就被停止,直到第一个线程恢复运行。只有确切知道目标线程是什么(或者目标线程正在做什么),并且采取强有力的措施来避免因暂停线程的运行而带来的问题或死锁状态,SuspendThread才是安全的。

线程的睡眠
      线程也能告诉系统,它不想在某个时间段内被调度。这是通过调用Sleep函数来实现的:
     VOID Sleep(DWORD cMilliseconds)

      该函数可使线程暂停自己的运行,直到cMilliseconds过去为止。关于Sleep函数,有下面几个重要问题值得注意:
      • 调用Sleep,可使线程自愿放弃它剩余的时间片。
      • 系统将在大约的指定毫秒数内使线程不可调度。不错,如果告诉系统,想睡眠100ms,那么可以睡眠大约这么长时间,但是也可能睡眠数秒钟或者数分钟。还是那个反复重申的概念, Windows不是个实时操作系统。虽然线程可能在规定的时间被唤醒,但是它能否做到,取决于系统
中还有什么操作正在进行。
      • 可以调用Sleep,并且为cMilliseconds)参数传递INFINITE。这将告诉系统永远不要调度该线程。这不是一件值得去做的事情。最好是让线程退出,并还原它的堆栈和内核对象。
      • 可以将0传递给Sleep。这将告诉系统,调用线程将释放剩余的时间片,并迫使系统调度另一个线程。但是,系统可以对刚刚调用Sleep的线程重新调度。如果不存在多个拥有相同优先级的可调度线程,就会出现这种情况。Sleep(0)是一个非常有意思的方法。要小心Sleep()神秘的时间调整问题。Sleep()可能会使你的机器出现特别的问题。这种问题在另一台机器上可能无法再现。

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

优先级
       操作系统会负责为每个线程分配CPU时间。一个线程所分配到的CPU时间主要取决于该线程的优先级,而线程的优先级又取决于进程的优先级类和线程本身的相对优先级。
1. 进程的优先级类
      进程的优先级类用来描述一个进程的优先程度。Win32支持四种不同的优先级类: Idle、Normal、High 和Realtime。其中,Normal是默认的优先级。在Windows单元中,每一种优先级类都对应着一个标志。当要进行进程的优先级设置时,可以用一种优先级类与CreateProcess()的参数dwCreationFlags进行或操作。另外,还可以动态地为一个已有的进程调整优先级类。这时候,通常你要用到下面API函数
 bool SetPriorityClass(HANDLE hProcess,DWORD fdwPriority),其中第一个参数是进程的句柄,你可以通过GetCurrentProcess来获得当前进程的句柄。每个优先级类也对应一个数字,值在4~ 24之间。注意在Windows NT/2000下,要有特殊的权限才能修改进程的优先类。默认的设置允许进程设置它们的优先级类,但是,这些都可以由系统管理员来关闭,尤其是在高负载的WinNT/2000服务器上。
       大多数情况下,进程的优先级类不要被设为Realtime。因为,大多数操作系统本身的线程的优先级类比Realtime低。如果一个进程得到的C P U时间比操作系统本身还多,后果是无法想象的。即使将进程的优先级类设为High ,也可能引起问题。因为,当高优先级的线程没有大部分空时间或等待外部事件时,它要从低优先级的线程和进程中抢夺CPU时间,直到它被一事件阻塞或处于空闲状态或处理消息。所以,在抢占式多任务操作系统中如果不能合理地安排优先级,就很容易崩溃。

优先级类说明实时进程中的线程必须立即对事件作出响应,以便执行关键时间的任务。
该进程中的线程还会抢先于操作系统组件之前运行。使用本优先级类
时必须极端小心高进程中的线程必须立即对事件作出响应,以便执行关键时间的任务。
Task Manager(任务管理器)在这个类上运行,以便用户可以撤消脱
离控制的进程高于正常进程中的线程在正常优先级与高优先级之间运行(这是Wi n d o w s
2 0 0 0中的新优先级类)正常进程中的线程没有特殊的调度需求低于正常进程中的线程在正常优先级与空闲优先级之间运行(这是Wi n d o w s
2 0 0 0中的新优先级类)空闲进程中的线程在系统空闲时运行。该进程通常由屏幕保护程序或后
台实用程序和搜集统计数据的软件使用
2. 相对优先级
       决定一个线程全面的优先级的另一方面是相对优先级。优先级类是针对进程的,它对进程内部的
所有线程都有效。而相对优先级是针对某个线程的。一个线程的相对优先级可设为以下七种: Idle、Lowest、Below Normal、Normal、Above Normal、Highest 和Time Critical。
要设置一个线程的相对优先级,可以通过API函数SetThreadPriority来完成,再DELPHI中,你可以通过TThread对象的Priority属性来设置。获得线程相对优先级的API函数是int GetThreadPriority(HANDLE hThread);

系统何如根据优先级来调度线程
       每个线程都会被赋予一个从0(最低)到31(最高)的优先级号码。当系统确定将哪个线程分配给CPU时,它首先观察优先级为31的线程,并以循环方式对它们进行调度。如果优先级为31的线程可以调度,那么就将该线程赋予一个CPU。在该线程的时间片结束时,系统要查看是否还有另一个优先级为31的线程可以运行,如果有,它将允许该线程被赋予一个CPU。只要优先级为31的线程是可调度的,系统就绝对不会将优先级为0到30的线程分配给C P U。这种情况称为渴求调度(starvation)。当高优先级线程使用大量的CPU时间,从而使得低优先级线程无法运行时,便会出现渴求情况。在多处理器计算机上出现渴求情况的可能性要少得多,因为在这样的计算机上,优先级为31和优先级为30的线程能够同时运行。系统总是设法使CPU保持繁忙状态,只有当没有线程可以调度的时候, CPU才处于空闲状态。
        人们可能认为,在这样的系统中,低优先级线程永远得不到机会运行。不过正像前面指出的那样,在任何一个时段内,系统中的大多数线程是不能调度的。例如,如果进程的主线程调用GetMessage函数,而系统发现没有线程可以供它使用,那么系统就暂停进程的线程运行,释放该线程的剩余时间片,并且立即将CPU分配给另一个等待运行的线程。如果没有为GetMessage函数显示可供检索的消息,那么进程的线程将保持暂停状态,并且决不会被分配给CPU。但是,当消息被置于线程的队列中时,系统就知道该线程不应该再处于暂停状态。此时,如果没有更高优先级的线程需要运行,系统就将该线程分配给一个CPU。

         高优先级线程将抢在低优先级线程之前运行,不管低优先级线程正在运行什么。例如,如果一个优先级为5的线程正在运行,系统发现一个高优先级的线程准备要运行,那么系统就会立即暂停低优先级线程的运行(即使它处于它的时间片中),并且将C P U分配给高优先级线程,使它获得一个完整的时间片。还有,当系统引导时,它会创建一个特殊的线程,称为0页线程。该线程被赋予优先级0,它是整个系统中唯一的一个在优先级0上运行的线程。当系统中没有任何线程需要执行操作时,0页线程负责将系统中的所有空闲R A M页面置0。

动态提高线程的优先级等级
       通过将线程的相对优先级与线程的进程优先级类综合起来考虑,系统就可以确定线程的优先级等级。有时这称为线程的基本优先级等级。

      系统常常要提高线程的优先级等级,以便对窗口消息或读取磁盘等I/O事件作出响应。
      例如,在高优先级类进程中的一个正常优先级等级的线程的基本优先级等级是13。如果用户按下一个操作键,系统就会将一个WM_KEYDOWN消息放入线程的队列中。由于一个消息已经出现在线程的队列中,因此该线程就是可调度的线程。此外,键盘设备驱动程序也能够告诉系统暂时提高线程的优先级等级。该线程的优先级等级可能提高2级,其当前优先级等级改为15。系统在优先级为15时为一个时间片对该线程进行调度。一旦该时间片结束,系统便将线程的优先级递减1,使下一个时间片的线程优先级降为14。该线程的第三个时间片按优先级等级13来执行。如果线程要求执行更多的时间片,均按它的基本优先级等级13来执行。注意,线程的当前优先级等级决不会低于线程的基本优先级等级。此外,导致线程成为可调度线程的设备驱动程序可以决定优先级等级提高的数量。Microsoft并没有规定各个设备驱动程序可以给线程的优先级提高多少个等级。这样就使得Microsoft可以不断地调整线程优先级提高的动态等级,以确定最佳的总体响应性能。系统只能为基本优先级等级在1至15之间的线程提高其优先级等级。实际上这是因为这个范围称为动态优先级范围。此外,系统决不会将线程的优先级等级提高到实时范围(高于15)。由于实时范围中的线程能够执行大多数操作系统的函数,因此给等级的提高规定一个范围,就可以防止应用程序干扰操作系统的运行。另外,系统决不会动态提高实时范围内的线程优先级等级。
       另一种情况也会导致系统动态地提高线程的优先级等级。比如有一个优先级为4的线程准备运行但是却不能运行,因为一个优先级为8的线程正连续被调度。在这种情况下,优先级为4的线程就非常渴望得到CPU时间。当系统发现一个线程在大约3至4s内一直渴望得到C P U时间,它就将这个渴望得到CPU时间的线程的优先级动态提高到15,并让该线程运行两倍于它的时间量。当到了两倍时间量的时候,该线程的优先级立即返回到它的基本优先级。
      系统动态的改变优先级,在我们编程的时候会产生不良影响,为此,还有两个API函数可以使得系统的此功能不起作用。
bool SetProcessPriorityBoost(HANDLE hProcess,bool disablePriorityboost);
bool SetThreadPriorityBoost(HANDLE hThread,bool disablePriorityboost);
从名字你就应该可以看出,第一个API函数可以激活或停用指定进程所有线程的优先级提高功能,而后面一个则是针对特定线程的。

例子:关键的代码如下

...{
    作者:wudi_1982
    联系方式:wudi_1982@hotmail.com
    转载请著名出处
   本代码旨在演示线程的调度,很多位置没有加入适当的控制和资源释放,请按照后续操作执行
}
type
  TSleepType=(stSleep,stSwitch);

  //演示线程调度的TThread派生类
  TPriThread1=class(TThread)
   private
      CurCount : integer; //当前计数
      Flb : TLabel;   //用来显示当前计数的label
      FCanSleep : Boolean; //是否自动释放时间片
      FSleepMs : integer;
      FSleepType : TSleepType;//释放时间片的方式
      procedure GetRestult;
    protected
      procedure Execute;override;
    public
      constructor Create(CreateSuspended: Boolean;ALabel : TLabel);
      property CanSleep : boolean read FCanSleep write FCanSleep;
      property SleepMs : integer  read FSleepMs write FSleepMs;
      property SleepType : TSleepType read FSleepType write FSleepType;
  end;

....

...{ TPriThread1的实现 }

constructor TPriThread1.Create(CreateSuspended: Boolean; ALabel: TLabel);
begin
//构造函数
  flb := ALabel;
  FSleepMs := 0;
  FCanSleep := true;
  FSleepType := stSleep;
  inherited create(CreateSuspended);
end;

procedure TPriThread1.Execute;
var
 i : integer;
begin
  inherited;
  FreeOnTerminate := true;
  CurCount := 0;
  for i := 0 to 100000 do
  begin
    CurCount := i;//改变当前计数
    Synchronize(GetRestult);//显示结果
    if FCanSleep then//是否自动释放时间片
    begin
       //根据释放时间片的不同方式进行相应操作
       case FSleepType of
         stSleep : Sleep(SleepMs);//睡眠
         stSwitch : SwitchToThread;//调用其他线程
       end;
    end;
  end;
end;

procedure TPriThread1.GetRestult;
begin
   flb.Caption := IntToStr(CurCount);
end;

...{Form1的主要代码}
procedure TForm1.btnPThread1CreateClick(Sender: TObject);
begin
   //生成两个线程
   MyPThread1 := TPriThread1.Create( not ckbx1State.Checked,lab1);
   MyPThread2 := TPriThread1.Create(not ckbx2State.Checked,lab2);
   //得到他们当前的优先级
   lb1p.Caption := inttostr(GetThreadPriority(MyPThread1.Handle));
   lb2p.Caption := inttostr(GetThreadPriority(MyPThread2.Handle));

end;

procedure TForm1.btnPThread1ResClick(Sender: TObject);
begin
  //执行线程
   MyPThread1.Resume;
   ckbx1State.Checked := true;

   MyPThread2.Resume;
   ckbx2State.Checked := true;
end;

procedure TForm1.btnPThread1SudClick(Sender: TObject);
begin
   //挂起线程
   MyPThread1.Suspend;
   ckbx1State.Checked := false;
   MyPThread2.Suspend;
   ckbx2State.Checked := false;
end;

procedure TForm1.btnUpPThread1Click(Sender: TObject);
begin
  //在线程挂起时,提高第一个线程的相对优先级
  MyPThread1.Priority := tpHigher;
  //显示当前的优先级到屏幕
  lb1p.Caption := inttostr(GetThreadPriority(MyPThread1.Handle));
 // MyPThread2.Priority := tpHigher;
end;

procedure TForm1.btnUpdateSleepClick(Sender: TObject);
begin
  //修改两个线程的时间片释放方式
  MyPThread1.CanSleep := ckbxAllowSleep1.Checked;
  case RadioGroup1.ItemIndex of
    0 :  MyPThread1.SleepType := stSleep;
    1 :  MyPThread1.SleepType := stSwitch;
  end;

  MyPThread2.CanSleep := ckbxAllowSleep2.Checked;
  case RadioGroup2.ItemIndex of
    0 :  MyPThread2.SleepType := stSleep;
    1 :  MyPThread2.SleepType := stSwitch;
  end;

end;

窗体效果:

线程调度程序的界面

让我们来用这个程序测试一些效果:
1、基本执行。程序运行之后,使用默认设置,点击【创建线程】按钮,线程将被创建,并且挂起,这是你可以间隔的点击【执行线程】和【挂起线程】按钮,你会在屏幕上看到线程的当前计数,注意这两个计数之间的差值,以及整个界面的执行效果(我指的是在你让线程不断的执行和挂起之间界面是否会出现不刷新的情况),当线程执行完毕之后,关闭程序。
2、通过Sleep(0)释放时间片演示线程调度。运行程序,使用默认设置,点击【创建线程】按钮,然后将两个线程的自释放时间片功能统统去掉(也就是去掉ckbxAllowSleep1 and 2的勾勾),然后点击【修改睡眠方式】按钮,随后你可以进行间隔点击【执行线程】和【挂起线程】按钮,多做几次这样的操作,观察两个计数之间的差值,和测试1的差值比较一下。我想你应该能想到些什么。然后,几乎可以肯定你的界面将会出现无法刷新的情况,并且你的鼠标无法立即在此界面上进行其他的操作。这个时候,稍等一下,你会发现过了一会儿,两个当前计数都被刷新了。为什么??这时,我们除了考虑我们创建的两个线程之外,你还要考虑的你程序本身的主线程以及其他可能存在的附属线程,我们再去程序中线程的那段循环代码,
    CurCount := i;//改变当前计数
    Synchronize(GetRestult);//显示结果
    if FCanSleep then//是否自动释放时间片
    begin
       //根据释放时间片的不同方式进行相应操作
       case FSleepType of
         stSleep : Sleep(SleepMs);//睡眠
         stSwitch : SwitchToThread;//调用其他线程
       end;
    end;
       你应该看到线程将当前计数显示在屏幕上的操作是执行了Synchronize(GetRestult),这里,因为我们的线程和VCL界面发生了交互,我们必须对Synchronize有所了解,去看VCL的源码,你会发现,当你在程序中第一次创建一个附属线程时, VCL将会从主线程环境中创建和维护一个隐含的线程窗口。此窗口唯一的目的是把通过Synchronize()调用的方法排队。Synchronize()把由Method参数传递过来的方法保存在TThread的FMethod字段中,然后,给线程窗口发一个CM_EXECPROC消息,并且把消息的lParam参数设为self(这里指线程对象)。当线程窗口的窗口过程收到这个消息后,它就调用FMethod字段所指定的方法。由于线程窗口是在主线程内创建的,线程窗口的窗口过程也将被主线程执行。因此,FMethod字段所指定的方法就在主线程内执行。
       在我们选择释放时间片的模式下,在这里,无论我们是用Sleep还是SwitchToThread,当前线程都会立即释放时间片,因为这时我们并没有修改线程的优先级,他们都在同样的优先级环境下运行,那么当占用CPU的线程释放时间片后,其他线程将可以相对轻松的得到CPU,所以在使用释放时间片的模式下,界面的刷新会良好。并且调度相对有序。
3、Sleep和SwitchToThread区别的演示。运行程序,使用默认设置,点击【创建线程】按钮,然后点击【提高线程1的优先级】按钮,再点击【执行线程】这是,两个线程将不再是同样的优先级,其他设置依然是默认的(使用sleep方式释放时间片),你会看到线程1首先执行,线程2处于可调度模式,但并没有被调度(当前计数没有刷新),并且屏幕也不刷新,在稍等一段时间之后,屏幕刷新,线程2也开始运行,并且此时屏幕刷新正常。为什么呢?回头去看本文上面的内容,当线程1的优先级提高之后,系统会首先调度它,虽然它使用sleep(0)来释放时间片,但当时间片释放后,因为它的优先级相对较高,系统依然会调度线程1,所以此时,线程2将不能执行,界面也不能有效刷新。在这个思路下,再做一个测试,使用同样的方式,只不过这次,在线程执行之前,除了提高线程1的优先级之外,还将线程1释放时间片方式改为SwitchToThread,此时你就可以看到两个线程都有机会执行,并且界面也将有效刷新。
4、你还可以做其他配置信息的测试,相信会加深对WIN32平台下线程调度的了解。

参考文献
1、《DELPHI5开发人员指南》
2、《WINDOWS核心编程》