线程的同步
在使用的时候,多线程最让人头疼的也许就是同步了。
如果你的线程只是完成一件并不需要访问线程对象外部资源的工作,在这种情况下,线程互相之间不需要进行通信,此时Windows的运行性能最好。但是,线程很少能够在所有的时间都独立地进行操作。通常情况下,要生成一些线程来处理某个任务。当这个任务完成时,另一个线程必须了解这个情况。
系统中的所有线程都必须拥有对各种系统资源的访问权,这些资源包括内存堆栈,文件,窗口和许多其他资源。如果一个线程需要独占对资源的访问权,那么其他线程就无法完成它们的工作。反过来说,也不能让任何一个线程在任何时间都能访问所有的资源。如果在一个线程从内存块中读取数据时,另一个线程却想要将数据写入同一个内存块,那么这就像你在读一本书时另一个人却在修改书中的内容一样。这样,书中的内容就会被搞得乱七八糟,结果什么也看不清楚。
线程需要在下面两种情况下互相进行通信:
• 当有多个线程访问共享资源而不使资源被破坏时。
• 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。
线程的同步包括许多方面的内容,Windows提供了许多方法,可以非常容易地实现线程的同步。但是,要想随时了解一连串的线程想要做什么,那是非常困难的。我们的头脑的工作不是异步的,我们希望以一种有序的方式来思考许多事情,每次前进一步。不过多线程环境不是这样运行的。你几乎无法完全知道目标系统中存在多少线程,也不知道他们处在什么状态下,更不知道他们要干什么。
用户方式下的线程同步
1、互锁函数
在MSDN关于同步函数的帮助文档中,你会看到大量的互锁函数。他们大多以Interlocked****的名字存在。互锁函数运行在用户模式。它能保证当一个线程访问一个变量时,其它线程无法访问此变量,以确保变量值的唯一性。这种访问方式被称为原子访问。
常用的互锁函数及其功能见如下列表:
函数
参数和功能
InterlockedIncrement
参数为PLONG类型。此函数使一个LONG变量增1
InterlockedDecrement
参数为PLONG类型。此函数使一个LONG变量减1
InterlockedExchangeAdd
参数1为PLONG类型,参数2为LONG类型。此函数将参数2赋给参数1指向的值
InterlockedExchange
参数1为PLONG类型,参数2为LONG类型。此函数将参数2的值赋给参数1指向的值
InterlockedExchangePointer
参数为PVOID* 类型,参数2为PVOID类型。此函数功能同上。
用InterlockedExchangeAdd来说明,他接受一个长整形变量的地址,然后将参数2的树枝加到参数1制定的长整形数据上。我们前边已经说了,他能保证当一个线程访问此长整形变量时,其他线程无法访问此变量,那么他是如何实现的呢?答案取决于运行的是何种CPU平台。对于x86家族的CPU来说,互锁函数会对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址。在Alpha平台上,互锁函数能够执行下列操作
1) 打开C P U中的一个特殊的位标志,并注明被访问的内存地址。
2) 将内存的值读入一个寄存器。
3) 修改该寄存器。
4) 如果C P U中的特殊位标志是关闭的,则转入第二步。否则,特殊位标志仍然是打开的,寄存器的值重新存入内存。
互锁函数工作与用户模式之下,所以他的速度是非常快点的。有利就有弊,互锁函数最大的缺点莫过于使用范围的狭隘性了,它更多的只是对单个变量的保护。
2、临界区
也有的地方叫它关键代码段。临界区指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,是指该代码知道没有别的线程要访问该资源。简单的说,就是一次只能由一个线程来执行的一段代码。
先来看一段例子,在例子中,线程完成对一个数组初始化的工作,目标是在当前的数值上加1,TThread对象中还定义了一个FUseCritical的变量,它用来决定线程在执行时是否要使用临界区的方式。
...{ 作者:wudi_1982 联系方式:wudi_1982@hotmail.com 本代码为演示代码,只贴出了一些比较重要的代码 转载请著名出处}const MaxArray = 1000;//公共内存区域的大小//演示临界区功能的线程类type TCriticalSectionThread= class(TThread) private FUseCritical : Boolean;//决定是否使用临界区 procedure GetRestult; protected procedure Execute;override; public constructor Create(UseCritical : Boolean); end;end;....var PublicMem : array[0..MaxArray] of integer;//一块公共区域 Cs : TRTLCriticalSection;//描述临界区信息的数据结构实现代码如下:...{ TCriticalSectionThread }constructor TCriticalSectionThread.Create(UseCritical: Boolean);begin //构造函数中接受是否使用临界区的参数 FUseCritical := UseCritical; inherited Create(false);end;procedure TCriticalSectionThread.Execute;var i : integer;begin inherited; //运行完毕后自动释放资源 FreeOnTerminate := True; //如果使用了临界区,则进入临界区 if FUseCritical then EnterCriticalSection(cs); //对数组初始化 for i := 0 to MaxArray do inc(PublicMem[i]); Synchronize(GetRestult); //离开 if FUseCritical then LeaveCriticalSection(Cs);end;procedure TCriticalSectionThread.GetRestult;var i : integer;begin //将结果显示在Form1.listbox1中 for i := 0 to MaxArray do Form1.ListBox1.Items.Add(inttostr(PublicMem[i]));end;//调用这个线程类来演示临界区功能的代码procedure TForm1.Button3Click(Sender: TObject);begin //将公共内存区域填充为0 FillMemory(@PublicMem,sizeof(PublicMem),0); ListBox1.Clear; //一个CheckBox,用来接受是否使用临界区方式的信息 if ckbxUsesC.Checked then //如果使用临界区,则首先初始化 InitializeCriticalSection(cs); //同时生成两个线程 TCriticalSectionThread.Create(ckbxUsesC.Checked); TCriticalSectionThread.Create(ckbxUsesC.Checked);end;//好的编码习惯中,你应该在确定线程已经不再需要使用临界区时,用DeleteCriticalSection(cs);清楚这个结构整理上面代码,并执行,在MaxArray定义比较大的时候(也就是一个线程完成工作需要时间比较长的时候),你会发现当不使用临界区时,两个进程“同时”出现对公共内存区的访问,数组没有按照你的预定方式进行初始化(理想的情况是数组先被初始化为1,然后再是2,可实际情况,你可能看到在第一个线程对数组初始化时,第二个线程抢占了CPU,所以,大量的数组成员被改写成了2),当使用临界区时,你会发现数组按照我们设定的思路先被初始化为1,然后再初始化为2。
让我们来看看在使用临界区的情况下系统是如何调度这两个线程的,当第一个线程创建之后,它成为可调度状态,至于系统目前是否分配CPU时间片给它,我们不知道,这要看系统中其他进程的情况,然后第二个线程创建,并且也成为可调度状态,当系统发现可以调度这两个线程的时候(也许在第一个线程创建之后,它就已经被调度了),系统调度一个线程,我们这里暂且假设调度的是第一个线程,线程执行EnterCriticalSection(cs)更新CRITICAL_SECTION(DLEPHI中将它定义为TRTLCriticalSection的记录)的成员变量,以指明调用线程已被赋予访问权并立即返回,使该线程能够继续运行,然后在一定时间之后,线程2抢占了CPU,然后执行EnterCriticalSection(cs),刷新CRITICAL_SECTION的成员变脸,系统发现一个线程已经被赋予了资源的访问权,这时,系统将调用线程(我们这里的线程2)置于等待状态。这种情况是极好的,因为等待的线程不会浪费任何CPU 时间。系统能够记住该线程想要访问该资源并且自动更新CRITICAL_SECTION的成员变量,直到线程1重新被调度,并执行了LeaveCriticalSection函数,这时系统便将线程2置为可调度状态,然后在合适的时间,线程2被调度,再次完成对数组初始化的工作。
使用临界区时要注意的内容
1、EnterCriticalSection和LeaveCriticalSection要配对出现,如果你只调用EnterCriticalSection而忘记使用LeaveCriticalSection,那么结果将是可怕的,这意味着其他需要访问被保护资源的线程将永远的等待下去,直到最终超时,产生一个异常条件。超时的时间被定义在注册表的HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager中,默认大约时30天的时间。
2、CRITICAL_SECTION结构中的成员应该在任何线程试图访问被保护的资源之前初始化。你要调用InitializeCriticalSection来完成此操作。如果一个线程试图进入未初始化的CRITICAL_SECTION,那么结果将是无法预料的。
3、当知道进程的线程不再试图访问共享资源时,用DeleteCriticalSection函数清楚CRITICAL_SECTION结构
几个有用的技巧
1)、每个需保护的共享资源使用一个单独的CRITICAL_SECTION变量,例如线程1和线程2访问一个资源A,而线程1和线程3访问另一个资源B,那最好都资源A、B都使用一个单独的CRITICAL_SECTION变量,如果使用同一个CRITICAL_SECTION变量,那么加入线程1先被调度了,线程2、3则只有等待线程1完成对A、B的使用后才有机会运行。如果使用单独的CRITICAL_SECTION变量,则在线程1使用完A之后,线程2即可迅速成为被调度状态。
2)、当要访问多个被保护资源时,如果你使用的不同的CRITICAL_SECTION变量,那么要注意他们的顺序,例如线程1先后调用EnterCriticalSection(cs1);EnterCriticalSection(cs2),而有另外一个线程则使用另外的顺序EnterCriticalSection(cs2),EnterCriticalSection(cs1),那么将有可能出现死锁的情况,线程1被调度,获得被cs1保护的资源A的使用权,然后线程2被调度,获得了被cs2保护的资源B的使用权,那么此时,无论是线程1,还是线程2,都将因为对方而一直等待下去。
3)、不要在临界区内长时间执行那些不需要使用保护资源的代码。如果长时间执行那些不必要保护的代码,其他的希望访问保护资源的线程将长时间的等待下去。
线程与内核对象的同步
用户方式同步的优点是它的同步速度非常快。虽然用户方式的线程同步机制具有速度快的优点,但其局限性也是明显的。例如,互锁函数家族只能在单值上运行,根本无法使线程进入等待状态。我们使用临界区的方式可以使得线程进入等待状态,但是只能用这些代码段对单个进程中的线程实施同步。因为在等待进临界区时你无法方便的设定超时值,所以有可能出现死锁的状态。
上面我一直强调了一个概念,就是等待状态,这是很重要的,因为等待状态的线程将不使用CPU资源。
内核对象机制的适应性远远优于用户方式机制。实际上,内核对象机制的唯一不足之处是它的速度比较慢。《WINDOWS核心编程》一书中说”这个转换需要很大的代价:往返一次需要占用x86平台上的大约1000个CPU周期,当然,这还不包括执行内核方式代码,即实现线程调用的函数的代码所需的时间。“,我没有测试过,不知道这1000个CPU周期的说法是否准确,但可以肯定内核对象机制将比用户模式下的同步要慢。
Windows的内核对象,包括进程,线程和作业等。可以将所有这些内核对象用于同步目的。对于线程同步来说,这些内核对象中的每种对象都可以说是处于已通知或未通知的状态之中。例如,进程内核对象总是在未通知状态中创建的。当进程终止运行时,操作系统自动使该进程的内核对象处于已通知状态。一旦进程内核对象得到通知,它将永远保持这种状态,它的状态永远不会改为未通知状态。当进程正在运行的时候,进程内核对象处于未通知状态,当进程终止运行的时候,它就变为已通知状态。
实际上,线程内核对象也遵循同样的规则。与进程内核对象一样,线程内核对象也可以处于已通知状态或未通知状态。
下面的内核对象可以处于已通知状态或未通知状态:进程、文件修改通知线程、事件、作业、 可等待定时器、文件、 信标、控制台输入、 互斥对象
线程可以使自己进入等待状态,直到一个对象变为已通知状态。注意,用于控制每个对象的已通知/未通知状态的规则要根据对象的类型而定。
等待函数
最常用的莫过于WaitForSingleObject
DWORD WaitForSingleObject(HANDLE hObject,DWORD dwMillseconds);
当线程调用该函数时,第一个参数标识一个能够支持被通知/未通知的内核对象。第二个参数允许该线程指明为了等待该对象变为已通知状态,它将等待多长时间。WaitForSingleObject的返回值能够指明调用线程为什么再次变为可调度状态。如果线程等待的对象变为已通知状态,那么返回值是WAIT_OBJECT_0。如果设置的超时已经到期,则返回值是WAIT_TIMEOUT。如果将一个错误的值(如一个无效句柄)传递给WaitForSingleObject,那么返回值将是WAIT_FAILED。
事件对象
在所有的内核对象中,事件内核对象是个最基本的对象。它们包含一个使用计数(与所有内核对象一样),一个用于指明该事件是个自动重置的事件还是一个人工重置的事件的布尔值,另一个用于指明该事件处于已通知状态还是未通知状态的布尔值。
事件能够通知一个操作已经完成。有两种不同类型的事件对象。一种是人工重置的事件,另一种是自动重置的事件。当人工重置的事件得到通知时,等待该事件的所有线程均变为可调度线程。当一个自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线程。
当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件使用得最多。事件初始化为未通知状态,然后,当该线程完成它的初始化操作后,它就将事件设置为已通知状态。这时,一直在等待该事件的另一个线程发现该事件已经得到通知,因此它就变成可调度线程。这第二个线程知道第一个线程已经完成了它的操作。
HANDLE CreateEvent(PSECURITY_ATTRIBUTS psa,
bool fManualRest,
bool fInitialState,
PCTSTR pszname);
第一个参数用来制定安全属性,通常我们用null,最后一个参数用来制定一个名字。fManualRest,参数是个布尔值,它能够告诉系统是创建一个人工重置的事件还是创建一个自动重置的事件(FALSE)。fInitialState,参数用于指明该事件是要初始化为已通知状态(TRUE)还是未通知状态(FALSE)。当系统创建事件对象后,createEvent就将与进程相关的句柄返回给事件对象。其他进程中的线程可以获得对该对象的访问权,方法是使用在pszName参数中传递的相同值,使用继承性,使用DuplicateHandle函数等来调用CreateEvent,或者调用OpenEvent,在pszName数中设定一个与调用CreateEvent时设定的名字相匹配的名字 。
一旦事件已经创建,就可以直接控制它的状态。当调用SetEvent时,可以将事件改为已通知状态:当调用ResetEvent函数时,可以将该事件改为未通知状态。
自动重置的事件与人工重置事件的区别在于,自动重置事件在线程成功地等待到该对象时,自动重置的事件就会自动重置到未通知状态。通常没有必要为自动重置的事件调用ResetEvent函数,因为系统会自动对事件进行重置
一个例子:
...{ 作者:wudi_1982 联系方式:wudi_1982@hotmail.com 本代码只贴出了一些比较关键的部分 转载请著名出处} //利用事件对象演示同步的TThread派生类 TEventThread=class(TThread) private CurCount : integer;//当前计数 Flabel : TLabel;//显示当前计数的Tlabel组建 procedure GetRestult; protected procedure Execute;override; public constructor Create(Alabel : TLabel); end;var EventHandle : THandle;//事件对象的句柄//TEventThread的实现代码...{ TEventThread }constructor TEventThread.Create(Alabel: TLabel);begin Flabel := Alabel; inherited Create(False);end;procedure TEventThread.Execute;var i : integer;begin inherited; CurCount := 0; for i := 0 to 10000 do begin case WaitForSingleObject(EventHandle,5000) of WAIT_OBJECT_0 : begin//如果等待到事件对象,则将当前技术加1,并且显示在指定label上 Inc(CurCount); Synchronize(GetRestult); Sleep(0); //关于sleep,switchtoThread的使用前面已经说了 // SwitchToThread //Application.ProcessMessages; end;//WAIT_OBJECT_0 WAIT_TIMEOUT : begin//超时则自动重置当前计数 CurCount := 0; Synchronize(GetRestult); SwitchToThread; end; end; end;end;procedure TEventThread.GetRestult;begin Flabel.Caption := IntToStr(CurCount);end;//窗体单元中测试TEventThread的一些代码procedure TForm1.CreateTClick(Sender: TObject);begin //这里创建了TEventThread线程类的两个实例,目的是更好的演示自动重置和手动重置的区别 TEventThread.Create(labEvent); TEventThread.Create(labEvent2);end;procedure TForm1.CreateEClick(Sender: TObject);begin //生成一个事件对象,事件对象的重置方式以及初始化状态通过两个checkBox来决定 EventHandle := CreateEvent(nil,ckbxAutoReset.Checked, ckbxInitEventState.Checked,pchar('MyEvent'));end;procedure TForm1.SetEClick(Sender: TObject);begin //通知 SetEvent(EventHandle);end;procedure TForm1.ResetEClick(Sender: TObject);begin //未通知 ResetEvent(EventHandle);end;procedure TForm1.closeClick(Sender: TObject);begin //再你确定不需要此事件对象的时候,记得释放资源 CloseHandle(EventHandle)end;利用事件对象演示同步的程序界面
整理上述代码,然后分别用不同的配置不同顺序的点击按钮,可以让你对事件对象的使用加深了解。
内核方式的同步还有很多种,但原理基本都一样,后续文章对尽可能的依次举例列举。
参考文献:《WINDOWS核心编程》
注:转载请著名出处,谢谢