前言
这是在头条客户端面试的时候提到的,当时只知道单例模式保证对象唯一,没有考虑实际使用中会发生什么,面完了认真了解下“单例模式”,做下总结。
另外,面试和平时准备的东西还是有区别的,平时准备的可能比较基础(概念为主),面试更多是这些概念在实际使用中能否解决对应的问题,并是否会引入其他的问题等。
实际使用中,均是多进程、多线程编程为主,因此进程之间的通信(IPC),线程之间的同步很重要,在思考问题的时候,一定要考虑当前问题的解法,是否“多进程或多线程安全”,如果不安全,是否有解决方法来保证当前算法达到“多进程或多线程安全”的要求。
单例模式
单例模式
单例模式确保一个类只有一个对象,并提供一个全局访问点。
实现思路
- 构造函数设为私有
- 使用static定义对象指针,定义类的get_instance成员函数。
具体实现(Lazy Initlization)
按照以上的规则和要求,实现了一下,这是“Lazy Initlization”实现方式,将对象的生成推迟到第一次访问的时候。
//1. Lazy initlization.
class Singleton{
public:
static Singleton * get_instance(){
if(instance_ == nullptr){
instance_ = new Singleton();
}
return instance_;
};
protected:
Singleton(){};
private:
static Singleton * instance_;
};
Singleton * Singleton::instance_ = nullptr;
void test1(){
Singleton * p1 = Singleton::get_instance();
Singleton * p2 = Singleton::get_instance();
printf("p1 addr : %p\n", *p1);
printf("p2 addr : %p\n", *p2);
}
int main() {
test1();
return 0;
}
上面的代码中,test1函数中对象的地址输出均一样,说明访问的对象是唯一的。
在单线程的情况下,该单例模式的实现是安全可用的。
但是多线程环境中,尤其是在初始化的这段代码中:
static Singleton * Singleton::get_instance(){
if(instance_ == nullptr){
instance_ = new Singleton();
}
return instance_;
}
两个线程可能同时执行到 i f ( i n s t a n c e = = n u l l p t r ) if(instance_\ == nullptr) if(instance ==nullptr)这句,由于均是首次访问,条件都成立,然后都进行了对象的实例化,导致进程中有该类有多个对象。
多线程安全的单例模式
而解决上述问题,最简单粗暴的方法是加锁,在每次判断的时候确保只有一个线程在执行该语句。
//2. lazy initlization + mutex
static mutex m_;
static Singleton * Singleton::get_instance(){
m_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
m_.unlock();
return instance_;
}
然而这种加锁方法在每次判断前都会进行一次加锁,会极大的增加系统的开销。
于是有人提出了“双检锁”的概念,相较于之前,增加了一次判断,并将“加锁”操作放到了第一次判断成立之后。
//2.1 lazy initlization + double-check
static mutex m_;
static Singleton * Singleton::get_instance(){
if(instance_ == nullptr){
m_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
m_.unlock();
}
return instance_;
}
在线程较多的情况下,“双检锁”能明显的降低了“加锁”带来的开销。
但这样真的就线程安全了么?
new 背后的操作
我们再来看看单例模式中最核心的一条语句:
if(instance_ == nullptr){
m_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
m_.unlock();
}
其中, i n s t a n c e = n e w S i n g l e t o n ( ) ; instance_ = new Singleton(); instance=newSingleton(); 用new实例化了一个对象,而new本身隐含了一下几个操作:
- 按照类的大小,申请对象的内存区域
- 执行类的构造函数
- 将内存区域的首地址返回给instance_
其中,2, 3在执行的时候可能会颠倒,即先返回地址,再执行构造函数。
考虑这样的场景:
单例模式还未实例化,A线程先进入 g e t i n s t a n c e 函 数 get_instance函数 getinstance函数,并加锁,执行 i n s t a n c e = n e w S i n g l e t o n ( ) ; instance_ = new Singleton(); instance=newSingleton();这句的时候,new中的操作顺序是1, 3, 2,即地址返回,构造函数还未执行。
但这种情况下,instance_ 已经不为空,另一个线程B在进行 i f ( i n s t a n c e = = n u l l p t r ) if(instance_ == nullptr) if(instance==nullptr)判断时,会直接得到instance_,并使用。
而此时线程A中的new还没来得及执行类的构造函数,所以线程B在使用 i n s t a n c e instance instance指针的时候一定会出现问题。
由此,这种方式实现的“双检锁”并不能真正达到多线程安全。
C++11中的双检锁
仔细思考下new背后的三个步骤,如果按照1, 2, 3的顺序执行的话,并不会发生问题。
再进一步的思考,这里实际上违反了"多线程操作的原子性",如果将1, 2, 3步骤按顺序封装成一个原子操作,即可解决问题。
C++11中的新特性能很好的解决这个问题,而且不止有一种解决方法,这里先提一种:
C++11原子操作
C++11中引入了原子操作,在多个线程中对这些类型的共享资源进行操作,编译器将保证这些操作都是原子性的。
对以上的代码修改如下:
//3. base 1. implemetation, use atomic.
class Singleton {
public:
static Singleton * get_instance()
{
Singleton * temp = instance_.load();
if(temp == nullptr) {
m_.lock();
temp = instance_.load();
if(temp == nullptr) {
temp = new Singleton();
instance_.store(temp);
}
m_.unlock();
}
return temp;
};
protected:
Singleton() {};
private:
static atomic<Singleton*> instance_;
static mutex m_;
};
atomic<Singleton *> Singleton::instance_;
mutex Singleton::m_;
这里使用使用atomic来保证instance_操作时的原子性。
静态对象
我们分析下,上述发生的线程安全问题均是在初始化的时候,自然地,如果在进程刚开始的时候便生成对象,生命周期贯穿整个进程的周期,似乎可避免这个问题。
如果我们把单例对象类型设为static,这样唯一的对象会被分配在数据区,而不是new之后的堆区,便能达到以上的效果。
这里将对象定义为局部静态变量。
这里还可能存在一个问题:static保证了对象的唯一性,但多线程的环境下,static修饰的对象可能会被初始化多次,这种情况怎么办?
万幸的是,C++11的新特性解决了我们这个顾虑。
If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。
换言之,局部静态变量只会被初始化一次。
//4
class Singleton {
public:
static Singleton & get_instance()
{
static Singleton instance;
return instance;
};
protected:
Singleton() {};
};
//使用
void get_singleton_instance()
{
Singleton & p = Singleton::get_instance();
printf("instance addr : %p\n", &p);
}
多线程安全问题
写到这里就告一段落了,总结一下上面遇到的多线程问题:
- 多线程对共享资源的判断(一定要加锁进行访问判断,然后再进入临界区)
- 访问被释放的资源(当一个线程对资源进行访问的时候,一定要确保该资源存在(因为别的线程可能将其资源释放))
- 对临界资源操作时,一定要保证操作的原子性。
参考
这里只是简单总结了下单例模式以及常规的实现,没有涉及到生产环境中的具体使用(如,单例类被其他类继承该怎么办,使用中如何实现可靠的运算符重载等…),
想要更深一步了解单例模式的可以参考下面的资料。