在多线程环境下,线程同步是不可避免的话题。Windows环境下的线程同步分为:用户态同步 与 内核态同步。
下面我们先了解用户态同步的一些方法。
- 使用Interlocked系列函数。简单易用的函数。
- 关键段。用来对关键资源实现独享访问。
- Slim读写锁。灵活的进行写独享读共享操作。
- 条件变量。当线程要进行较为复杂的条件进行同步时,可以实现。
Interlocked系列函数。
Windows提供了Interlocked系列函数,用来原子的对数据进行增减或交换。如自旋转锁,就可以通过InterlockedExchange函数实现。
BOOL g_fResourceInUse = FALSE;
void Func1()
{
// InterlockedExchange会一直原子设置g_fResourceInUse为TRUE,同时返回g_fResourceInUse
// 上一次的值。当g_fResourceInUse为初始值FALSE或被另一线程设置为FALSE后,该循环才会结束。
// 以此自循环(自旋锁)方式来实现对资源的独占访问。
while (InterlockedExchange(&g_fResourceInUse,TRUE) == TRUE)
Sleep(0);
// Access the resource, do something
...
// Do not need the resource anymore, release it
InterlockedExchange(&g_fResourceInUse,FALSE)
}
注意,这种循环方式会占用CPU大量时间,不建议在单CPU机器上运行。(可以用关键段代替或用C++11标准中的atom系列函数代替)
关键段
上面使用自旋锁的方式进行同步显然是低效的。因为等待线程依然处于可调度状态,仍然会占用CPU时间。Windows提供了一系列函数,让线程同步。这一系列函数保证了在线程获得想要的资源之前,不被CPU调度,直到其要求的资源可被线程访问为止。关键段就是其一,其实关键段的实现是通过事件内核对象的。
运用关键段五个步骤:
1、声明一个可以被多个线程访问到其地址的关键段变量。
2、在使用关键段前,调用InitializeCriticalSection函数初始化关键段。
2、在进入资源前调用EnterCriticalSection,请求进入关键段(若进入不了,则线程等待)
3、在离开资源时,调用LeaveCriticalSection,离开关键段。
若确定了关键段已经不被任何线程再使用,则要销毁关键段对象。
4、在不再使用关键段时,调用DeleteCriticalSection销毁关键段。
CRITICAL_SECTION g_cs;
int g_sum = 0;
//初始化关键段,注意不要多次初始化,否则后果是未定义的
<pre name="code" class="cpp">InitializeCriticalSection(&g_cs);
void ThreadFunc1()
{
EnterCriticalSection(&g_cs);
g_sum++;
LeaveCriticalSection(&g_cs);
}
void ThreadFunc2()
{
EnterCriticalSection(&g_cs);
g_sum++;
LeaveCriticalSection(&g_cs);
}
...
...
// 不再使用critical section,显示销毁
DeleteCriticalSection(&g_cs);
关键段最容易忘记
LeaveCriticalSection
,这时候可以用RAII技巧来进行简单的封装。
关于关键段的细节
1、若一个线程已经成功进入关键段,则可以多次调用EnterCriticalSection,相应的,要调用多次LeaveCriticalSection来离开临界区。
2、对于跨进程的线程同步,可以使用mutex对象。
可以使用TryEnterCriticalSection进入关键段,他不会使线程进入等待,而是返回布尔值表示是否获得了关键段。对于返回TRUE,需要调用LeaveCriticalSection。
关键段与旋转锁
当线程由于得不到关键段而进入等待状态时,会进行用户态和内核态切换,这会占用大量的CPU时间。在多处理器的环境下,可能的一种情况是,用户/内核态的切换还未结束,占用关键段的线程可能已经释放了关键段。
在多处理器的情况下,可以使用
InitializeCriticalSectionAndSpinCount
函数来初始化关键段。其函数原型如下
BOOL WINAPI InitializeCriticalSectionAndSpinCount(
_Out_ LPCRITICAL_SECTION lpCriticalSection,
_In_ DWORD dwSpinCount
);
其中参数dwSpinCout用来设置旋转锁循环次数。 SetCriticalSectionSpinCount 可以重设自旋转锁次数。
该函数会在进入内核态前,旋转设置的循环次数来获取关键段。若在旋转锁阶段获取关键段,则不会进入内核态。
注意,在单CPU模式下,dwSpinCout是被忽略的,总是为0。因为在单CPU下,
InitializeCriticalSectionAndSpinCount
是没有意义的:CPU在旋转锁阶段被线程占用,其他线程根本没有时机来释放关键段。但我们仍可以这样初始化关键段,以应对未来可能的多CPU环境。
Slim读写锁
一般的,对于线程的同步,读是可以共享的,而写则是互斥的。因此Windows提供了读写锁机制。
与关键段类似,在使用读写锁之前,要调用InitializeSRWLock函数初始化读写锁。
利用读写锁要分清读者和写者。
读写锁使用步骤
1、声明SRWLOCK对象。
2、用InitializeSRWLock函数初始化SRWLOCK对象。
3.1、对于读者,调用
以共享的方式获取,释放的读写锁。若该锁没被占用或被其他线程读,则立即获得锁,否则等待。
3.2、对于写者,调用
以独占的方式获取,释放的读写锁。若该锁未被占用,则立即获得锁,否则等待。
Slim与关键段的对比
Slim锁与关键段主要有以下两点区别
1、Slim锁不能够递归获取,即当一个线程Acquire并获得Slim锁之后,不能够再次Acquire同一把锁。
2、不存在TryEnter类似函数获取Slim锁。
3、Slim锁不用显示销毁,系统会自动释放。
4、总体上说,Slim锁的效率优于关键段。
多种同步方法的效率对比
条件变量同步
有时候需要线程原子方式释放获得的锁同时阻塞自身,直到某一条件成立为止。这时候可以通过条件变量进行同步。
等待条件变量函数。当条件被满足,线程被唤醒后,会自动得到锁。
唤醒等待条件的线程函数