多线程下的单例模式

单例模式可能是应用得最广泛的设计模式之一,也是学习设计模式必学、面试必问的模式之一🤣,那么就很有必要搞清楚单例模式是一个什么东西了。这里着重讨论一下多线程下的单例模式,Singleton(单例模式)的基本概念就不再展开了。一般来说,应用单例模式的场景为要求全局只存在一个单例类的实例,如果该单例类产生了多个实例可能会对系统造成影响或损害,需要限制为1个。

It ensures that the Singleton class has only one instance and provides a global access point for it. We can use a Singleton when the presence of multiple instances can potentially damage the system, and we need global access to the single instance.

单例模式的经典实现

我们可以来看一下最经典的例子,以伪代码(接近java,get到意思就好😂)展示:

public class Singleton {
    private static Singleton _instance = null;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (_instance == null) {
            _instance = new Singleton();
        }
        
        return _instance;
    }
}

这可以说是单例模式最直接的实现了,private修饰构造函数导致外部无法通过new关键字来实例化对象,需要实例化Singleton或者获取Singleton实例必须调用提供的唯一接口getInstance(),对外界调用起到限制。调用getInstance()时首先会检查Singleton对象是否已被初始化,再返回对象实例,另外,Singleton实例只有在第一次调用getInstance()时才会真正初始化,也就是说只有在真正使用的时候才生成实例,这叫做懒加载(lazy initialization),可以提高系统启动速度,也更加适合在资源有限的系统中运行。

上面的经典实现有十分明显的问题,它不是线程安全的(not thread safe),具体可以看下面的场景:

  1. 在需要实例化Singleton时,线程A进入getInstance(),此时_instance == nullTrue线程A会进入if分支。
  2. 线程A执行初始化代码_instance = new Singleton()之前,发生一次线程切换,线程A挂起,切换到线程B,并且线程B也调用getInstance()
  3. 对于线程B来说_instance == null也是为True,因为线程A根本还没生成Singleton实例,线程B进入if分支。
  4. 最终结果就是线程A线程B都在if分支里,两个线程都会生成一个Singleton实例,此时系统就有两个Singleton实例。

简单粗暴地解决

怎么样可以防止生成多个Singleton实例呢?很容易,把懒加载去掉就好:

public class Singleton {
    private static Singleton _instance = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        return _instance;
    }
}

放弃懒加载的特性,这种实现会在类加载的时候就生成Singleton实例,并且保证线程安全。如果Singleton实例生成的代价低、性能影响小,或者这是最合适的一种实现,因为它简单并且安全。

添加锁🔐

但是我们还是想要懒加载要怎么改?一般方法是添加锁来保护Singleton实例化过程,保证只有一个线程可以进入实例化流程(enter crtical section),实现如下:

public static Singleton getInstance() {
    acquire_lock();
    if (_instance == null) {
        _instance = new Singleton();
    }
    release_lock();
       
    return _instance;
}

这个实现也可以保证线程安全,线程B不可能在线程A完成Singleton实例化之前接触到if判断分支,即使线程A在if分支里被切换,其他线程也因为等待锁都被阻挡在if (_instance == null)之前。但是这个实现的致命弱点是对性能影响很大,因为每一次调用getInstance()时都要获取锁,特别是当多个线程一起调用getInstance()的情况下,线程之间会因为竞争锁而被阻塞,当调用足够频繁的时候,系统对外的表现可会是经常卡一会没响应,或者整体的运行效率降低,看起来这样的实现也不太好。

添加锁之前先check

认真观察添加锁的实现会发现,其实添加锁只是为了安全地进行对象实例化,一旦_instace被实例化后,这把锁🔐就再也不需要了,但是上面的实现是每一次获取实例都去获取一次锁。有没有办法只让锁获取一次就好了?我们意识到锁只是在第一次获取的时候(第一次调用,实例化对象时)才产生意义,所以if判断可以提前到获取锁之前:

public static Singleton getInstance() {
    if (_instance == null) {
        acquire_lock();
        _instance = new Singleton();
        release_lock();
    }
            
    return _instance;
}

看起来好像很机智的样子🎉,我们可以只在首次调用getInstance()时才获取锁,往后因为_instance不为null所以都不用获取锁了,然而实际上行不通,为什么呢?当线程A进入if判断但是在获取锁之前被切换线程,线程B此时也可以进入if判断,会发生两个线程进入if判断的情况,后面就会生成多个Singleton实例,没错,坑就坑在线程可能随时被切换。

double-check

能解决上面的问题吗?还是可以的,我们可以额外添加一个if判断来防止重复进入实例化代码块:

public static Singleton getInstance() {
    if (_instance == null) {
        acquire_lock();
        if (_instance == null) {
            _instance = new Singleton();
        }
        release_lock();
    }
            
    return _instance;
}

这个代码估计是最常见的double-check单例模式实现了,看起来可以有效防止上面提到的问题,即使有多个线程同时进入第一个if判断,在Singleton实例首次被生成之后,后面的线程在critical section里也不会再重复实例化对象了,因为还有第二个if判断在里面。

