设计模式详解:Singleton(单例类)

Singleton(单例类)

设计模式学习:概述

意图

保证每一个类仅有一个实例,并为它提供一个全局访问点。

顾名思义,单例类Singleton保证了程序中同一时刻最多存在该类的一个对象。

有些时候,某些组件在整个程序运行时就只需要一个对象,多余的对象反而会导致程序的错误。

或者,有些属性型对象也只需要全局存在一个。比如,假设黑体字属性是一种对象,调用黑体字属性对象的函数来让一个字变成黑体。显然并不需要在每创造一个黑体字时就生成一种属性对象,只需要调用当前存在的唯一对象的函数即可。

Singleton模式的功能有两点:一是保证程序的正确性,使得最多存在一种实例的对象不会被多次创建。二是提高程序性能,避免了多余对象的创建从而降低了内存占用。

代码案例 + 解释

单例模式比较简单,我们来看一下它的最简易实现:

class Singleton
{
     
private:
    static Singleton* sin;
    Singleton(){
     }
    
public:
    static Singleton* getInstance()
    {
     
        if(sin == nullptr)
            sin = new Singleton();
        return sin;
	}
}

首先,我们将构造函数声明为私有,这样就防止了任何人用new关键字创建对象,而只能调用函数的getInstance函数来获取单例指针。然后,检查是否有已经存在的对象,如果有,直接返回该指针,如果没有,创建对象并返回指针。

这种做法在单线程程序中是没问题的,但它不是线程安全的。这也很好理解:当线程A执行完判断语句,刚刚进入if语句内但还没有完成对象创建前,如果这时操作系统切换到线程B,而B也调用了该函数,这样,B调用过程的判断也为真(因为线程A只是完成了判断,还没有来得及创建对象就休眠了),导致A,B各自拥有了一个单例类对象,这显然是不合理的。

下面是一种改进方案:

class Singleton
{
     
private:
    static Singleton* sin;
    Singleton(){
     }
    
public:
    static Singleton* getInstance()
    {
     
        Lock lock;
        if(sin == nullptr)
            sin = new Singleton();
        return sin;
	}
}

这里,我们引入了一个线程锁,了解多线程和线程锁的话应该很容易理解:如果任何一个线程调用了getInstance并且还没有完成调用过程,其他线程就无法进入这个函数,也就避免了上述问题。

但是,这种写法虽然的的确确实现了单例类应当具备的功能,但它的代价过大:在对象创建后,几乎所有调用getInstance的行为都是只读的。而这一只读行为却无法多线程地进行。低并发环境下还好,但如果在高并发(比如互联网服务器)环境下,同时可能存在成千上万给线程试图调用getInstance,却因为锁而导致只有其中一个可以正常运作,这会导致该高并发体系几乎无法运作。

如何解决这个问题,下面的做法是一种方案:

class Singleton
{
     
private:
    static Singleton* sin;
    Singleton(){
     }
    
public:
    static Singleton* getInstance()
    {
     
        if(sin == nullptr)
            Lock lock;
        	if(sin == nullptr)
            	sin = new Singleton();
        return sin;
	}
}

这个做法就是著名的双检查锁。它就是为了解决高并发单例类的访问问题而出现的解决方案,这一方案让人们在数年内都认为是没有问题的

上面的单检查锁导致只读过程代价太高,究其原因,是因为在对象已经创建后仍然不加判断地使用锁。因此,在双检查锁方案中,我们只在对象没有被创建时上锁。这种方案,似乎解决了高并发访问的问题,也保留了单例类的特性。

然而,这种做法由于内存读写reorder的问题,可能导致双检查锁的失效,这一问题在双检查锁被提出的数年后才被发现。

什么是内存读写reorder?如果你了解编译原理的话,你就会知道,所谓高级语言(C,C++,JAVA)等,最后都会被编译器翻译为若干条功能更简单的汇编指令。在我们的例子中,这个语句:

sin = new Singleton();

事实上包含了三个小步骤:

  1. 为变量分配一块内存。
  2. 调用构造器将其初始化。
  3. 将内存地址赋给指针。

我们知道,所谓线程上下文切换,事实上是在汇编语言层级上完成的,由操作系统完成线程的时间片分配。因此,上面一个语句的三个步骤,有可能无法在一次线程切换时完成

只要这三个步骤的顺序不发生改变,双检查锁的逻辑仍然不会出错。然而,机器内存读写的reorder优化机制,可能会导致上面三个步骤不按正常顺序执行

当然,不管怎样都一定要先分配内存,因此,在极端情况下,会出现下面的指令执行顺序:

  1. 为变量分配一块内存。
  2. 将内存地址赋给指针。
  3. 上下文切换,执行其他线程的getInstance()
  4. 调用构造器将其初始化。

在第3步发生了什么?我们再看一下这段双检查锁代码:

static Singleton* getInstance()
    {
     
        if(sin == nullptr)
            Lock lock;
        	if(sin == nullptr)
            	sin = new Singleton();
        return sin;
	}

要知道,当内存地址赋给指针后,指针就已经不再是Null了!因此,在线程A执行完前两步(分配内存、赋值),切换到线程B之后,线程B会认为自己拿到了一个已经初始化的、可以直接使用的指针!也就是Sin == null判断为false,函数直接返回!

这是危险的,因为虽然今后切换到线程A后,A仍然可以完成对象的初始化工作,但在那之前,B已经拿到了一个未初始化的对象指针,并且很有可能会调用这一对象做一些事情,这将大概率导致程序崩溃。

这是一个十分隐蔽的致命Bug,它甚至直接导致了高级语言公司纷纷为自己的语言添加新特性使得能够弥补双检查锁的缺陷,可以说,是一个跨时代的超级Bug。

不同语言有针对该问题的不同解决方案,这里只列出C++11给出的方案,不再进行过多的解释。想要了解的话,可以自行查询C++库。

std::atomic<Singleton*> Singleton::sin;
std::mutex Singleton::m_mutex;

Singleton* getInstance()
{
     
    Singleton* tmp = sin.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);
    if(tmp == nullptr)
    {
     
        std::lock_guard<std::mutex> lock(m_mutex);
        tmp = sin.load(std::memory_order_relaxed);
        if(tmp == nullptr)
        {
     
            tmp = new Singleton();
            std::atomic_thread_fence(std::memory_order_released);
            sin.store(tmp, std::memory_order_relaxed);
		}
	}
    return tmp;
}

总结

设计模式 Singleton(单例类)
稳定点:
变化点:
效果: 使程序中同时只存在最多一个该类对象

2020.2.17转载请标明出处

你可能感兴趣的:(设计模式,设计模式,多线程,指针,c++,运维)