分类: C/C++

C++中实现多线程安全的单体类

最近看了一些算是比较高大上的C++代码,被内力震伤了,赶紧记录下来!最最基础的就是这个:单体类。单体是面向对象中一种非常流行的设计模式,C++的实现百度一下可以找到一坨,但这个稍稍有点特殊——多线程安全。

普通版本的单体类实现如下:

乍一看似乎完全没有问题,不过如果这个单体类运行在多线程环境中,将会有可能创建多个实例。临界区出现在Instance()函数中创建单体对象的部分,即静态变量m_Instance!当访问该变量判断单体是否已被创建时,如果不进行临界区保护,很有可能会造成多个线程同时进入临界区,创建了多个Singleton对象,Boom…

知道了问题解决方法也就明了了,对临界区m_Instance进行保护即可:

原理非常简单,对m_Instance变量的访问使用自旋锁进行了加锁,这样使多线程的访问不会同时进入临界区,消除了隐患。这里做一点思考:

  1. 为什么使用自旋锁而不用互斥锁?
  2. 为什么不使用pthread中提供的自旋锁实现?

第一个问题实际上涉及到一些trade off的问题,自旋锁和互斥锁虽然都起到了互斥访问的作用,但是行为并不相同,互斥锁当无法获取锁时挂起线程,锁被释放后由系统将线程唤醒,是一个sleep, wake up的过程。而自旋锁则不放弃CPU,不断循环直到锁被释放后获得锁继续执行。

互斥锁的优点很明显,当无法获取锁时立即挂起线程,将CPU交给其他线程,有效利用了CPU时间,但是缺点在于sleep, wake up过程本身就有不小的开销;自旋锁避免了sleep, wake up造成的开销,但因为一直占用CPU直到获得锁,也带来了CPU时间的浪费。

那么如果在单核的条件下,自旋锁不会主动释放CPU,则锁的持有者必然无法释放锁,自旋锁只能不断循环直到CPU时间耗尽系统执行线程调度,如果不巧临界区执行时间又比较长,那么造成的CPU资源浪费反而会超过互斥锁的sleep, wake up的代价;然而在多核的条件下,即使自旋锁占据了CPU,锁仍然有可能会被其他CPU上执行的线程释放,这样对于像单体这样临界区极短的环境,自旋锁的性能就会优于互斥锁。

不过如今pthread提供的互斥锁都使用了优化,在极短时间内先进行自旋,如果没有获得锁再将线程挂起,因此采用互斥锁往往是比较优先的选择,这也是后话了…

第二个问题我也并不是十分理解,难道是出于移植性的考虑么?既然看到这了,那么顺藤摸瓜看看在用户空间的自旋锁是如何实现的:

static inline、内存屏障、原子操作…似乎有种回到了Linux内核代码的感觉,内存屏障是为了避免CPU对指令进行多线程不安全的重排(见这里),而原子操作则是为了保证获得锁操作的原子性,如果没有这个保证,我们自旋锁的实现就变得没有意义了。除去这些特殊的技巧,代码本身的运行逻辑简单直接:不断循环直到获得锁。并且为了减少对CPU时间的浪费,每运行一段时间后都要调用sched_yield()来主动让出CPU。

到这还不算完,自旋锁中的原子操作居然也是自己实现的…难道真是为了可移植性么?

这一段代码实际上就是一条嵌入式汇编指令,调用了xchg指令,我对指令集的了解还仅仅停留在《深入理解计算机系统》里面讲的一些粗浅的入门知识,不能胡乱分析,待以后学习深入了再回来补上。


学艺不精,随便看看别人的代码就各种惊叹。
痛改前非,准备好好复习+学习下C++,感觉自己弱弱哒!

  • 胡萝卜

    1. IsCreated() 这个函数应该是可以不用加锁的吧,加锁只是为了保持代码的一致性?..
    2. 看C/C++里面嵌入的汇编代码很捉急+1. 😉
    3. Singleton 这种模式用的还是很广,熟悉一些现用的案例很有用的,比如spring框架就用了这个中,我被问道过,当时傻了……

    • 用的挺多的,就是看到这个多线程安全的实现感觉很屌,代码还是厂里lsd写的…
      IsCreated()这个相当于是读写者问题,没有读写锁用自旋锁解决还是锁上好一点,可以保证在写入的时候不会被读出无效值,用读写锁来解决应该是更好的解决方法。