真的是这样吗?就这样完了?并不是🙃,还有坑。

编译器优化问题

上面的实现不能保证100%的线程安全,原因是存在编译器优化。现代编译器都太先进了,它们会按照效率更高的方式进行指令重排,会充分利用寄存器和流水线以最高并行度去运行指令(现在已经是多核时代,一台机器上不再是一个CPU而是多个CPU,多个CPU在数据不互相干扰的情况下可以也同时独立运行指令)。一个具体例子:

int a = 10;
int b = 20;
int c = a + b;

我们人眼认为a是先于b被声明初始化的,最后c再被计算,但是对于编译器,它可以向我们保证c最后计算出来等于30,也会保证在c计算出来之前a和b都被正确声明和赋值即a肯定等于10,b肯定等于20,但是它不保证的是到底a先被声明赋值还是b先被声明赋值,有可能是先b再a,也有可能是先a再b,我们无从猜测,所以我们也不能指望它可靠。

这里引申出另一个问题,_instance = new Singleton();实例化操作到底是怎么被运行的?

_instance = new Singleton();并不是一个原子操作,它被执行时是分为3个步骤的:

  1. 一块特定的内存被分配出来给Singleton对象使用。

  2. 这块内存会被赋值初始化,此时Singletom对象已经能用了。

  3. 这块内存(Singleton对象)的引用赋值给_instance

    _instance指向Singleton实例的内存,实际上只有这块内存被完整初始化之后,_instance才是一个正确可用的实例(_instance is valid only when the initialization is complete)。这里可能出现的问题是编译器将这3个步骤打混了,假如线程A先执行步骤1和3,在没来得及执行步骤2的时候发生线程切换,对于线程B来说,_instance已经不为null了,就屁颠屁颠拿去用了,但是实际上_instance指向一块还没有被初始化的内存,如果线程B拿这个对象去使用会引发各种谜之bug甚至出现系统崩溃。

如何解决这个问题?

我们只需要将实例化对象那一行代码的运行顺序摆正就好了,换个说法就是避免编译器的优化:

  1. 在C++和Java中存在关键字volatile,也就是 private volatile Singleton _instance = new Singleton();,它可以使编译器不去做激进的优化,保持原来的执行顺序不变,不过这个关键字某种程度上也会影响整个系统的性能,毕竟变量变成volatile后即使实例化完成后,每次去对象都不去寄存器快速取出来而要去内存取。(volatile这个关键字坑很多,最好还是不要乱用)

  2. 使用内存屏障来迫使完全实例化之后再改变flag的值:

    public class Singleton {
        private static Singleton _instance = null;
        private static bool flag = false;
        
        private Singleton() {}
        
        public static Singleton getInstance() {
            if (!flag) {
                acquire_lock();
                if (!flag) {
                    _instance = new Singleton();
                    memory_barrier();
                    flag = true;
                }
                release_lock();
            }
            
            return _instance;
        }
    }

    因为有内存屏障的存在,可以保证flag = true;这句代码不会被编译器优化执行顺序,也就是一定会完成_instance = new Singleton();之后,flag变量才会变为true,所以flag就是_instance已经被正确初始化的标志,if直接判断flag变量即可。如果将flag = true;放在内存屏障前面行不行呢?那是不行的,因为可能被编译器优化之后flag变量甚至会在_instance正确实例化之前就被置为true。然而内存屏障也需要编译器的支持,否则可能很难使用内存屏障,甚至需要写上汇编代码。

  3. 最后一种是针对Java的实现,详见Initialization-on-demand holder idiom

总结

上面提到的所有实现,都没有找到尽善尽美可以运行在所有平台/编译器的方式。可能最后的解决方案就是放弃懒加载,权衡性能和安全。如果我们想坚持使用懒加载同时保证线程安全,性能会因为锁的获取受到影响(full locking版本),当然还是有一点小手段可以优化——尽量减少getInstance()的调用次数:

// 调用三次getInstance函数,也就获取了三次锁
Singleton.getInstance().method1();
Singleton.getInstance().method2();
Singleton.getInstance().method3();
 
// 只调用一次getInstance函数,获取一次锁,性能更好
Singleton instance = Singleton.getInstance();
instance.method1();
instance.method2();
instance.method3();

虽然这并没有解决问题,但是确实可以在性能上有所提高。

Singleton设计模式从来就不是一个简简单单的设计模式,在不同场景下,每一种设计模式都需要进行演变来应用,分清每一种实现的好坏,挑一种最合适业务场景的实现进行应用,这才是代码功力和业务理解能力💪。

TODO

violate关键字对单例的影响

参考资料

墙裂推荐阅读:Singleton in multi-threaded environment

C/C++ volatile关键字深度剖析

LINUX KERNEL MEMORY BARRIERS

Dissecting the Disruptor: Demystifying Memory Barriers

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2017-2022 Zingphoy Han
  • 访问人数: | 浏览次数:

一块钱一个俯卧撑 O_O

微信