双重检查锁与单例模式

单例模式是比较常见的一种设计模式,在开发实践中经常看到它的身影,它有很多种实现方式,曾经有人在一篇文章中列举了十几种实现方式,比如饿汉式、懒汉式、双重检查锁、枚举。。。等等,程序员应该都熟悉这些常见的实现方式。本文对其中的一种实现方式-双重检查锁,介绍一下它的实现方式,分析为什么会出现乱序执行导致线程不安全,又是如何避免的。

我们知道单例模式的对象在进程中仅有一份,在多线程环境下为了防止创建出多个对象,需要对创建对象的过程进行互斥操作,这样,当多线程同时竞争时,保证只能由一个线程来创建这个唯一的对象。常见互斥操作的方式就是使用锁,比如互斥锁或者自旋锁。如下面的C++11代码片段就是使用mutex锁来实现的:

class lockSingleton {
	static mutex lock;
	static lockSingleton *instance;
	
public:
	static lockSingleton *getInstance() {
		lock_guard<mutex> guard(lock); // 每次地调用都要获取互斥锁 
		if (instance == nullptr) {
			instance = new lockSingleton();
		}
		
		return instance;
	}
	
};

这种实现方式的最大的缺点就是,调用getInstance()来获取单例对象时,都要无条件的申请锁,哪怕是单例对象已经成功创建了。也就是说,如果调用了n次getInstance(),可能只有一次是获得锁后并创建对象,而其余的n-1次调用,都是在获得锁后,发现instance不为空指针nullptr,接着就释放锁后直接返回了。

我们看一下和锁操作有关的系统开销,申请锁和释放锁用到了硬件提供的原子操作指令,而原子操作比非原子操作要慢得多,还要锁内存总线,尤其是如果申请不到锁,操作系统要把线程挂起,等到释放锁的时候还要唤醒一个线程,这些都要涉及到系统调用上下文和线程上下文的切换,是一个重量级的操作;再看一下互斥锁所保护的临界区代码,只有区区一个指针判空逻辑(当然第一次获得锁时还有创建对象的逻辑,不过仅有一次)。可见,相对于这个简单的逻辑判断来说,使用锁的开销实在太大了,也就是说一个线程在调用这个方法时,CPU的时间开销几乎都花在了锁的申请和释放上面,边际成本太高了,一点也不划算(而其它不相关的CPU也会造成“失速”,因为当使用硬件原子操作时,会锁定内存总线,造成其它CPU都无法访问内存,此时CPU若要访问内存时就得先等待,直到内存总线锁释放)。既然当对象创建成功以后,每次调用该方法时,指针变量instance肯定是不为nullptr的,那么,何不针对这一特点进行优化呢?

“双重检查锁”就是针对这种场景的优化方式,方法就是在申请锁之前先检查instance是否为空指针,只有当instance为空指针时,才进入申请锁的流程,这样可以避免绝大多数不必要的加解锁操作。这个方法所体现的思想是,先使用低成本的代码检查条件是否满足,如果满足了,再使用带有锁的代码逻辑进行高成本的检查,因为先后共有两次逻辑检查,所以称为“双重检查锁”。下面看一下双重检查锁的完成代码片段:

static dclSingleton *getInstance() {
        if (instance == nullptr) {          ①
            lock_guard<mutex> guard(lock);
            if (instance == nullptr) {②
                instance = new dclSingleton();}
        }
        
        return instance;
    }

我们分析一下这段代码,假设一个线程A在①处读取instance时,另一个线程B获得了锁,正在运行临界区的代码,如果③已经执行了,那么instance就不会是nullptr,线程A直接返回,如果③还没有执行,instance为nullptr,线程A就申请锁,当B执行完③离开临界区时释放锁,A获得锁后执行到②处发现instance已经不为nullptr了,就直接返回。实现机制非常优雅,开销也非常低,一个简单的逻辑判断就能避免锁的操作,貌似是线程安全的,但遗憾的是,仍然无法百分百地保证解决线程安全问题。

从上面的代码可以看到,instance是一个共享变量,它是被多个线程共享使用的,为了线程安全,instance应该作为临界资源进行保护。在程序中有两处访问它的地方,分别在①处和③处,可以看到,在③处使用mutex互斥锁进行了保护,而在①处并没有使用mutex互斥锁进行保护。根据互斥锁的特性,释放锁时有release内存语义,获取锁时有acquire内存语义,按说在①处是对instance进行读,应该有一个acquire语义,保证它和③处释放锁时保持一个release-acquire语义。这样,当在①处读取instance时,如果instance不为nullptr,就能保证③处对单例对象的数据成员的写操作happen-before于①处之前,也就保证了在①处读取instance时,单例对象已经初始化完成,这是锁的内存序语义保证的。然而由于①处的instance访问不在被锁保护的临界区之内,没有任何互斥性及顺序性保证,也就是代码在①处执行时,它被”破防“了,突破了mutex互斥锁保护,不管临界区里面的代码执行顺序如何,可以直接读取临界资源。

