C++设计模式——SingleTon单件模式

SingleTon概述

SingleTon单件模式(单例模式),涉及到一个特殊的类,这个类只能有一个instance。

因此类设计者设计的SingleTon模式的类必须阻止使用者生成该类的任何一个instance,且必须向使用者提供一个公共接口访问该类的唯一instance。

保证一个类仅有一个实例,并提供一个该实例的全局访问点。 ——《设计模式》GoF

SingleTon使用场景

1、要求生产唯一序列号。

2、WEB 中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。

3、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。

SingleTon结构

SingleTon代码实现

SingleTon类设计:

class SingleTon {
private:
    SingleTon();
    SingleTon(const SingleTon&);
public:
    static SingleTon* singleTon;
    static SingleTon* getInstance();
};

(1)为了避免用户产生类实例,将构造/拷贝构造函数都设置为private

(2)singleTon为SingleTon类产生的唯一实例,getInstance获取这个实例

//singleTon初始化
SingleTon* SingleTon::singleTon = nullptr;

getInstance()实现代码

(1)单线程版。线程非安全版本,只适用于单线程环境

//线程非安全版本
SingleTon* SingleTon::getInstance() {
    if(singleTon == nullptr) {
        singleTon = new SingleTon;
    }
    return singleTon;
}

(2)线程安全版本,但锁的代价太高

//线程安全版本,但锁代价太高
SingleTon* SingleTon::getInstance() {
    Lock lock;  // 读写都加锁
    if(singleTon == nullptr) {
        singleTon = new SingleTon;
    }
    return singleTon;
}

此实现为使用者的每次访问都提供了加锁机制,因此多线程环境下保证了唯一实例singleTon的访问是安全的。

缺点在于,线程对singleTon的读操作也会产生不必要的加锁。

比如:线程A线程B同时调用getInstance(),singleTon初值为nullptr,线程A先加锁,为singleTon生成其真正实例,线程A释放锁后,singleTon已经不为空,线程B对singleTon只会产生读操作,但是依旧需要加锁。如果在高并发场景下,100w个线程同时访问singleTon,singleTon只由某个线程实例一次,即只进行一次写操作,后续线程对singleTon的访问都是读操作,即访问100w次,就加锁100w次,99 9999次都是无须加锁可直接访问的读操作,这样一看锁的代价确实很高。

优化方法:将读写分开,读操作直接访问,写操作需要加锁

(3)双检查锁,但是由于内存的reorder导致了不安全

//双检查锁,内存reorder问题
SingleTon* SingleTon::getInstance() {
    if(singleTon == nullptr) { // 读无须加锁
        Lock lock;  // 写时加锁
        if(singleTon == nullptr) {
            singleTon = new SingleTon; //可能产生编译器指令优化,内存reorder
        }
    }
    return singleTon;
}

假设还是线程A和B,singleTon初值为nullptr,线程A先加锁,为singleTon生成其真正实例,线程A释放锁后,singleTon已经不为空,线程B再访问singleTon时就可直接从第一个if判断跳转到return语句访问singleTon实例,从而避免了对读操作加锁。

双检查锁的代码实际上就是在(2)线程安全版本的代码中加了一个最外层的if判断优化了读操作。

问题1:Lock lock;之后的if判断可以省略吗?

不能!首先逻辑上,Lock lock;之后的代码段是一个完整的对singleTon实例访问的线程安全代码;其次,假设没有Lock lock;之后的if语句,线程A线程B同时访问进入到第一个if语句,不论谁先加锁,最终sinleTon都会被AB两个线程各自实例一次,这对线程安全本身就是一个巨大的隐患。

问题2:内存reorder

对于singleTon = new SingleTon;这条语句,理论上是三个步骤:为singleTon分配内存-->调用SingleTon构造函数-->将分配的内存地址返回给singleTon。如果是这样的,那么无论线程AB怎么调度,二者对于singleTon的访问都是线程安全的。

但是编译器的优化机制可能产生另一个顺序:为singleTon分配内存-->将分配的内存地址返回给singleTon-->调用SingleTon构造函数。被reorder后的指令代码,最容易产生的一种不安全情况就是:线程B访问到线程A还没有构造完毕的"半成品"singleTon。

假设线程A此时成功拿到了锁,并已经执行完singleTon = new SingleTon;这条语句的前两个步骤(reorder):为singleTon分配内存-->将分配的内存地址返回给singleTon,此时singleTon就不是一个nullptr指针,而是指向一段内存空间,但是这段空间上并没有存储一个SingleTon对象!如果线程B恰好在此时调用getInstance(),就会判断第一个if语句,singleTon不为nullptr,从而return singleTon,那么线程B中调用一方获取到的就是一个不能正确访问的半成品sinlgTon!

(4)最推荐的懒汉式单例(magic static )——局部静态变量

利用了 C++11 的 magic static 特性:如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。
 
class CCentralCache {
// 单例模式
private:
    CCentralCache ( ) { printf ("constructor\n");}
    CCentralCache ( const CCentralCache& ) = delete;
    CCentralCache& operator=( const CCentralCache& ) = delete;

public:
    ~CCentralCache ( ) { printf ("destructor\n");}
    static CCentralCache& getInstance ( )   // 注意调用此函数一定要使用 & 接收返回值,因为 拷贝构造函数已经被禁止
    { 
        static CCentralCache _centralcache; // 懒汉式    // 利用了 C++11 magic static 特性,确保此句并发安全
        return _centralcache; 
    }
};

 

 

posted @ 2020-11-22 11:13  _程序兔  阅读(309)  评论(0编辑  收藏  举报