设计模式-创建型模式之-单例模式

  正常情况下一个类定义后,可以创建很多实例。但是有时候我们有一种这样的需求:希望程序中只有一个这样的实例,以避免资源访问的冲突、干扰等。例如,我们知道,串口这种设备通常是独占的,如果设计一个类,使用指定串口通讯。为了避免使用冲突,在不使用单例模式时,我们必须小心谨慎的确保只有一个这样的实例存在。但是人总会犯错,使用某种机制来进行约束才是最佳方案。这种机制就是单例模式:顾名思义,单例模式就是类只能创建一个实例的一种设计模式。

  我们知道类实例化是通过构造函数实现,那如何保证类只创建一个实例呢?显然需要在构造函数上做手脚(因为类的实例化就在它里面进行的嘛)。

  单例的写法有两种:

  • 饿汉式
  • 懒汉式

所谓饿汉式就是一开始就创建了对象(急迫的需要对象)。
所谓懒汉式就是按需创建对象(想要对象了,才创建)。

懒汉式单例-存在缺陷的版本

class SingletonPattern
{
public:
    static SingletonPattern *getInstance()
    {
        // 简单判断指针是否为空,来决定是否创建对象,并且指针instance为静态的,即生命周期为整个程序期间,从而实现确保只创建一个对象。
        if (instance == nullptr)
        {
            //不调用getInstance,对象就不会被创建,故而叫懒汉式
            instance = new SingletonPattern();
        }

        return instance;
    }

private:
    //构造函数私有,使得无法在外部通过构造函数创建对象,规避了误调用构造函数(SingletonPattern object; new SingletonPattern)的可能。
    SingletonPattern()
    {
        std::cout << "Singleton Pattern Constructor" << std::endl;
    }

    static SingletonPattern *instance;
};

SingletonPattern *SingletonPattern::instance = nullptr;

  以上懒汉式写法存在几个缺陷:

缺陷一:多线程时因为竞态资源,导致单例失败

  例如,如下测试代码:

    std::thread t1([] {
        SingletonPattern::getInstance();
    });
    
    std::thread t2([] {
        SingletonPattern::getInstance();
    });
    
    t1.join();
    t2.join();

  比较容易出现共享的资源(SingletonPattern::instance)在多线程时,由于未做竞态资源保护,导致构造函数被调用多次,起不到单例的作用。解释下这个过程:

    static SingletonPattern *getInstance()
    {
        if (instance == nullptr) //假设线程t1执行完判断,条件成立,此时系统发生调度,线程t2开始运行,注意此时instace == nullptr,t2判断也会成立。因此最终都会进入到下面构造的过程!
        {
            instance = new SingletonPattern();
        }

        return instance;
    }

  如果不容易发生这种情况,那直接让系统在判断instance == nullptr后,执行构造前让系统调度一下即可,常见的方法是:休眠一下或者礼让(yeild):

        if (instance == nullptr)
        {
            // sleep(1); or std::this_thread::yield();
            instance = new SingletonPattern();
        }

这样都可以让当前线程尝试让度CPU调度。

缺陷二:未禁用拷贝构造函数等特殊函数,导致严重问题

  上面的单例是允许如下写法的:

    SingletonPattern *obj1 = SingletonPattern::getInstance();
    SingletonPattern obj2(*obj1);

  由于C++在代码未显示指定某些特殊函数实现时,会自动生成默认版本,他们是:

  • 默认构造函数
  • 析构函数
  • 拷贝构造函数
  • 赋值运算符重载函数
  • 移动构造函数
  • 移动赋值运算符重载函数。

这里以拷贝构造函数为例,因此第二行代码将调用默认的拷贝构造函数,这将带来一系列严重的隐患。其他几个特殊函数也是类似的。

隐患一:它破坏了单例设计原则:[仅允许一个实例存在]

隐患二:单例的状态不统一。

  假设类SingletonPattern中有一个表示状态的成员变量,看下面这个例子:

class SingletonPattern
{
public:
    //其他和原来的类一样
    ....
    ....
    
    void setState(int s)
    {
        state = s;
    }

    int getState() const
    {
        return state;
    }
private:
    int state = 0;
};

按照下面的调用方式:

    SingletonPattern *obj1 = SingletonPattern::getInstance();
    SingletonPattern obj2(*obj1);

    obj1->setState(1);
    std::cout << obj2.getState() << std::endl;

会发现明明对象obj1设置为1了,但是输出的状态为0,即构造初始化的状态。这就是因为未禁用拷贝构造函数,导致生成了2个完全独立的对象obj1和obj2,因此他们的非静态成员变量是独立存在的。

还有一类情况,例如:

#include <cstring>
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

class SingletonPattern
{
public:
    static SingletonPattern *getInstance()
    {
        if (instance == nullptr)
        {
            instance = new SingletonPattern();
        }

        return instance;
    }

    ~SingletonPattern()
    {
        close(fd);
    }