我们不妨使用一个实际例子来分析,下面是用C++11编写的一个双重检查锁的单例代码,对象有两个int型数据成员,代码非常简单。

#include <atomic>
#include <mutex>
#include <cstdio>
using namespace std;

class dclSingleton {
    int x;
    int y;
    static mutex lock;
    static dclSingleton *instance;

    dclSingleton() {
        x = 0;
        y = 1;
    }

    dclSingleton(const dclSingleton &) = delete;
    dclSingleton &operator=(const dclSingleton &) = delete;

public:
    static dclSingleton *getInstance() {
        if (instance == nullptr) {
            lock_guard<mutex> guard(lock);
            if (instance == nullptr) {
                instance = new dclSingleton();
            }
        }
        
        return instance;
    }
    
    void print() {
        puts("一个毫无实际用处的例子");
    }
};

dclSingleton *dclSingleton::instance = nullptr;
mutex dclSingleton::lock;

int main() {
    dclSingleton *p = dclSingleton::getInstance();
    p->print();
}

我们先看创建对象的代码:instance = new dclSingleton();如果我们把代码使用placement new来实现的话,这句话等同于:

dclSingleton *p = (dclSingleton *)::operator new(sizeof(dclSingleton)); // 第一步
new(p) dclSingleton(); // 第二步
instance = p; // 第三步

实际上C++编译器使用new操作符在堆中创建一个对象时,也是按照这个步骤处理的,即分成了3个步骤,第一步是为对象分配内存块;第二步是对这段内存块进行初始化,也就是调用构造函数;第三步才是把this指针赋值给静态变量instance。

尽管C++语义上把创建对象分成了有前后依赖顺序的三个步骤,但编译器在编译优化时,可能会对代码进行重排序,让CPU乱序执行,即有可能让第三步提前到第二步执行。我们从源码中看到,第二步是一个函数调用,而第三步是一个指针赋值操作,它们怎么会又是如何重排序的呢?当然,这里的重排序并不是说编译器在C++源代码级别上把语句重新排序了,而是指在生成底层的汇编指令时,优化器有可能根据优化策略对指令进行重排序,也就是说重排序是编译器在指令级优化的结果。如果为了避免调用构造函数的额外开销,编译器进行了内联(inline)优化,那么整个函数体的指令代码就会在第2句的位置处展开,在此基础上,优化器还会把这些展开以后的指令和它周边的指令在同一个上下文中综合考虑,这样,内联展开后的指令和周边指令有可能发生重排序,从而让第3句的赋值指令安排在构造函数中的一些初始化代码的前面。因此,从宏观上看,就好像是源代码的第二句和第三句重排序了。

可以借助于工具来查看这段程序编译后的汇编代码,在https://godbolt.org/网站上,把上面那段程序粘贴上去,可以查看生成的汇编代码,选择使用x86-64 gcc编译器进行编译,并打开编译优化选项-O2。因为使用了优化选项,编译器对函数进行了内联处理,会把main函数中第一行getInstance()函数的调用进行了内联优化,所生成的汇编代码及其注释如下:

.LC0:
        .string "\344\270\200\344\270\252\346\257\253\346\227\240\345\256\236\351\231\205\347\224\250\345\244\204\347\232\204\344\276\213\345\255\220"
main:
        push    rbp
        push    rbx
        sub     rsp, 8
        cmp     QWORD PTR dclSingleton::instance[rip], 0 // 对应第一次if (instance == nullptr) 判断instance是否是空指针
        je      .L22
.L3:
        mov     edi, OFFSET FLAT:.LC0
        call    puts
        add     rsp, 8
        xor     eax, eax
        pop     rbx
        pop     rbp
        ret
.L22:
        mov     ebx, OFFSET FLAT:_ZL28__gthrw___pthread_key_createPjPFvPvE
        test    rbx, rbx
        je      .L7
        mov     edi, OFFSET FLAT:dclSingleton::lock
        call    __gthrw_pthread_mutex_lock(pthread_mutex_t*) // 申请互斥锁
        test    eax, eax
        jne     .L23
        cmp     QWORD PTR dclSingleton::instance[rip], 0  // 对应第二次if (instance == nullptr) 判断instance是否是空指针
        je      .L7// 当为空指针时跳转到进入L7位置,开始创建单例对象
.L8:
        test    rbx, rbx
        je      .L3
        mov     edi, OFFSET FLAT:dclSingleton::lock
        call    __gthrw_pthread_mutex_unlock(pthread_mutex_t*)
        jmp     .L3
