欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

WINDOWS下线程同步探讨  

程序员文章站 2022-05-19 20:25:32
...

概述

线程同步可以采用多种方式。可以在用户方式下实现,也可以在内核方式下实现。前者的优势在于速度快,因为不用在用户方式和内核方式之间切换,但只能用于同一个进程内的线程之间的同步;后者是使用内核对象的方式,速度虽慢,但可以用于不同进程之间的线程同步。而且后者相对前者方法丰富许多,功能也强大许多。

用户方式下的线程同步

互锁函数组

下列函数可以以原子的方式进行操作(即或者全做,或者全不做,而且做得过程中不会被打断):

InterlockedExchangeAdd:原子方式增加一个变量,可以在参数中提供负值来实现原子减法操作。

InterlockedExchange,InterlockedExchangePointer:实现原子赋值操作,而且返回原始数值。

InterlockedCompareExchange,InterlockedCompareExchangePointer:原子方式比较赋值,即如果目标变量和被比较值相等时,目标变量才会被赋值为原始值。

所有的互锁函数都是跟写变量相关的,没有读变量的互锁函数。因为读变量不会产生同步问题。引申出来,也没有比较两个变量是否相等的互锁函数,因为只是读变量,而不会写变量。所以下文的循环锁中的相等判断就不会产生同步问题。

循环锁

循环锁是利用互锁函数族中的InterlockedExchange来实现的一种线程同步方式。参考下面的代码:

BOOL g_bResourceInUse = FALSE;

void func()

{

//wait to access the resource

while (InterlockedExchange(&g_bResourceInUse, TRUE) == TRUE)

{

Sleep(0);

}

//access the resource

//no longer need to access the resource

InterlockedExchange(&g_bResourceInUse, FALSE);

}

说明:

Sleep(0)是告诉系统该线程将释放剩余的时间片,并迫使系统调度另外一个线程。主要是因为下面:

while (InterlockedExchange(&g_bResourceInUse, TRUE) == TRUE)

{

Sleep(0);

}

开始g_bResourceInUse(简称布尔量)为FALSE,如果两个线程都运行这段代码,第一个会比较布尔量和TRUE,因为布尔量是FALSE,所以布尔量被赋值为FALSE,并且返回TRUE,进入关键区;另一个线程则总是返回TRUE,所以循环直到第一个线程退出关键区重新把布尔量赋为FALSE为止。

如果等待时间很短,这种方式是相当快的。比下面的关键代码段都要快,因为发生冲突时关键代码段的等待过程还是通过内核对象来实现的。

关键代码段

Critical sections(关键代码段)是一小块用来处理一份被共享资源的代码,该段代码必须独占的对某些共享资源的访问权。这可以让多行代码以原子方式执行。实施的方式是在程序中加入“进入”或“离开”critical section的操作。如果一个线程进入了critical section,另外一个线程绝对不能进入该critical section。

为实现这种功能MS提供了五个函数:

InitializeCriticalSection:创建critical section,其实是CRITICAL_SECTION类型的变量。它不是内核对象,所以不是返回句柄。它存在于进程的内存空间中。关于使用critical section的线程以及使用计数都保存在该结构中。

DeleteCriticalSection:用完critical section后清除CRITICAL_SECTION结构。

EnterCriticalSection:在进入critical section前必须调用该函数。这样可以保证之后的代码在同一时间内只有一个线程可以进入。它会查看CRITICAL_SECTION结构,从而保证这一点。具体方法是:

1. 如果没有别的线程进入critical section,则进入。并设置critical section为自己线程所访问。

2. 如果线程自己正在访问该critical section,则只是将计数加1,然后进入。

3. 如果别的线程访问critical section,则等待。系统会在别的线程释放资源后更新CRITICAL_SECTION,从而使该唤醒该线程。可以看出,这种系统更改结构然后唤醒线程的方法必然需要内核对象的配合,而且等待意味着该线程必须从用户方式转到内核方式。

