设计模式-创建型模式之-单例模式
正常情况下一个类定义后,可以创建很多实例。但是有时候我们有一种这样的需求:希望程序中只有一个这样的实例,以避免资源访问的冲突、干扰等。例如,我们知道,串口这种设备通常是独占的,如果设计一个类,使用指定串口通讯。为了避免使用冲突,在不使用单例模式时,我们必须小心谨慎的确保只有一个这样的实例存在。但是人总会犯错,使用某种机制来进行约束才是最佳方案。这种机制就是单例模式:顾名思义,单例模式就是类只能创建一个实例的一种设计模式。
我们知道类实例化是通过构造函数实现,那如何保证类只创建一个实例呢?显然需要在构造函数上做手脚(因为类的实例化就在它里面进行的嘛)。
单例的写法有两种:
- 饿汉式
- 懒汉式
所谓饿汉式就是一开始就创建了对象(急迫的需要对象)。
所谓懒汉式就是按需创建对象(想要对象了,才创建)。
懒汉式单例-存在缺陷的版本
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最佳实践 | 不是首选 | 推荐使用静态局部变量实现 |

浙公网安备 33010602011771号