.L7:
        mov     edi, 8
        call    operator new(unsigned long)  // 分配内存
        mov     edx, 1
        mov     QWORD PTR dclSingleton::instance[rip], rax// 指针赋值
        sal     rdx, 32
        mov     QWORD PTR [rax], rdx // 初始化数据成员x和y
        jmp     .L8
.L23:
        mov     edi, eax
        call    std::__throw_system_error(int)
        mov     rbp, rax
        jmp     .L10
main.cold:
dclSingleton::lock:
        .zero   40
dclSingleton::instance:
        .zero   8

把构造单例对象的代码片段取出来,同样,代码中没有调用构造函数的指令,因为构造函数太简单了,只有两行简单的赋值语句,编译器也对它进行了内联优化。汇编代码片段及注释如下:

mov	edi, 8 // 单例里面有两个int型的成员,它们的占用的内存空间是4*2=8字节,其中x位于低4字节,y位于高4字节。
call	operator new(unsigned long) //使用operator new操作符分配8个字节的内存空间,返回的地址存放在rax寄存器中,即this指针
mov	edx, 1 // 对应y = 1,暂时存放于rdx寄存器的低4字节的寄存器edx中
mov	QWORD PTR dclSingleton::instance[rip], rax // 把this指针赋值给instance
sal	rdx, 32 // 寄存器rdx左移32位,也就是它的高32位是1,对应数据成员y,低32位是0,对应数据成员x,
mov	QWORD PTR [rax], rdx // 为this指针指向的8个字节赋值,即调用构造函数

先解释一下第5条指令,它是把1,0这两个长度为4字节的int型常数合并成了一个8字节的常数,这样只写一次内存就可以同时初始化两个长度为4字节的变量。x位于this(即寄存器rax)指向的位置,而y位于this+4指向的位置,当第6条指令执行完之后,x=0, y=1,即完成了构造函数的调用。

代码段中第1、2行是分配内存,第4行是为变量instance赋值,第3、5、6行是调用构造函数(当然是编译器内联优化之后的代码)。从这里可以看到,在创建单例对象时的步骤为:

  1. 为单例对象分配内存块
  2. 把分配好的内存块地址赋值给instance,注意此时内存块还没有被初始化
  3. 初始化这块内存块

编译器在优化时,把调用构造函数的代码进行了内联展开,并且展开后的代码和第三步的指针赋值一块进行了优化分析,把指针赋值的操作提到前面去执行,这就是所谓乱序执行,也就说编译器优化代码后打乱了程序的执行顺序。为了方便理解,把它翻译成C++语言:

dclSingleton *p = (dclSingleton *)::operator new (sizeof(dclSingleton)); // 第一步
instance = p; // 第三步
new(p) dclSingleton(); // 第二步

显然,当一个线程执行完第2行代码还未执行第3行的时候,被抢占了,如果另一个线程刚好执行到第一次if语句的空指针检查处,此时就会得到一个已经成功赋值不为nullptr的instance,但它指向的却是一个还没有初始化的单例对象。

那么为什么编译器要对指令进行重排序呢?
我们先分析下面这段创建单例对象的汇编代码,程序员在编程时心目中预想的可能是下面的流程,当然这也是编译器没有指定优化选项时生成的代码:

mov	edi, 8
call	operator new(unsigned long)
mov	edx, 1
sal	rdx, 32
mov	QWORD PTR [rax], rdx
mov	QWORD PTR dclSingleton::instance [rip], rax

其中寄存器rax是this指针,它存放了new操作符分配内存成功后返回的地址,当对instance进行赋值时,在编译器看来指令6和指令3、4、5之间都没有前后依赖关系(实际上在C++语言级别上是有依赖关系的,因为这是C++的语义,编程人员知道,编译器的前端也知道,但处于后端的优化器并不知道),既然指令之间没有依赖关系,优化器完全可以按照指令执行的最优化进行重新组合、排序。

指令3是对寄存器edx赋值,指令4是对寄存器rdx进行移位操作,edx是rdx的低32位,因此指令4依赖于指令3,而指令5是使用rdx为rax指向的内存块赋值,即调用内联后的构造函数,rdx的值依赖于指令4的执行结果,因为这些指令之间有前后依赖关系,可见指令3、4、5之间的顺序是不能改变的。

指令6完全可以放在最后执行,符合程序的编程顺序,也符合程序编程人员的心理预期,为什么编译器把它提前了呢。

因为为了性能,既然编译时指定了优化选项,优化器就会根据程序执行的上下文,对前后没有依赖关系的指令执行顺序进行重排序,让CPU执行时可以更合理的分配执行资源,以达到最佳的执行效果。