LeaveCriticalSection:查看结构中的成员变量。该函数每次计数都要递减1,指明调用线程多少次被赋予对共享资源的访问权。如果计数大于0,则该函数不做其它操作,只是返回;如果等于0,说明该线程释放了资源,所以该函数查看调用EnterCriticalSection中是否有别的线程在等待。如果至少有一个在等待,则更新成员变量,唤醒等待线程。没有等待线程则更新成员变量说明没有线程使用该资源。

一个EnterCriticalSection必须和一个LeaveCriticalSection配合。即对于同一个CRITICAL SECTION可以多次调用Enter,但必须调用同样次数的Leave。Enter只可能使其它请求使用该critical section的线程阻塞,如果本身正在使用该关键区,则只是让计数加1,不会自己阻塞自己。

TryEnterCriticalSection用来判断线程是否能够进入critical section,它马上返回结果。

关键代码段和循环锁的配合

关键代码段在发生线程等待时会转入内核状态,因为状态转换是非常费时的,所以这对于可能迅速唤醒的线程而言比较费时;而循环锁则总是处于可调度状态,对于可能需要很长时间才能获得资源而使用的线程而言则会被多次唤醒而检查到资源仍不可用,如果能将这类线程置为等待状态而不是可调度状态,会更加高效。

所以说,对于马上可以获取资源的线程,使用循环锁是比较高效的;对于长时间之后才能获得资源的线程,使用关键代码段是比较好的。如果自己实现这种策略,可以先调用一定次数的循环锁,如果仍然不能获取资源,则转为使用关键代码段。

不过微软本身在关键代码段函数族中实现了对二者的结合。如果要将循环锁用于关键代码段,可以使用下面函数:

InitializeCriticalSectionAdnSpinCount

它和InitializeCriticalSection类似,但多了一个DWORD类型的参数,用来设置线程等待(需要进入内核状态)之前想要循环锁循环迭代的次数。遗憾的是,该值只有对于多CPU才是有效的,因为MS实现的循环锁不同于自己前面的例子,而是没有调用sleep。这样对于单CPU而言循环锁执行过程中另一个线程是无法释放已经拥有的资源的。所以如果要求高效只能自己在代码中对二者进行结合。

使用内核对象进行线程同步

内核对象

每个内核对象是内核分配的一个内存块,并且只能由内核访问。该内存块是一种数据结构,它的成员负责维护该对象的各种信息。用户程序不能直接在内存中找到这些变量并修改它们。只能通过windows提供的一些接口函数对其进行操作。

每个内核对象都对应一个句柄。进程中有一个句柄表,只是个数据结构的数组。每个结构包含一个指向内核对象的指针、一个访问掩码和一些标志。句柄其实是在该表中的索引,从1开始。但在win2000中是该内核对象的开头在表中字节偏移数。句柄是进程唯一的,而非系统唯一,同一个句柄值在不同进程中是不具有同样含义的。

内核对象通过CreateXXX创建,通过CloseHandle来关闭。

用于同步的内核对象

可以用于同步的内核对象可以处于通知状态和未通知状态。线程可以等待这些对象。如果被等待独享处于已通知状态,则线程变为可调用;如果处于未通知状态,则线程阻塞。

等待函数是WaitForSingleObject和WaitForMultipleObjects。

前者用来等待单一的内核对象。第一个参数指明了对象的句柄,第二个则是等待时间。当被等待对象为未通知状态时,线程阻塞,直到指定的时间结束;当通知时,通过该代码,执行下面的语句。返回值可能是WAIT_OBJECT_0,表示被等待对象变为通知状态;WAIT_TIMEOUT表示超时;WAIT_FAILED表示被等待对象的句柄无效等错误。

后者用来等待多个内核对象。可以设置等待所有对象都变为已通知时才唤醒线程;或者其中任何一个变为已通知就唤醒线程。返回值可能是WAIT_OBJECT_N,N是自然数。在只有一个对象变为已通知状态就返回的情况下,它表示了成为已通知状态的对象的序号。

等待成功后可能会改变对象的属性,这称为成功等待的副作用。无论对等待单个对象还是多个对象,副作用都是在等待成功时一次性执行的。

