用户模式的线程同步

来源:互联网 发布:淘宝网优衣库旗舰店 编辑:程序博客网 时间:2024/05/16 05:12

在以下两种基本情况下,线程之间需要相互通信: 

  • 需要让多个线程同时访问一个共享资源,同时不能破坏资源的完整性;
  • 一个线程需要通知其它线程某项任务已经完成。

1.原子访问:Interlocked系列函数 
所谓原子访问,指的是一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问同一资源。 
我们需要有一种方法能够保证对一个值的递增操作时原子操作——也就是说,不会被打断。Interlocked系列函数提供了我们需要的解决方案。 
LONG InterlockedExchangeAdd
PLONG volatileplAddend, 
LONGlIncrement); 

LONGLONG InterlockedExchangeAdd64
PLONGLONG volatilepllAddend, 
LONGLONGllIncrement); 

只要调用这个函数,传一个长整形变量的地址和另一个增量值,函数就会保证递增操作是以原子方式进行的。 
Interlocked函数又是如何工作的呢?取决于代码运行的CPU平台。如果是x86系列CPU,那么Interlocked函数会在总线上维持一个硬件信号,这个信号会阻止其它CPU访问同一个内存地址。无论编译器如何生成代码,无论机器上装配了多少个CPU,这些函数都能够保证对值的修改时以原子方式进行的。
Interlocked函数执行得极快,调用一次Interlocked函数,通常只占用几个CPU周期(通常小于50),而且也不需要在用户模式和内核模式之间进行切换(这个切换通常需要占用1000个周期以上)。
当然,也可以用InterlockedExchangeAdd来做减法——只要在第二个参数中传入一个负值就行了。
下面是其它三个Interlocked函数:
LONG InterlockedExchange
PLONG volatileplTarget, 
LONGlValue); 
LONGLONG InterlockedExchange64
PLONGLONG volatileplTarget, 
LONGLONGlValue); 
PVOID InterlockedExchangePointer
PVOID* volatileppvTarget, 
PVOID pvValue); 

实现自旋锁的时候,InterlockedExchange及其有用: 
// Global variable indicatingwhether a shared resource is in use ornot 
BOOL g_fResourceInUse =FALSE; ... 
void Func1(){ 
// Wait to access theresource. 
while (InterlockedExchange (&g_fResourceInUse,TRUE) == TRUE) 
Sleep(0); 
// Access the resource.
... 
// We no longer need to access theresource.
 

InterlockedExchange(&g_fResourceInUse,FALSE);

} 
在单CPU的机器上应避免使用旋转锁 
PVOID InterlockedCompareExchange
PLONGplDestination, 
LONGlExchange, 
LONGlComparand); 
PVOID InterlockedCompareExchangePointer
PVOID*ppvDestination, 
PVOIDpvExchange, 
PVOIDpvComparand); 

该函数对当前值( plDestination 参数指向的值)与 lComparand 参数中传递的值进行比较。如果两个值相同,那么* plDestination 改为 lExchange 参数的值。如果* plDestination 中的值与lComparand 的值不匹配,* plDestination 保持不变。该函数返回* plDestination 中的原始值。记住,所有这些操作都是作为一个原子执行单位来进行的。 
2.高级线程同步 
如果我们只需要以原子方式修改一个值,那么Interlocked系列函数非常好用,我们当然应该优先使用它们。为了能够以“原子”方式访问复杂的数据结构,我们必须超越Interlocked系列函数。
我们既不应该使用旋转锁,也不应该进行轮循,因为浪费CPU时间是件很糟糕的事情。而应该调用函数把线程切换到等待状态,直到线程想要访问的资源可供使用为止。
volatile关键字:
volatile限定符告诉编译器这个变量可能被应用程序之外的其它东西修改,比如操作系统、硬件或者一个并发执行的线程。确切地说,volatile限定符告诉编译器不要对这个变量进行任何形式的优化,而是始终从变量在内存中的位置读取变量的值。给一个结构加volatile限定符等于给结构中所有的成员都加volatile限定符,这样可以确保任何一个成员始终都是从内存中读取的。
3.关键段 
关键段
 (criticalsection)是一小段代码,它在执行之前需要独占对一些共享资源的访问权。这种方式可以让多行代码以“原子方式”来对资源进行操控。即,代码知道除了当前线程之外,没有任何线程会同时访问该资源。当然,系统仍然可以暂停当前线程去调度其它线程。但是,在当前线程离开关键段之前,系统是不会去调度任何想要访问同一资源的其它线程的。