现代CPU处理器为了提高执行性能,除了在提升时钟频率和使用多核外,同时对单个CPU的处理能力也进行了优化,比如指令多发射机制,CPU的执行单元有多个执行端口(execution port),即CPU在执行指令时,可以一次性的派发多个指令,只要指令之间没有对执行资源形成竞争的话,它们可以并行执行。

前面说过指令3、4、5因为有前后依赖关系,只能依次按顺序执行,在编译生成汇编代码时,不会对它们进行乱序。由于指令4只能等待指令3执行完之后才能执行,既然指令6和前面的指令3、4、5没有依赖关系,那就把它提前到指令3之后,指令6访问内存,指令3只对寄存器赋值并不访问内存,它们之间没有对内存总线的竞争,完全可以同时执行,这样CPU可以同时发射这两个mov指令,从而提高程序的运行速度。但指令6不能放在4之后,因为接下来的指令5也访问内存,这样紧挨着的指令6和5都访问内存,两个不同的执行端口可能会对内存总线总成竞争,无法同时发射这两个mov指令。

当然,如果这段代码运行在没有多发射机制的CPU上,这两个MOV指令就一个一个的执行,只是性能差了点,并不会影响执行结果。显然编译器优化后的代码,无论CPU有没有多发射机制,都可以正确执行,而且在有多发射机制的CPU上还能提高运行速度,编译器这样做是再合理不过了。

不过,优化器不但不知道上层的C++语义,而且也不知道代码的运行环境是单线程还是多线程,都是按照单线程的执行环境进行优化处理。可见,指令乱序执行是一把双刃剑,带来了性能提升的同时,也为在多线程运行环境下带来了内存顺序不一致隐患。

可见,造成双重检查锁实现单例模式线程不安全的原因,一是第一次逻辑检查时临界资源没有使用锁保护,二是临界区的代码乱序执行。只要这两个问题同时存在,就无法解决双重检查锁的问题。如果使用锁为第一次逻辑检查保护,就又走回使用“双重检查锁"优化前的老路子了,显然是行不通的,如果要解决双重检查锁的缺陷,只能在程序执行顺序上考虑:

1、使用-O0编译
不允许编译器优化,保证程序不乱序执行,但是程序的性能可能差一些。

2、指定内存序
让编译器优化时别对程序进行重排序,方法就是使用内存屏障进行内存顺约束。
我们看下面的C++代码片段,这段代码中在调用构造函数和为instance赋值之间添加了一条指定内存屏障的语句,指定使用release语义的内存屏障,告诉编译器不要让dclSingleton* p = new dclSingleton();越过屏障发生在instance = p;的后面。

    static dclSingleton *getInstance() {
        if (instance == nullptr) {
            lock_guard<mutex> guard(lock);
            if (instance == nullptr) {
			dclSingleton* p = new dclSingleton();
			atomic_thread_fence(memory_order_release);  // 在调用构造函数和为p赋值之间添加一个内存屏障
			instance = p;
            }
        }
        
        return instance;
    }

使用相同的优化选项进行编译,生成的汇编代码如下:

mov     edi, 8
call    operator new(unsigned long)
mov     edx, 1
sal     rdx, 32
mov     QWORD PTR [rax], rdx
mov     QWORD PTR dclSingleton::instance[rip], rax

可见,指令没有发生乱序,保证instance指针初始化后,指向的是一个已经完全构造好的对象。

如果在Java环境下,可以通过使用volatile修饰instance变量来保证内存序,这样,在对instance赋值时,编译器生成的指令也不会进行重排序,能够保证对单例对象指针的赋值不会早于对象中的数据成员的初始化。还有一种方式,如果对象中的数据成员都是使用final修饰的话,编译器也能够保证各个数据成员的初始化不会晚于对单例指针的赋值,这样也保证了调用构造函数初始化时不会重排序。这些volatile和final都是JVM的内存语义保证的,不过在对volatile修饰的变量进行访问时,底层一般使用了lock指令前缀,会竞争内存总线,相当于C++中“memory_order_seq_cst”类型的内存序,因为JVM的内存模型要求volatile保证的是强一致性,相对其它弱序的内存序语言,要低效一些。

双重检查锁除了用在单例模式实现外,在C++11中也用在了call_once()、函数内static变量的初始化、weak_ptr.lock()等的实现。此外,在优化自旋锁的实现时,也大都使用双重检查锁的实现方法:先用性能高的代码进行自旋(称作本地自旋),当有可能获得锁的时候,退出本地自旋,再使用性能低的CAS操作来获取自旋锁。

总之,双重检查锁在多线程访问锁时,可以有效地避免频繁的加解锁操作。不过,使用双重检查锁时一定要指定内存顺,防止出现指令的重排序现象,如果不加注意,可能会引起严重的后果。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值