    void addContent(const char *content)
    {
        ssize_t ret;
        ret = write(fd, content, strlen(content));
        if (ret < 0)
        {
            perror("write");
        }
    }

private:
    SingletonPattern()
    {
        std::cout << "Singleton Pattern Constructor" << std::endl;
        fd = open("./test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
        if (fd < 0)
        {
            perror("open");
        }
    }

    static SingletonPattern *instance;
    int fd;
};

SingletonPattern *SingletonPattern::instance = nullptr;

int main()
{
    SingletonPattern *obj1 = SingletonPattern::getInstance();
    obj1->addContent("hello");
    {
        SingletonPattern obj2(*obj1);
    }
    obj1->addContent("world");

    return 0;
}

像文件描述符这种在进程中共享的资源,由于未禁用拷贝构造函数,导致误触发调用析构,关闭了文件,接着下面掉调用写文件,错误发生。

改进懒汉式单例

改进版本一:单检锁实现

#include <iostream>
#include <mutex>
#include <thread>

class SingletonPattern
{
public:
    static SingletonPattern *getInstance()
    {
        //使用锁来保护竞态资源
        std::lock_guard<std::mutex> locker(mutex);
        if (instance == nullptr)
        {
            instance = new SingletonPattern();
        }
        return instance;
    }
    
    //禁用可能带来风险的会自动生成的特殊函数
    SingletonPattern(const SingletonPattern &) = delete;
    SingletonPattern &operator=(const SingletonPattern &) = delete;
    SingletonPattern(SingletonPattern &&) = delete;
    SingletonPattern &operator=(SingletonPattern &&) = delete;

private:
    SingletonPattern()
    {
        std::cout << "Singleton Pattern Constructor" << std::endl;
    }

    static SingletonPattern *instance;
    static std::mutex mutex;
};

SingletonPattern *SingletonPattern::instance = nullptr;
std::mutex SingletonPattern::mutex;

int main()
{
    std::thread t1([] {
        SingletonPattern::getInstance();
    });

    std::thread t2([] {
        SingletonPattern::getInstance();
    });

    t1.join();
    t2.join();
    return 0;
}

所谓单检锁,就是只检查一次instance == nullptr。此方案看上去挺好的,但是既然给它取个特殊的名字,那必定是有原因的。

改进版本二:双检锁实现

  双检锁就是检查instance == nullptr两次,为什么要这样做?想像一下,我们这里引入锁主要是干什么?保证安全的检查instance == nullptr。为什么会可能不安全?因为检查为空到实际赋值完毕,这中间有很多个步骤,在中途的任意一个点发生调度都可能引发问题。双检锁的写法为:

    static SingletonPattern *getInstance()
    {
        if (instance == nullptr)
        {
            //使用锁来保护竞态资源
            std::lock_guard<std::mutex> locker(mutex);
            if (instance == nullptr)
            {
                instance = new SingletonPattern();
            }
        }
        return instance;
    }

  假设前面已完成过一次调用了,此时instance != nullptr,直接返回instance即可,而不必再检查一次锁,从而提高效率,毕竟检查锁的步骤可能是很繁琐的。

  那是否线程安全呢?被锁保护的区域是线程安全的,但是第一个instance != nullptr检查存在问题。构造instance = new SingletonPattern();这句实际会有很多步骤,比如内存分配,构造初始化这块分配的内存,这块内存指针赋值给instance。按照我们通常的想法,按照这3个执行步骤好像依然没问题,因为最后才执行指针赋值,那即便另一个线程执行第一个instance != nullptr检查,依然会进入到锁检查这里来被卡住。但实际对于现在的CPU支持乱序执行,会可能出现一种情况,在时序上先分配内存,然后立即内存赋值,最后才是构造初始化这块内存。为什么可以这样的?因为CPU的乱序引擎为了提高执行效率,在多级流水线上支持无依赖的指令并行执行,因此可能执行时序和实际的指令时序不一致。假设线程A由于乱序执行,分配了内存,并且赋值了指针,但是还未构造初始化,此时发生调度,线程B执行,由于第一个instance != nullptr未被锁保护,并且乱序执行的赋值语句已完成,所以会立即返回该指针。那么此时使用该指针访问的这片内存是出于未初始化状态,可能会导致未定义行为。因此这种写法线程不安全!

改进版本三:通过原子操作的双检锁

class SingletonPattern
{
public:
    static SingletonPattern *getInstance()
    {
        SingletonPattern *tmp = instance.load(std::memory_order_acquire);
        if (tmp == nullptr)
        {
            //使用锁来保护竞态资源
            std::lock_guard<std::mutex> locker(mutex);
            tmp = instance.load(std::memory_order_relaxed);
            if (tmp == nullptr)
            {
                tmp = new SingletonPattern();
                instance.store(tmp, std::memory_order_release);
            }
        }
        return tmp;
    }

