WINDOWS下线程同步探讨
概述
线程同步可以采用多种方式。可以在用户方式下实现,也可以在内核方式下实现。前者的优势在于速度快,因为不用在用户方式和内核方式之间切换,但只能用于同一个进程内的线程之间的同步;后者是使用内核对象的方式,速度虽慢,但可以用于不同进程之间的线程同步。而且后者相对前者方法丰富许多,功能也强大许多。
用户方式下的线程同步
互锁函数组下列函数可以以原子的方式进行操作(即或者全做,或者全不做,而且做得过程中不会被打断):
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,表示被等