上一篇讨论有关“互锁函数家族”处理线程同步的方法。本书中还描述了“高速缓存行”(cache line)概念,还给了一些线程同步的建议,比如要实现单个变量的同步,应该避免使用volatile关键字,如果实在需要对单个变量进行同步,最好使用互锁函数(传递的是地址,所以每次取值都从内存取得)。感觉这些东西没有什么好讲的,看看书就可以了。
然后,本书提供了另外一种工作在用户模式的线程同步的方法:关键代码段。虽然不能协调多个进程中的线程,但是我确实最经常使用这种机制来协调单个进程中线程的同步(因为好用^_^)。
关键代码段,所谓“代码段”,也就是说“一段代码”。这段代码的执行是以原子的形式执行的,独占着某些资源,任何想要访问这些资源的其他线程在关键代码段执行完成之前只能等待。
要让实现关键代码段,需要以下5个步骤:
1、初始化一个关键代码段结构。
2、一个线程进入关键代码段。
3、对资源进行原子操作。
4、该线程离开关键代码段。
5、删除关键代码段。
上述5个步骤,除了第3步是程序员需要自己进行设计,其他都有相应的API函数可以被调用。
首先看下关键代码段结构:CRITICAL_SECTION。这个数据结构是有明确文档定义的,但是微软认为里面的内容不需要我们去了解。所以该结构内部的成员对我们来说是透明的。该结构在WinBase.h文件中被定义为RTL_CRITICAL_SECTION。
在第1步,你需要初始化这个结构,调用InitializeCriticalSection函数,传递一个该结构的指针。在该函数内部会设置CRITICAL_SECTION的内部成员。
在第5步,当你不再需要这个关键代码段的时候,你需要呼叫函数DeleteCriticalSection来清除CIRTICLA_SECTION结构,同样是传递一个该结构的指针。
当你调用InitializeCriticalSection函数初始化了一个关键代码段之后,你就可以让你的线程通过这个关键代码段来对某写资源进行原子访问。
在第2步,你需要进入一个关键代码段,呼叫函数EnterCriticalSection,同样传递一个已经初始化了的CRITICAL_SECTION结构指针。
当调用该函数的时候,会发生以下三种的处理:
如果没有其他线程在访问相关资源,那么该函数更新内部数据,指明当前线程已经被赋予访问权并立即返回,使得该线程可以继续执行。
如果CRITICAL_SECTION结构的成员变量指明了当前线程已经被赋予了访问权,则更新内部数据,指明该线程被赋予了多少次访问权(递增计数)。这种情况比较少见,只有在一个线程内部多次调用EnterCriticalSection函数才会发生。
如果CRITICAL_SECTION结构的成员变量指明了当前已经有一个线程被赋予了访问权,那么该函数将当前线程设置为等待状态。然后更新内部数据,一旦正在访问资源的线程离开的关键代码段(调用LeaveCritlcalSection,后面会讲),该线程就会处于可调度状态。
如果当前已经初始化了一个关键代码段cs,同时存在着2个线程:T1和T2。然后T1呼叫EnterCriticalSection(&cs)函数进入该关键代码段,然后T2也呼叫EnterCriticalSection(&cs),那么T2会进入等待状态,等待T1离开cs所代表的关键代码段后,T2才恢复到可调度状态。
实际上,等待的线程可能会超时,然后抛出一个异常。该时间数值由注册表中的一个值表示的:
HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager
该值默认为2592000s,即大约3 0天。
从内部来讲,EnterCriticalSection函数并不复杂,在内部使用互锁函数,执行的只是一些简单的测试。但是这些测试都是以原子的方式进行的。
你可以使用函数TryEnterCriticalSection来代替EnterCriticalSection。
该函数不会让呼叫的线程进入等待状态,相反,该函数返回一个BOOL变量,指明当前线程能否进入关键代码段。
该函数可以让线程快速地查看能否获取某些资源,如果不能,该线程可以继续做其他的事情。如果该函数返回TRUE,说明CRITICAL_SECTION的成员变量已经更新,可以进入对应的关键代码段了。
如果一个线程顺利地进入了关键代码段,那么意味着它可以独占某些资源,此时可以以特定的算法来对某些资源访问和操作了(对应步骤3)。
第4步,在一个线程对某些资源访问或操作完成之后,必须离开关键代码段,调用函数LeaveCriticalSection函数,同样传递一个CRITICAL_SECTION结构的指针。
调用该函数的时候,CRITICAL_SECTION的内部数据会被更新。该函数使计数递减1,指明当前线程被赋予的访问权次数。
如果递减后,该计数仍然大于0,则该函数什么也不做,只是简单的返回而已。
递减后,如果该计数等于0,它就更新成员变量,并查看那些因为调用EnterCriticalSection而处于等待状态的线程,如果存在这样的线程,它就更新成员变量,并选择其中一个,让其变成可调度状态;如果没有线程在等待,则更新成员变量,说明此时没有线程在访问资源。
也就是说,一个EnterCriticalSection函数必须有一个LeaveCriticalSection函数与之对应,否则一个线程会一直独占了某些资源,即使该线程结束之后,这些资源也被关键代码段锁定。而其他线程如果调用EnterCriticalSection进入该关键代码段是无法访问这些资源的。
下面讨论有关“关键代码段与循环锁”的问题。
如果一个关键代码段已经被其他线程所拥有,那么如果当前线程试图进入这个关键代码段的时候,会立即被设置为等待状态。意味着该线程必须从用户模式转入内核模式,大约需要1000个CPU周期。这种转换是需要付出代价的。实际上,在多CPU计算机上,当前拥有资源的线程可能正执行在另一个CPU上,这样,它很可能会马上离开关键代码段,释放相关资源。
为了提高关键代码段的性能,微软为关键代码段提供了循环锁机制。当一个线程调用EnterCriticalSection函数的时候,可以使用循环锁进行循环查询,这样就可以多次尝试访问资源。只有当每次尝试均告失败之后,该线程才转入内核模式。
如此一来,只要在这组尝试失败以前,原先占有资源的线程离开了关键代码段,那么该尝试访问资源的线程便可尝试成功,这样避免了转入内核模式的执行,提高的性能。
要将循环锁用于关键代码段,必须将一个关键代码段与一个循环次数关联起来,可以调用InitializeCriticalSectionAndSpinCount函数,即能够初始化关键代码段,也可以将一个循环锁查询次数与之绑定。
PCRITICAL_SECTION pcs, //关键代码段结构指针
DWORD dwSpinCount); //循环锁循环查询次数(尝试访问资源次数)
要注意的情况是,如果在单CPU的计算机上,该函数的第二个参数dwSpinCount会被忽略,永远为0,因为在单CPU上,如果一个线程在循环尝试请求资源,而当前拥有资源的线程不可能被调度,资源是无法释放的。
也可以修改一个关键代码段循环锁循环次数:
PCRITICAL_SECTION pcs, //关键代码段结构指针
DWORD dwSpinCount); //循环锁循环次数
当然,如果运行在单CPU计算机上,dwSpinCount参数会被忽略。
一般地,经验告诉我们,设置dwSpinCount为4000,即让线程循环4000次来尝试获取资源。
InitializeCriticalSection函数可能会运行失败(在资源极度贫乏的情况下),由于微软忽略了这个问题,所以它的返回类型是VOID。在这种情况下,你可以使用InitializeCriticalSectionAndSpinCount函数,它返回一个BOOL型数据,指明初始化关键代码段是否成功。
当使用关键代码段的时候,可能会出现对关键代码段的争用,即当前线程调用EnterCriticalSection函数的时候,该关键代码段的访问权已经被另一个线程所拥有,此时发生了争用。此时关键代码段使用事件内核对象处理线程同步问题。
当在内存资源极度贫乏的情况下,此时线程争用关键代码段,那么关键代码段可能无法创建必要的事件内核对象,这个时候EnterCriticalSection函数会产生一个EXCEPTION_INVALID_HANDLE异常,你可以采取一下两种方法处理之:
1、使用结构化异常的方法,当异常产生的时候,不访问关键代码段保护的资源,当内存变成可用状态的时候,再次呼叫EnterCriticalSection函数。
2、使用InitializeCriticalSectionAndSpinCount函数创建关键代码段的时候确保设置了dwSpinCount参数的高信息位,当该函数发现dwSpinCount的高信息位被设置,它会创建一个事件内核对象,并将该内核对象与关键代码段关联起来。如果事件内核对象无法创建,函数返回FALSE。如果创建成功那么就意味着EnterCriticalSection总能运行成功,因为总是先创建事件内核对象。