    //禁用可能带来风险的会自动生成的特殊函数
    SingletonPattern(const SingletonPattern &) = delete;
    SingletonPattern &operator=(const SingletonPattern &) = delete;
    SingletonPattern(SingletonPattern &&) = delete;
    SingletonPattern &operator=(SingletonPattern &&) = delete;

private:
    SingletonPattern()
    {
        std::cout << "Singleton Pattern Constructor" << std::endl;
    }

    static std::atomic<SingletonPattern *> instance;
    static std::mutex mutex;
};

std::atomic<SingletonPattern *> SingletonPattern::instance;
std::mutex SingletonPattern::mutex;

改进版本四:Meyers单例

从C++11后,有了如下优雅的写法,它是Scott Meyers 在《Effective C++》中提出的编程范式。

class SingletonPattern
{
public:
    static SingletonPattern *getInstance()
    {
        static SingletonPattern instance;
        return &instance;
    }

    //禁用可能带来风险的会自动生成的特殊函数
    SingletonPattern(const SingletonPattern &) = delete;
    SingletonPattern &operator=(const SingletonPattern &) = delete;
    SingletonPattern(SingletonPattern &&) = delete;
    SingletonPattern &operator=(SingletonPattern &&) = delete;

private:
    SingletonPattern()
    {
        std::cout << "Singleton Pattern Constructor" << std::endl;
    }
};

为啥这样写就是线程安全的呢?实际是C++11标准规定的。参见Static block variables
。里面介绍到:

If multiple threads attempt to initialize the same static local variable concurrently, the initialization occurs
exactly once (similar behavior can be obtained for arbitrary functions with std::call_once).
如果多个线程尝试同时初始化同一个静态局部变量,则初始化只会发生一次(通过 std::call_once 可以为任意函数获得类似的行为)。

看上去这里不再像版本三那样又是原子操作,又是互斥锁,它显得格外的清爽。实际呢这只是编译器实现了C++11标准中上面这条规则,累活让编译器去做了。可以看下它对应的汇编:

SingletonPattern::getInstance()::instance:
        .zero   1
guard variable for SingletonPattern::getInstance()::instance:
        .zero   8
SingletonPattern::getInstance():
        push    rbp
        mov     rbp, rsp
        push    r12
        push    rbx
        movzx   eax, BYTE PTR guard variable for SingletonPattern::getInstance()::instance[rip]
        test    al, al
        sete    al
        test    al, al
        je      .L2
        mov     edi, OFFSET FLAT:guard variable for SingletonPattern::getInstance()::instance
        call    __cxa_guard_acquire
        test    eax, eax
        setne   al
        test    al, al
        je      .L2
        mov     r12d, 0
        mov     edi, OFFSET FLAT:SingletonPattern::getInstance()::instance
        call    SingletonPattern::SingletonPattern() [complete object constructor]
        mov     edi, OFFSET FLAT:guard variable for SingletonPattern::getInstance()::instance
        call    __cxa_guard_release
.L2:
        mov     eax, OFFSET FLAT:SingletonPattern::getInstance()::instance
        jmp     .L7
        mov     rbx, rax
        test    r12b, r12b
        jne     .L5
        mov     edi, OFFSET FLAT:guard variable for SingletonPattern::getInstance()::instance
        call    __cxa_guard_abort
.L5:
        mov     rax, rbx
        mov     rdi, rax
        call    _Unwind_Resume
.L7:
        pop     rbx
        pop     r12
        pop     rbp
        ret

里面可以看到有些地方调用了__cxa_guard_acquire,__cxa_guard_release,__cxa_guard_abort__cxa_atexit。实际也是一些加锁机制。

饿汉式

  饿汉式单例在类的加载过程中便迫不及待地(“饿”的体现)为实例指针分配了动态内存,当有多个线程尝试获取实例指针时,获得的将是同一个实体,因此饿汉式单例天然地具有多线程安全性,但也正因为实例的动态内存分配过程未考虑访问时机,因而会降低程序启动速度。下面是典型的饿汉式单例实现:

class HungrySingleton
{
public:
    static HungrySingleton *getInstance()
    {
        return instance;
    }

    HungrySingleton(const HungrySingleton &) = delete;
    HungrySingleton &operator=(const HungrySingleton &) = delete;
    HungrySingleton(HungrySingleton &&) = delete;
    HungrySingleton &operator=(HungrySingleton &&) = delete;

private:
    HungrySingleton()
    {
    }

    static HungrySingleton *instance;
};

HungrySingleton *HungrySingleton::instance = new HungrySingleton;

单例饿汉式和懒式对比

对比维度 饿汉式 懒汉式
初始化时机 程序启动时 第一次调用getInstance()时
线程安全 天然线程安全 需要特殊处理才能线程安全
资源使用 可能浪费未使用的资源 按需分配更高效
实现复杂度 简单直接 线程安全版本较复杂
性能 调用时无开销 需要运行时检查(双检查锁等)
销毁控制 程序结束时自动销毁 通常不会主动销毁(可手动实现)
C++11最佳实践 不是首选 推荐使用静态局部变量实现
posted @ 2025-06-10 14:04  thammer  阅读(13)  评论(0)    收藏  举报