C++ 多线程编程(三):单例模式


C++ 多线程编程(三):单例模式

1. 单例模式介绍

单例模式(Singleton Pattern)是设计模式的一种,其特点是只提供唯一一个类的实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。

定义一个单例类:

  1. 私有化它的构造函数,以防止外界创建单例类的对象;
  2. 使用类的私有静态指针变量指向类的唯一实例;
  3. 使用一个公有的静态方法获取该实例。

具体运用场景:

  1. 设备管理器,系统中可能有多个设备,但是只有一个设备管理器,用于管理设备驱动;
  2. 数据池,用来缓存数据的数据结构,需要在一处写,多处读取或者多处写,多处读取;

2. 单例模式的实现

2.1 懒汉式

懒汉式(Lazy-Initialization)的方法是直到使用时才实例化对象,也就说直到调用get_instance() 方法的时候才 new 一个单例的对象, 如果不被调用就不会占用内存。

2.1.1 最基础的懒汉式

这是最基础最简单的懒汉式,大多数人写懒汉式的话可能就是这种形式的,毕竟如果不在多线程中使用的话这里是没有任何问题的。

不过在多线程并发的情况下,这样无法保证实例唯一,甚至这样的做法是失效的。

下面是懒汉式实现和多线程中的测试代码:

#include <iostream>
#include <thread>
#include <armadillo>

class Single
{

public:
    // 获取单实例对象
    static Single *GetInstance()
    {
        if (m_instance_ptr == nullptr)
        {
            m_instance_ptr = new Single;
        }
        return m_instance_ptr;
    }

    // 打印实例地址
    void Print()
    {
        std::cout << "我的内存地址是:" << this << std::endl;
    }

    ~Single()
    {
        std::cout << "析构函数" << std::endl;
    }

    // 禁止外部拷贝构造
    Single(const Single &signal) = delete;

    // 禁止外部赋值操作
    const Single &operator=(const Single &signal) = delete;

private:
    static Single *m_instance_ptr;

    // 禁止外部构造
    Single()
    {
        std::cout << "构造函数" << std::endl;
    }
};

Single *Single::m_instance_ptr = nullptr;

void test()
{
    //假设创建线程之前有一些其他工作要做
    sleep(1);

    Single *single = Single::GetInstance();

    single->Print();
}

int main()
{
    std::thread T[10];
    for (auto &i: T)
        i = std::thread(test);

    //下面两个循环二选一即可!
    //for (auto &i: T)
    //    i.detach();
    //getchar();

    for (auto &i: T)
        if (i.joinable())
            i.join();

    return 0;
}

一个运行结果是:

构造函数构造函数
我的内存地址是:构造函数0x7f6c14000b20
构造函数
构造函数

我的内存地址是:0x7f6c04000b20
构造函数
我的内存地址是:0x7f6bfc000b20
构造函数构造函数

我的内存地址是:我的内存地址是:0x7f6c1c000b200x7f6c0c000b20


构造函数
我的内存地址是:0x7f6bf8000b20
构造函数我的内存地址是:我的内存地址是:0x7f6bec000b20
我的内存地址是:
我的内存地址是:0x7f6bf4000b20
0x7f6be4000b20
0x7f6be8000b20

可以看到这里的实例并不是唯一的!这种方式会导致两个问题:

  1. 线程安全的问题

    当多线程获取单例时有可能引发竞态条件:第一个线程在if中判断 m_instance_ptr是空的,于是开始实例化单例;同时第2个线程也尝试获取单例,这个时候判断m_instance_ptr还是空的,于是也开始实例化单例;这样就会实例化出两个对象,这就是线程安全问题的由来;

    解决办法:加锁

  2. 内存泄漏

    注意到类中只负责new出对象,却没有负责delete对象,因此只有构造函数被调用,析构函数却没有被调用,因此会导致内存泄漏。

    解决办法:使用共享指针

2.1.2 线程安全、内存安全的懒汉式

这里我们可以使用智能指针、锁来保证这个懒汉式是线程安全且内存安全的。示例代码如下所示:

#include <iostream>
#include <thread>
#include <armadillo>
#include <memory> // shared_ptr
#include <mutex>  // mutex

class Single
{
public:
    typedef std::shared_ptr<Single> Ptr;

    // 获取单实例对象
    static Ptr GetInstance()
    {
        // "double checked lock"
        if (m_instance_ptr == nullptr)
        {
            std::lock_guard<std::mutex> lk(m_mutex);

            if (m_instance_ptr == nullptr)
            {
                m_instance_ptr = std::shared_ptr<Single>(new Single);
            }
        }
        return m_instance_ptr;
    }

