深入探索并发编程系列(二)-总是使用轻量级锁

在多线程编程中,我们会常说起锁(也叫互斥量)。但锁仅仅是个概念,要真正用上锁,需要将其实现。事实证明,存在许多实现锁的方法,不同方法在性能表现上却有很大的区别。

Windows SDK为C/C++提供了锁的两种不同实现:Mutex和Critical Section。(正如Ned Batchelder 这篇文章指出的那样,Critical Section可能并不是对锁命名的最好方式,我们在这里就不去深究了)

Windows的Critical Section就是我们说到的轻量级锁。它是专为没有其它线程竞争锁的情况下而优化的。为了说明这点,下面举个例子,有个单线程lock/unlock一百万次,使用的是Windows的Mutex。

1
2
3
4
5
6
7
HANDLE mutex = CreateMutex(NULL, FALSE, NULL);
for (int i = 0; i < 1000000; i++)
{
WaitForSingleObject(mutex, INFINITE);
ReleaseMutex(mutex);
}
CloseHandle(mutex);

用Windows的Critical Section也做同样的实验:

1
2
3
4
5
6
7
8
CRITICAL_SECTION critSec;
InitializeCriticalSection(&critSec);
for (int i = 0; i < 1000000; i++)
{
EnterCriticalSection(&critSec);
LeaveCriticalSection(&critSec);
}
DeleteCriticalSection(&critSec);

如果你分别在两种情况下的内部循环中添加计时代码,然后将结果除以一百万,就能得到一对lock/unlock操作的平均时间注1. 我照做了,并且在两个不同的处理器上做过实验。结果如下:

lightmutex

Critical Section要快25倍。就像Larry Osterman 在Why don’t critical sections work cross process?文章中解释的那样,每次使用Windows Mutex的时候都会进入到内核,Critical Section却不会。但其为此付出的代价是进程间不能共享Critical Section. 但是谁又会在意这个呢?绝大多数时候,你只想在单个进程内保护一些数据而已。(当然实际上也存在进程间共享轻量级锁的情况–那时候就别用Critical Section了)。细节可以参考下一篇文章:自己动手实现轻量级锁

现在假设你有个线程每秒钟获取Critial Section 100000次,这时没有其它线程竞争这把锁。基于上面得出的结果,锁的开销应该在0.2%和0.6%之间。不算太坏!在更低的频率下,开销就可以忽略不计了。

其它平台

在MacOS 10.6.6下,提供了POSIX Threads API来实现锁。这是一种轻量级锁,除非存在竞争,否则是不会进入到内核的。在非竞争条件下,调用pthread_mutex_lock/pthread_mutex_unlock操作需要92ns(基于1.86GHz的Core双核CPU).有趣的是,当仅仅只有一个线程运行的时候,它能够检测到,这时会切换到一条trivial的代码路径中去处理,也仅需要38ns的时间。

MaxOS还提供了NSLock,一个Objective-C类,不过这仅仅是对前面提到的POSIX互斥量的封装。由于每个操作都要调用objc_msgSend方法,开销会大一些:需要155ns(基于上述CPU测试),如果是单线程的话,需要98ns.

当然,Ubuntu 11.10也是用POSIX Threads API来实现锁的注2。这是另一种轻量级锁,其基于一种被称为futex的Linux特有的基础设施. 调用pthread_mutex_lock/pthread_mutex_unlock操作需要66ns. 你也可以在进程间共享锁,但我没测试过注3

甚至连Playstation 3 SDK也提供了轻量级锁和重量级锁供大家选择。回想2007年,那时我参与开发的Playstation 3 游戏中,使用的还是重量级锁。当切换到轻量级锁的时候,游戏启动时间能加快17s. 对我来说,这种差异真是一针见血(hit home)。

在我 上一篇 文章中,我为“锁是慢的”这个结论作了辩解并提供了一些支持我观点的数据。现在大家应该清楚,当你使用的不是轻量级锁的时候,整个观点便毫无意义了。我很清楚,由于重量级锁的存在让大家在过去几年对锁加深了一定的误解。

一些前辈可能会说在以前的老平台下,只有重量级锁的实现,或者说那时只能用信号量来完成任务。但现在大部分平台都提供了轻量级锁的实现。就算平台没有提供,你也可以在应用层实现自己的轻量级锁,如果你可以容忍一些弊端和警告的话,甚至还可以在进程间共享锁。我的下一篇文章将会告诉大家如何自己动手实现轻量级锁.

译者注

注释1: 当然,合理的计时框架应该是这样的:

1
2
3
4
5
6
7
log start time;
for (int i = 0; i < 1000000; i++)
{
lock();
unlock();
}
log end time;

不过这里的结果是墙上时钟(Wall Clock),而不是CPU时间。

注释2: Linux下提供了两种轻量级锁,分别为pthread_spinlock_tpthread_mutex_t。前者属于busy loop类型,当没有获得锁时会通过一个while循环进行等待;对于后者,当线程无法成功拿到锁时会调用system_wait,当前线程加入到mutex对应的等待队列中。一般来说,当临界区较短或者一个core上只有一个线程时,和mutex相比,使用spin lock性能较佳,因为避免了上下文切换。

注释3:使用如下代码可以创建一把用于进程互斥的mutex:

1
2
3
4
5
pthread_mutexattr_t mutexattr;  
pthread_mutexattr_init(&mutexattr);
pthread_mutexattr_setpshared(&mutexattr,PTHREAD_PROCESS_SHARED);
//there is a mutex...
pthread_mutex_init(&mutex,&mutexattr);

当前,前提是mutex位于进程间的共享内存上。另外,进程间通信和互斥一般不使用mutex。

Acknowledgement

本文由Diting0x睡眼惺忪的小叶先森 共同完成,在原文的基础上添加了许多精华注释,帮助大家理解。

感谢好友小伙伴-小伙伴儿 skyline09_ 阅读了初稿,并给出宝贵的意见。

原文: http://preshing.com/20111124/always-use-a-lightweight-mutex/

本文遵守Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0)
仅为学习使用,未经博主同意,请勿转载
本系列文章已经获得了原作者preshing的授权。版权归原作者和本网站共同所有

攒点碎银娶媳妇