多线程下的单例模式

简介:

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

动机

在软件系统中,经常有这样一个特殊的类,必须保证它们在系统中只存在一个示例,才能确保它们的逻辑正确性、以及良好的效率。这个应该类设计者的责任,而不是使用者的责任。

示例

class Singleton{
public:
    Singleton(const Singleton &) = delete;

    static Singleton* GetInstance();

private:
    Singleton() = default;
    static Singleton *instance_;
};

Singleton* Singleton::instance_ = nullptr;

// 线程非安全版本
Singleton* Singleton::GetInstance() {
    if (instance_ == nullptr) {
        instance_ = new Singleton();
    }
    return instance_;
}

上述代码,在单线程环境下是没有问题的,但是在多线程下就很有可能发生问题,因为我们写下的代码在由编译器处理后,往往不是我们所写的高级语言所表现的那样,最后可能会分为好几部来执行。
那么我们就来分析一下上面这段代码:

  1. 线程 a 首先执行到了 if (instance_ == nullptr) 此时 instance_ 还是 nullptr,进入 if 语句,线程 a 时间片用完。
  2. 线程 b 执行到 if (instance_ == nullptr) 此时 instance_ 还是 nullptr,那么线程b 也会进入 if 语句。
  3. 由于两个线程 a 和 b 都进入 if 语句,都会执行 instance_ = new Singleton(); 则程序会从堆中创建两个甚至多个(多个线程情况下),只有最后赋给 instance_ 的那个对象得以保存下来,其他对象将会“丢失”,并造成内存泄露。

从上面的分析我们可以得出,出现问题的原因在于,有多个线程可能会“同时”进入 if (instance_ == nullptr) 这条语句,那么我们只要在进行判断之前进行加锁,让任一时刻只有一个线程可以执行 if (instance_ == nullptr) 即可解决上面出现可能创建多个对象的问题。

Singleton* Singleton::getInstance() {
    Lock lock;  // 示例
    if (m_instance == nullptr) {
        m_instance = new Singleton();
    }
    return m_instance;
}

这样加锁,效率相对比较低。并且当对象创建完成之后,对所有的 GetInstance 函数来说,都只是读取这个变量,无需加锁,但这样的写法导致每次访问都会申请拿锁,导致效率降低。
我们可以利用双检查锁来解决上述锁的代价过高的问题

Singleton* Singleton::GetInstance() {
    if (instance_ == nullptr) {
        Lock lock;
        if (instance_ == nullptr) {
            instance_ = new Singleton();
        }
    }
    return instance_;
}

我们咋一看,以上写法好像并没有什么问题,并且很好的解决了锁的代价过高的问题,在对象创建完成后,其他线程在访问实例的时候并不会加锁,而是直接返回。

但是,以上双检查锁的写法也存在着问题。我们看代码有一个指令序列,但代码在汇编之后,可能在执行的时候,抢CPU的指向权的时候,可能和我们预想的不一样。一般m_instance = new Singleton();我们认为是先分配内存,再调用构造函数创建对象,再把对象的地址赋值给变量。但在CPU实际执行的时候,以上的三个步骤可能会被重新打乱顺序执行。可能会是先分配内存,然后就把内存地址直接赋值给变量,最后在调用构造函数来创建对象。那么如果出现以上情况,变量已经被赋值了对象的指针,但实际却指向了没被初始化的内存。那么此时,线程安全问题就再次出现了。

那么该如何解决这个问题呢?
在 java 和 C# 这类语言来说,语言标准增加了一个 volatile 关键字,通过它来修饰单例的对象,此时就不会出现先赋值,然后调用构造函数的情况,保证了多线程下的正确性。msvc 编译器自己添加了 volatile 关键字,属于编译器扩充,但跨平台的问题没办法解决。直到 C++11 后才真正的解决了这个问题,实现了跨平台。

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <atomic>
#include <mutex>

class Singleton {
public:
    Singleton(const Singleton &) = delete;

    static Singleton* GetInstance();

private:
    Singleton() = default;

    static std::atomic<Singleton *> instance_;
    static std::mutex mutex_;
};

std::atomic<Singleton *> Singleton::instance_;
std::mutex Singleton::mutex_;

Singleton* Singleton::GetInstance() {
    // 通过原子的对象的load方法获得对象的指针。
    Singleton *tmp = instance_.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);         // 获取内存屏障

    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(mutex_);
        tmp = instance_.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new Singleton();
            std::atomic_thread_fence(std::memory_order_release); // 释放内存屏障
            instance_.store(tmp, std::memory_order_relaxed);
        }
    }
    return tmp;
}
// 参见 c++ 并发编程指南

int main() {

    return 0;
}

总结

  • 单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。单例模式的要点有三个:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。单例模式是一种对象创建型模式。
  • 单例模式只包含一个单例角色:在单例类的内部实现只生成一个实例,同时它提供一个静态的工厂方法,让客户可以使用它的唯一实例;为了防止在外部对其实例化,将其构造函数设计为私有。
  • 单例模式的主要优点在于提供了对唯一实例的受控访问并可以节约系统资源;其主要缺点在于因为缺少抽象层而难以扩展,且单例类职责过重。
  • 单例模式适用情况包括:系统只需要一个实例对象;客户调用类的单个实例只允许使用一个公共访问点。

扩展阅读:
Scott Meyers 大神写的一篇论文
http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf

http://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/
https://stackoverflow.com/questions/6086912/double-checked-lock-singleton-in-c11
https://www.zhihu.com/question/66896665

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值