    // 打印实例地址
    void Print()
    {
        std::cout << "我的内存地址是:" << this << std::endl;
    }

    //使用智能指针的时候不能够禁止外部析构!
    ~Single()
    {
        std::cout << "析构函数" << std::endl;
    }

    // 禁止外部拷贝构造
    Single(const Single &signal) = delete;

    // 禁止外部赋值操作
    const Single &operator=(const Single &signal) = delete;

private:
    static Ptr m_instance_ptr;
    static std::mutex m_mutex;

    // 禁止外部构造
    Single()
    {
        std::cout << "构造函数" << std::endl;
    }
};

// initialization static variables out of class
Single::Ptr Single::m_instance_ptr = nullptr;
std::mutex Single::m_mutex;

void test()
{
    //假设开始前有准备工作要进行
    sleep(1);

    Single::Ptr single = Single::GetInstance();

    single->Print();
}

int main()
{
    std::thread T[10];
    for (auto &i: T)
        i = std::thread(test);

    //下面两个循环二选一即可!
    //for (auto &i: T)
    //    i.detach();
    //getchar();

    for (auto &i: T)
        if (i.joinable())
            i.join();

    return 0;
}

一个运行结果是:

构造函数
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
我的内存地址是:0x7f08b4000b20
析构函数

shared_ptr和mutex都是C++11的标准,以上这种方法的优点是:

  • 基于shared_ptr,用了C++比较倡导的 RAII思想,用对象管理资源。

    当shared_ptr析构的时候,new出来的对象也会被delete掉,以此避免内存泄漏。

  • 加了锁,使用互斥量来达到线程安全。

    这里使用了两个if判断语句的技术称为双检锁。好处是,只有判断指针为空的时候才加锁,避免每次调用get_instance的方法都加锁,锁的开销毕竟还是有点大的。

不足之处在于:

  • 使用智能指针会要求用户也得使用智能指针,非必要不应该提出这种约束;
  • 使用锁也有开销;
  • 代码量也增多了,实现上我们希望越简单越好。

还有更加严重的问题,在某些平台(与编译器和指令集架构有关),双检锁会失效!具体可以看这篇文章,解释了为什么会发生这样的事情。

因此这里还有第三种的基于 Magic Staic的方法达到线程安全

2.1.3 magic static(局部静态变量)

这种方法又叫做Meyers’ Singleton, 是《Effective C++》系列书籍的作者Meyers提出的。所用到的特性是在C++11标准中的Magic Static特性:

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。

这样保证了并发线程在获取静态局部变量的时候一定是初始化过的,所以具有线程安全性。

C++静态变量的生存期 是从声明到程序结束,这也是一种懒汉式。

示例代码如下:

#include <iostream>
#include <thread>
#include <armadillo>

//静态局部变量的懒汉单例(C++11线程安全)
///  内部静态变量的懒汉实现
class Single
{

public:
    // 获取单实例对象
    static Single &GetInstance()
    {
        /**
         * 局部静态特性的方式实现单实例。
         * 静态局部变量只在当前函数内有效,其他函数无法访问。
         * 静态局部变量只在第一次被调用的时候初始化,也存储在静态存储区,生命周期从第一次被初始化起至程序结束止。
         */
        static Single signal;
        return signal;
    }

    // 打印实例地址
    void Print()
    {
        std::cout << "我的实例内存地址是:" << this << std::endl;
    }

    ~Single()
    {
        std::cout << "析构函数" << std::endl;
    }

    // 禁止外部拷贝构造
    Single(const Single &signal) = delete;

    // 禁止外部赋值操作
    const Single &operator=(const Single &signal) = delete;

private:
    // 禁止外部构造
    Single()
    {
        std::cout << "构造函数" << std::endl;
    }
};

void test()
{
    //假设开始前有准备工作要进行
    sleep(1);

    Single &single = Single::GetInstance();

    single.Print();
}

int main()
{
    std::thread T[10];
    for (auto &i: T)
        i = std::thread(test);

    //下面两个循环二选一即可!
    //for (auto &i: T)
    //    i.detach();
    //getchar();

    for (auto &i: T)
        if (i.joinable())
            i.join();

    return 0;
}

一个运行结果是:

构造函数
我的实例内存地址是:我的实例内存地址是:0x55c6ee5541d00x55c6ee5541d0