进程内核对象

当进程正在运行时,进程内核对象处于未通知状态。当进程停止运行时,就处于已通知状态。可以通过等待进程来检查进程是否仍然运行。

无成功等待的副作用。

线程内核对象

当线程正在运行时,线程内核对象处于未通知状态。当线程停止运行时,就处于已通知状态。可以通过等待线程来检查线程是否仍然运行。

无成功等待的副作用。

事件内核对象

包括人工重置的事件和自动重置的事件。

当人工重置事件得到通知时,等待该事件的所有线程成为可调度线程;它没有成功等待副作用。

当自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线程。其成功等待的副作用是该对象自动重置为未通知状态。

事件内核对象通过CreateEvent创建,初始可以是通知或未通知状态。SetEvent将事件改为已通知状态,ResetEvent将事件设为未通知状态。

当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,经常使用人工事件对象。另外如果一个写线程,多个读线程,可以让写线程完成写操作时通过人工事件通知读线程读取数据。

而自动事件对象则可以用于保护资源在同一时间只有一个线程可以访问,因为它保证只有一个线程被激活。

等待定时器内核对象

等待定时器是在某个时间或按规定的时间间隔发出自己的信号通知的内核对象。包括人工重置的定时器和自动重置的定时器。初始必须是未通知状态。

当发出人工重置的定时器信号时,等待该定时器的所有线程变为可调度;无成功等待副作用。

当发出自动重置的定时器信号时,只有一个等待线程变为可调度线程。成功等待副作用是重置对象。

通过CreatWaitableTimer创建,CancelWaitableTimer撤销一个定时器,SetWaitableTimer告诉定时器何时让其变为已通知状态。

这个对象不太常用,所以自己也没有好好看。

信号量内核对象

信号量用来对资源进行计数。它包含两个32位值,一个表示能够使用的最大资源数量,一个表示当前可用的资源数量。

信号量的使用规则如下:

1. 如果当前资源数量大于0,发出信号量信号

2. 如果当前资源数量是0,不发出信号量信号

3. 不允许当前资源数量为负值

4. 当前资源数量不能大于最大信号数量

通过CreateSemaphore创建。ReleaseSemaphore来释放资源,从而使当前资源数量增加。

当调用等待函数时,它会检查信号量的当前资源数量。如果它的值大于0,那么计数器减1,调用线程处于可调度状态。如果当前资源是0,则调用函数的线程进入等待状态。当另一个线程对信号量的当前资源通过ReleaseSemaphore进行递增时,系统会记住该等待线程,并将其变为可调度状态。

当有多个资源共访问时,经常使用信号量内核对象。

其成功等待副作用是当前资源数量减1。

互斥器内核对象

互斥器保证线程拥有对单个资源的互斥访问权。互斥对象类似于关键代码区,但它是一个内核对象。

互斥器不同于其他内核对象,它有一个“线程所有权”的概念。它如果被某个线程等待成功,就属于该线程。

互斥器的使用规则如下:

1. 如果线程ID是0(无效ID),互斥对象不被任何线程拥有,并且发出该互斥对象的通知信号。

2. 如果ID是非0数字,那么一个线程可以拥有互斥对象,并且不发出该互斥对象的通知信号。

3. 互斥器有一个递归计数器。如果线程已经拥有了互斥器,而它再次等待该互斥器,则马上成功返回;而且递归计数器加1。

通过CreateMutex创建。ReleaseMutex用来释放互斥器。如果线程拥有互斥器,则首先把递归计数器减1,如果减到0,则线程释放互斥器,或者说互斥器的所属线程为空。此后其他线程就可以等待得到该互斥器了。但是如果一个线程ReleaseMutex了一个本来不归他所有的互斥器,则不会有任何效果。

互斥器常用于保护由多个线程访问的内核块。互斥器保证了访问内存块的任何线程拥有对该内存块的独占访问。

其成功等待副作用是将所有权赋予线程,并将递归计数器加1。

互斥器和关键代码区的功能是非常相似的,只是一个是用户对象,一个是内核对象。