一般情况下,我们会将CRITICAL_SECTION结构作为全局变量来分配,这样进程中的所有线程就能够非常方便地通过变量名来访问这些结构。在使用CRIICAL_SECTION的时候,只有两个必要条件:第一条件是所有想要访问资源的线程必须知道用来保护资源的CRITICAL_SECTION结构的地址(我们可以通过自己喜欢的任何方式来把这个地址传给各个线程)。第二个条件是在任何线程试图访问被保护的资源之前,必须对CRITICAL_SECTION结构的内部成员进行初始化。
下面这个函数用来对结构进行初始化:
VOID InitializeCriticalSection(PCRITICAL_SECTIONpcs); 

当知道线程不再需要访问共享资源的时候,我们应该调用下面的函数来清理CRITICAL_SECTION结构:
VOID DeleteCriticalSection(PCRITICAL_SECTIONpcs); 

然后我们在以下两个函数之间访问共享资源:
VOID EnterCriticalSection(PCRITICAL_SECTIONpcs); 

。。。共享资源的访问。。。
VOID LeaveCriticalSection(PCRITICAL_SECTIONpcs); 

EnterCriticalSection会执行下面的测试:

  • 如果没有线程正在访问该资源, EnterCriticalSection便更新成员变量,以表示调用线程已被赋予访问权并立即返回,使该线程能够继续运行(访问该资源)。

 

  • 如果成员变量表明调用线程已经被赋予对资源的访问权,那么 EnterCriticalSection便更新这些变量,以指明调用线程多少次被赋予访问权并立即返回,使该线程能够继续运行。这种情况很少出现,并且只有当线程在一行中两次调用 EnterCriticalSection而不影响对LeaveCriticalSection的调用时,才会出现这种情况。

 

  • 如果成员变量指明,一个线程(除了调用线程之外)已被赋予对资源的访问权,那么EnterCriticalSection将调用线程置于等待状态。这种情况是极好的,因为等待的线程不会浪费任何CPU时间。系统能够记住该线程想要访问该资源并且自动更新 CRITICAL_SECTION 的成员变量,一旦目前访问该资源的线程调用 LeaveCriticalSection函数,该线程就处于可调度状态。

我们可以用下面的函数的函数来代替EnterCriticalSection
BOOL TryEnterCriticalSection(PCRITICAL_SECTIONpcs); 

TryEnterCriticalSection从来不会让调用线程进入等待状态 它会通过返回值来表示调用线程是否获准访问资源。如果资源正在被其它线程访问,那么返回值为FALSE,其它为TRUE。如果返回TRUE,那么CRITICAL_SECTION的成员已经更新过了,以表示该线程正在访问资源。因此,每个返回TRUE的TryEnterCriticalSection调用必须有一个对应的 LeaveCriticalSection 
当不能用Interlocked函数解决同步问题的时候,我们应该试一试关键段。关键段的最大好处在于它们非常容易使用,而且它们在内部也使用了Interlocked函数,因此执行速度非常快。关键段的最大缺点在于它们无法用来在多个进程之间对线程进行同步。 
当线程试图进入一个关键段,但这个关键段正被另一个线程占用的时候,函数会立即把调用线程切换到等待状态。这意味着线程必须从用户模式切换到内核模式(大约1000个CPU周期),这个切换的开销非常大。为了提高关键段的性能,Microsoft把旋转锁合并到了关键段中。因此,当调用EnterCriticalSection的时候,它会用一个旋转锁不断地循环,尝试在一段时间内获得对资源的访问权。只有尝试失败的时候,线程才会切换到内核模式并进入等待状态。 
为了在使用关键段的时候同时使用旋转锁,我们必须调用下面的函数来初始化关键段:
BOOL InitializeCriticalSectionAndSpinCount
PCRITICAL_SECTIONpcs, 
DWORDdwSpinCount); 

