C++ 多线程编程(三):单例模式
1. 单例模式介绍
单例模式(Singleton Pattern)是设计模式的一种,其特点是只提供唯一一个类的实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
定义一个单例类:
- 私有化它的构造函数,以防止外界创建单例类的对象;
- 使用类的私有静态指针变量指向类的唯一实例;
- 使用一个公有的静态方法获取该实例。
具体运用场景:
- 设备管理器,系统中可能有多个设备,但是只有一个设备管理器,用于管理设备驱动;
- 数据池,用来缓存数据的数据结构,需要在一处写,多处读取或者多处写,多处读取;
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
可以看到这里的实例并不是唯一的!这种方式会导致两个问题:
线程安全的问题
当多线程获取单例时有可能引发竞态条件:第一个线程在if中判断
m_instance_ptr
是空的,于是开始实例化单例;同时第2个线程也尝试获取单例,这个时候判断m_instance_ptr
还是空的,于是也开始实例化单例;这样就会实例化出两个对象,这就是线程安全问题的由来;解决办法:加锁
内存泄漏
注意到类中只负责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
析构函数
这是最推荐的一种单例实现方式:
- 通过局部静态变量的特性保证了线程安全 (C++11, GCC > 4.3, VS2015支持该特性);
- 不需要使用共享指针,代码简洁;
- 注意在使用的时候需要声明单例的引用
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 instance
和static 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)最优雅。