我的实例内存地址是:0x55c6ee5541d0
我的实例内存地址是:我的实例内存地址是:0x55c6ee5541d094312890450384
我的实例内存地址是:
我的实例内存地址是:0x55c6ee5541d0
我的实例内存地址是:0x55c6ee5541d0
0x55c6ee5541d0
我的实例内存地址是:0x55c6ee5541d0
我的实例内存地址是:0x55c6ee5541d0
析构函数

这是最推荐的一种单例实现方式:

  1. 通过局部静态变量的特性保证了线程安全 (C++11, GCC > 4.3, VS2015支持该特性);
  2. 不需要使用共享指针,代码简洁;
  3. 注意在使用的时候需要声明单例的引用 Single& 才能获取对象。

另外网上有人的实现返回指针而不是返回引用:

static Singleton* get_instance(){
    static Singleton instance;
    return &instance;
}

这样做并不好,理由主要是无法避免用户使用delete instance导致对象被提前销毁。还是建议大家使用返回引用的方式。

2.2 饿汉式

饿汉式(Eager Singleton):指单例实例在程序运行时被立即执行初始化。

#include <iostream>
#include <thread>
#include <armadillo>

class Single
{

public:
    // 获取单实例对象
    static Single &GetInstance()
    {
        return instance;
    }

    // 打印实例地址
    void Print()
    {
        std::cout << "我的实例内存地址是:" << this << std::endl;
    }

    ~Single()
    {
        std::cout << "析构函数" << std::endl;
    }

    // 禁止外部拷贝构造
    Single(const Single &signal) = delete;

    // 禁止外部赋值操作
    const Single &operator=(const Single &signal) = delete;

private:
    static Single instance;

    // 禁止外部构造
    Single()
    {
        std::cout << "构造函数" << std::endl;
    }
};

// initialize defaultly
Single Single::instance;

void test()
{
    //假设开始前有准备工作要进行
    sleep(1);

    Single &single = Single::GetInstance();

    single.Print();
}

int main()
{
    std::thread T[10];
    for (auto &i: T)
        i = std::thread(test);

    //下面两个循环二选一即可!
    //for (auto &i: T)
    //    i.detach();
    //getchar();

    for (auto &i: T)
        if (i.joinable())
            i.join();

    return 0;
}

一个运行结果是:

构造函数
我的实例内存地址是:我的实例内存地址是:0x55ee2cf391380x55ee2cf39138

我的实例内存地址是:0x55ee2cf39138
我的实例内存地址是:0x55ee2cf39138我的实例内存地址是:
我的实例内存地址是:我的实例内存地址是:0x55ee2cf39138我的实例内存地址是:我的实例内存地址是:
0x55ee2cf39138
0x55ee2cf39138我的实例内存地址是:0x55ee2cf39138
0x55ee2cf39138
0x55ee2cf39138

析构函数

由于在main函数之前初始化,所以没有线程安全的问题。但是潜在问题在于no-local static对象(函数外的static对象)在不同编译单元中的初始化顺序是未定义的。

也就是说,static Singleton instancestatic Singleton& getInstance()二者的初始化顺序不确定,如果在初始化完成之前调用getInstance() 方法会返回一个未定义的实例。

3. 何时使用单例模式

选自stackoverflow上的高分回答:singleton-how-should-it-be-used

Use a Singleton if:

  • You need to have one and only one object of a type in system

Do not use a Singleton if:

  • You want to save memory
  • You want to try something new
  • You want to show off how much you know
  • Because everyone else is doing it (See cargo cult programmer in wikipedia)
  • In user interface widgets
  • It is supposed to be a cache
  • In strings
  • In Sessions
  • I can go all day long

How to create the best singleton:

  • The smaller, the better. I am a minimalist
  • Make sure it is thread safe
  • Make sure it is never null
  • Make sure it is created only once
  • Lazy or system initialization? Up to your requirements
  • Sometimes the OS or the JVM creates singletons for you (e.g. in Java every class definition is a singleton)
  • Provide a destructor or somehow figure out how to dispose resources
  • Use little memory

4. 总结

  • Eager Singleton 虽然是线程安全的,但存在潜在问题;
  • Lazy Singleton通常需要加锁来保证线程安全,但局部静态变量版本在C++11后是线程安全的;
  • 局部静态变量版本(Meyers Singleton)最优雅。

文章作者: Immortalqx
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Immortalqx !
评论
  目录