dwSpinCount是我们希望旋转锁循环的次数。 这个值可以是0~0x00FFFFFF之间的任何一个值。在单处理器的机器上调用这个函数,那么函数会忽略 dwSpinCount参数,因此次数总是0。因为如果一个线程正在循环,那么占用资源的线程将没有机会放弃对资源的访问权。
我们可以调用一下函数来改变关键段的旋转次数:
DWORD SetCriticalSectionSpinCount(

PCRITICAL_SECTIONpcs, 
DWORDdwSpinCount); 

用来保护进程堆的关键段锁使用的旋转次数大约是4000,这可以作为我们的一个参考值。
4.Slim读/写锁 
SRWLock的目的和关键段相同:对一个资源进行保护,不让其它线程访问它。但是,与关键段不同的是,SRWLock允许我们区分哪些想要读取资源的值的线程(读取者线程)和想要更新资源的值的线程(写入者线程)。让所有的读取者线程在同一时刻访问共享资源应该是可行的,这是因为仅仅读取资源的值并不存在破坏数据的风险。只有当写入者线程想要对资源进行更新的时候才需要进行同步。在这种情况下,写入者线程想要对资源进行更新的时候才需要进行同步。在这种情况下,写入者线程应该独占对资源的访问权:任何其它线程,无论是读取者线程还是写入者线程,都不允许访问资源。这就是SRWLock提供的全部功能。
首先,我们需要分配一个SRWLOCK结构并用InitializeSRWLock函数对它进行初始化:
VOID InitializeSRWLock(PSRWLOCKSRWLock); 

一旦SRWLock初始化完成之后,写入者线程就可以调用AcquireSRWLockExclusive,将SRWLOCK对象的地址作为参数传入,以尝试获得对被保护资源的独占访问权。
VOID AcquireSRWLockExclusive(PSRWLOCKSRWLock); 

完成对资源的更新之后,应该调用ReleaseSRWLockExclusice,并将SRWLOCK对象的地址作为参数传入,这样就解除了对资源的锁定。
VOID ReleaseSRWLockExclusice(PSRWLOCKSRWLock); 

对读取者线程来说,同样有两个步骤,但调用的是下面两个新的函数:
VOID AcquireSRWLockShared(PSRWLOCKSRWLock); 
VOID ReleaseSRWLockShared(PSRWLOCKSRWLock); 

不存在用来删除或销毁SRWLOCK的函数,系统会自动执行清理工作
与关键段相比,SRWLock缺乏下面两个特性:

  • 不存在TryEnter(Shared/Exclusive)SRWLock 之类的函数:如果锁已经被占用,那么调用AcquireSRWLock(Shared/Exclusive) 会阻塞调用线程。
  • 不能递归地调用SRWLOCK。也就是说,一个线程不能为了多次写入资源而多次锁定资源,然后再多次调用ReleaseSRWLock* 来释放对资源的锁定。

 

线程/微妙

Volatile 

读取

Volatile 

写入

Interlocked 

递增

Critical Section

关键段

SRWLock

共享模式

SRWLock 

独占模式

Mutex

互斥量

1

8

8

35

66

66

67

1060

2

8

76

153

268

134

148

11082

4

9

145

361

768

244

307

23785

 

表1:同步机制的性能比较 

总结一下,如果希望在应用程序中得到最佳性能,那么首先应该尝试不要共享数据,然后依次使用volatile读取,volatile写入,InterlockedAPI,SRWLock以及关键段。当且仅当所有这些都不能满足要求的时候,再使用内核对象。因为每次等待和释放内核对象都需要在用户模式和内核模式之间切换,这种切换的CPU开销非常大。
5.一些有用的窍门和技巧 

  • 以原子方式操作一组对象时使用一个锁;
  • 同时访问多个逻辑资源时以完全相同的顺序来获得资源的锁;
  • 不要长时间占用锁;