本文主要讨论WINDOWS应用层编程的线程同步问题。在实际编程过程中,我们经常会遇到线程同步的问题,例如在编写多线程共同访问一个共享资源的程序时,如果多个线程只是读取资源那么就不会涉及到下面我们要讨论的问题;如果当有的线程读取资源,有的线程改变资源时,就会产生资源在访问时的同步问题。即当一个线程改变资源,同时其他线程也在读写该资源,这样会导致资源内容的不可确定性。为避免这种情况的发生,保证资源的完整性,我们通常会采用线程同步的方法。WINDOWS操作系统为我们提供了事件(EVENT)、信号量(SEMAPHORE)、互斥(MUTEX)等内核对象来同步线程,同时也提供了关键代码段(CRITICAL_SECTION)这样的用户方式来进行同步。下面我就对这两种情况分别来讨论。
内核对象方式:WINDOWS内核提供的可以用来进行线程同步的内核对象,都是可以发信号(Signaled)的对象,在WINDOWS中它们被称为“调度程序对象”(dispatcher object)。它们包括进程(PROCESS)、线程(THREAD)、事件(EVENT)、信号量(SEMAPHORE)、互斥(MUTEX)、时钟(TIMER)等。对这一类的对象,线程都可以通过用WaitForSingleObject()或WaitForMultpleObjects()等API来获得对象的使用权。当对象处于信号态时,线程立即返回,获得对象的使用权。当对象未处于信号态时,即对象正被其他线程占用时,线程就会被排在该内核对象的等待队列当中,当对象变为信号态时,队列中排队的第一个线程便得到该对象,被内核放入线程就绪队列,已重新获得CPU时间。在线程使用完对象后,需要释放该对象的使用权,以便其它等待该对象的线程获得执行。相关对象操作的API在MSDN中有详细的描述,这里就不再复述。下面简单讨论一下,WINDOWS的内核实现:
相关结构
typedef struct _DISPATCHER_HEADER {
UCHAR Type;
UCHAR Absolute;
UCHAR Size;
UCHAR Inserted;
LONG SignalState;
LIST_ENTRY WaitListHead;
} DISPATCHER_HEADER;
typedef struct _KWAIT_BLOCK {
LIST_ENTRY WaitListEntry;
struct _KTHREAD *RESTRICTED_POINTER Thread;
PVOID Object;
struct _KWAIT_BLOCK *RESTRICTED_POINTER NextWaitBlock;
USHORT WaitKey;
USHORT WaitType;
} KWAIT_BLOCK, *PKWAIT_BLOCK, *RESTRICTED_POINTER PRKWAIT_BLOCK;
(注:这两个结构包含NTDDK.H中,是公开的)
在下面的图(1)中我们看到,线程1在等待对象B,线程2在等待对象A,B。如果对象A变为信号状态,内核将会检测到线程2在等待该对象,但由于线程2还在等待对象B,那么线程2还不能被放入线程就绪队列,以重新获得CPU。如果对象B变为信号
图(1)
状态,那么线程1由于没有等待其它对象,而被内核放入就绪队列并执行。而线程2则需等待线程1释放对象B后才可被安排获得执行。对于不同的内核对象,内核在处理等待它们的线程时也不尽相同,对于进程,线程等内核对象,当对象变为信号状态时,内核将一次使所有等待线程都获得使用权。要对此详细了解的朋友可以看《Inside Windows 2000》。
用户方式:在WINDOWS下,WINDOWS还给用户提供了关键代码段(CRITICAL_SECTION),这种用户方式的线程同步解决方法。这种方式,比内核对象方式执行快。因为每次检测内核对象是否被占用时,系统都要由用户模式换为核心模式,这样就要消耗不少的切换时间。而关键代码段只是在用户模式下被简单检测是否已被其它线程占用,这样就比内核对象少了模式间切换所用的时间。在需要同步保护的代码量不是很大的情况下,使用关键代码段不失为一个好的解决方法,它可以提高代码的执行效率,减少线程间的冲突。当关键代码段发生冲突时,即一个线程检测到关键代码段已被其他线程占用时,该线程就进入等待状态。关键代码段有一个等待超时,如果超时发生时,线程仍未获得关键代码段,就会产生一个异常。所以,在编写关键代码段程序时应当注意用完后释放。关键代码段的超时值被记录在注册表的HKEY_LOCAL_MACHINE\System\CurrentControSet\Control\Session Manager键下的CriticalSectionTimeout关键字中。默认的值为2592000s,大约30天。我们可以通过改动这个值来调整超时时间,由于该值是系统公用的,所以建议设置该值时最小不要小于3S,以免影响到系统正常等待关键代码段超过3S的线程和其他应用。具体操作关键代码段的API,在MSDN中都有详细描述,这里也不进行复述。 下面简单讨论一下线程同步实现的原理:
操作系统之所以能够进行线程同步,主要依靠的是原子操作,原子操作是不会被打断的操作,通常它是由处理器体系结构提供,一般是由硬件支持的test-and-set操作。下面是笔者在X86体系架构下,效仿LINUX写的两个WINDOWS函数,TestAndSetBit()用于将指定的位设为1和TestAndClearBit()用于将指定的位清0,即加锁与解锁操作。
static int TestAndSetBit(int nOrder,volatile void* Var)
{
int nOldBit=0;
_asm {
MOV EAX,Var
MOV EBX,nOrder
LOCK BTS [EAX],EBX
MOV EAX,nOldBit
SBB nOldBit,EAX
}
return nOldBit;
}
static int TestAndClearBit(int nOrder,volatile void* Var)
{
int nOldBit=0;
_asm {
MOV EAX,Var
MOV EBX,nOrder
LOCK BTR [EAX],EBX
MOV EAX,nOldBit
SBB nOldBit,EAX
}
return nOldBit;
}
(注:LOCK指令是锁总线,用于多处理器的情况。BTS是X86的test-and-set操作。BTR是X86的test-and-reset操作)
下面通过一段代码用上面的函数来实现线程同步:
DWORD g_dwLock=0;
…
while(TestAndSetBit(0,&g_dwLock))
Sleep(1000);
/*
要保护的代码
*/
TestAndClearBit(0,&g_dwLock);
这是一个用户模式下的线程同步的应用,冲突发生时,当前线程睡眠,以使获得锁的线程能够运行被保护的代码,并在运行结束后解锁。当等待线程睡醒并重新执行时,就会得到锁并进行后面的运行。在核心模式下,原理是一样的,只是在资源冲突时的处理方式不同。
具体编程时要用哪一种方式进行线程的同步还要看程序的具体需要,笔者这里就不列举了。最后,笔者要向大家道歉,文章写的有些乱,可能有些地方讲的不清楚,有些地方理解的不对,还望大家及时指出。
参考书目:
《Inside windows 2000》
《WINDOWS内核编程》
colorknight
2003/4/3
本文地址:http://com.8s8s.com/it/it1864.htm