单例模式(C++版)

引言:

面向对象很好地解决了”抽象”的问题,但是必不可免地要付出一定的代价。对于通常情况来讲,面向对象的成本大都可以忽略不不计。但是某些情况,面向对象所带来的成本必须谨慎处理。

在一个项目中,全局范围内,某个类的实例有且仅有一个,通过这个唯一实例向其他模块提供数据的全局访问,这种模式就叫单例模式。单例模式的典型应用就是任务队列。

1. 动机(Motivation)

  1. 在软件系统中,经常有这样一些特殊的类,必须保证它们在系统中只存在一个实例,才能确保它们的逻辑正确性、以及良好的效率。

  2. 如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例?

  3. 这应该是类设计者的责任,而不是使用者的责任。

2.实现

我们必须采取一些措施,保证只有一个实例。对于C++而言,涉及一个类多对象操作的函数有以下几个:

  1. 构造函数:创建一个新的对象
  2. 拷贝构造函数:根据已有对象拷贝出一个新的对象
  3. 拷贝赋值操作符重载函数:两个对象之间的赋值

针对上面的三种情况,我们设计类的时候,应该做如下处理:

  1. 构造函数私有化,在类内部只调用一次,这个是可控的。
    • 由于使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态的,这样就可以通过类名来访问了,为了不破坏类的封装,我们都会把这个静态对象的访问权限设置为私有的。
    • 在类中只有它的静态成员函数才能访问其静态成员变量,所以可以给这个单例类提供一个静态函数用于得到这个静态的单例对象。
  2. 拷贝构造函数私有化或者禁用(使用 = delete)。
  3. 拷贝赋值操作符重载函数私有化或者禁用(从单例的语义上讲这个函数已经毫无意义,所以在类中不再提供这样一个函数,故将它也一并处理一下。)

3.类图

image-20220901082949746

3.1 定义一个单例模式的类的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个单例模式的类
class Singleton
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
Singleton(const Singleton& obj) = delete;
Singleton& operator=(const Singleton& obj) = delete;
static Singleton* getInstance();
private:
Singleton() = default;
static Singleton* m_obj;
};

4.具体实现

在实现一个单例模式的类的时候,有两种处理模式:

  • 饿汉模式
  • 懒汉模式

两者的区别在于

  • 饿汉式是在类加载时候完成实:例化的。懒汉式是延时加载的,当需要的时候才进行实例化。

  • 饿汉式是[线程安全]的,在类创建的同时就已经创建好一个静态的对象供系统使用,以后不在改变。懒汉式需要自己实现线程安全。

4.1 饿汉式实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

// 饿汉模式
#include<iostream>
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
static TaskQueue* getInstance()
{
return m_taskQ;
}
void printf_text(){
std::cout<<"this is a Singleton"<<std::endl;
}
private:
TaskQueue() = default;
static TaskQueue* m_taskQ;
};
// 静态成员初始化放到类外部处理
TaskQueue* TaskQueue::m_taskQ = new TaskQueue;

int main()
{
TaskQueue* obj = TaskQueue::getInstance();
obj->printf_text();
}

定义这个单例类的时候,就把这个静态的单例对象创建出来了。

类的静态成员变量在使用之前必须在类的外部进行初始化才能使用。

4.2 懒汉式实现

懒汉模式是在类加载的时候不去创建这个唯一的实例,而是在需要使用的时候再进行实例化。

4.2.1 单线程使用,多线程不安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 懒汉模式
#include <iostream>
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue &obj) = delete;
TaskQueue &operator=(const TaskQueue &obj) = delete;
static TaskQueue *getInstance()
{
if (m_taskQ == nullptr)
{
m_taskQ = new TaskQueue;
}
return m_taskQ;
}
void printf_text()
{
std::cout << "this is a Singleton" << std::endl;
}

private:
TaskQueue() = default;
static TaskQueue *m_taskQ;
};

TaskQueue *TaskQueue::m_taskQ = nullptr;

int main()
{
TaskQueue *obj_1 = TaskQueue::getInstance();
TaskQueue::getInstance()->printf_text();
return 0;
}

在调用getInstance()函数获取单例对象的时候,如果在单线程情况下是没有什么问题的,如果是多个线程,调用这个函数去访问单例对象就有问题了。假设有三个线程同时执行了getInstance()函数,在这个函数内部每个线程都会new出一个实例对象。此时,这个任务队列类的实例对象不是一个而是3个,很显然这与单例模式的定义是相悖的。

4.2.2 多线程安全,加锁代价高

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 懒汉模式
#include <iostream>
#include <mutex>
//互斥锁mutex
std::mutex mt;
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue &obj) = delete;
TaskQueue &operator=(const TaskQueue &obj) = delete;
static TaskQueue *getInstance()
{
//加锁
mt.lock();
if (m_taskQ == nullptr)
{
m_taskQ = new TaskQueue;
}
mt.unlock();
return m_taskQ;
}
void printf_text()
{
std::cout << "this is a Singleton" << std::endl;
}

private:
TaskQueue() = default;
static TaskQueue *m_taskQ;
};

TaskQueue *TaskQueue::m_taskQ = nullptr;

int main()
{
TaskQueue *obj_1 = TaskQueue::getInstance();
TaskQueue::getInstance()->printf_text();
return 0;
}

存在资源浪费,当其实只有当存在写操作的时候,我们才需要加锁,现在即使是获取实例,也需要等待。

4.2.3 双检查锁,由于内存读写导致不安全(reorder导致)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 懒汉模式
#include <iostream>
#include <mutex>

std::mutex mt;
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue &obj) = delete;
TaskQueue &operator=(const TaskQueue &obj) = delete;
static TaskQueue *getInstance()
{
if (m_taskQ == nullptr)
{
mt.lock();
if (m_taskQ == nullptr)
{
m_taskQ = new TaskQueue;
mt.unlock();
}
return m_taskQ;
}

}
void printf_text()
{
std::cout << "this is a Singleton" << std::endl;
}

private:
TaskQueue() = default;
static TaskQueue *m_taskQ;
};

TaskQueue *TaskQueue::m_taskQ = nullptr;

int main()
{
TaskQueue *obj_1 = TaskQueue::getInstance();
TaskQueue::getInstance()->printf_text();
return 0;
}

“双重检查”Double-Check。看似完美,但是存在致命的问题。

对于我们正常的思维来说:

1
m_taskQ = new TaskQueue;  

应该是会顺序进行下面三个步骤:

  1. singleton对象分配空间。

  2. 在分配的空间中构造对象

  3. 使m_taskQ指向分配的空间

但是代码到了汇编层次,到了CPU的指令层,线程的执行顺序是随机的,他需要去抢时间片,所有它的实际步骤会和我们假设的不一样。比如就会分配空间,然后就会使m_taskQ指向分配的空间,最后在进行对象的构造。如果出现了这种情况,那么指针指向内存里面的值是不可用的。

这样重排序并不影响单线程的执行结果,但是在多线程中就会出问题。如果线程A按照第二种顺序执行机器指令,执行完前两步之后失去CPU时间片被挂起了,此时线程B在第3行处进行指针判断的时候m_taskQ 指针是不为空的,但这个指针指向的内存却没有被初始化,最后线程 B 使用了一个没有被初始化的队列对象就出问题了(出现这种情况是概率问题,需要反复的大量测试问题才可能会出现)。

4.3 C++11之后的最优解

4.3.1静态局部对象

在实现懒汉模式的单例的时候,相较于双重检查锁定模式有一种更简单的实现方法并且不会出现线程安全问题,那就是使用静态局部局部对象,对应的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 懒汉模式 C++ 11之后可以使用 静态局部变量
#include <iostream>
#include <mutex>
using namespace std;
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
static TaskQueue* getInstance()
{
static TaskQueue taskQ;
return &taskQ;
}
void print()
{
cout << "hello, world!!!" << endl;
}

private:
TaskQueue() = default;
};
int main()
{
TaskQueue* queue = TaskQueue::getInstance();
queue->print();
return 0;
}

这种单例被称为Meyers' Singleton。这种方法很简洁,也很完美,但是注意:

  1. gcc 4.0之后的编译器支持这种写法。
  2. C++11及以后的版本(如C++14)的多线程下,正确。
  3. C++11之前不能这么写。

可以这样实现的原因:

C++11规定:如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。

4.3.2 原子变量atomic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
static TaskQueue* getInstance()
{
TaskQueue* queue = m_taskQ.load();
if (queue == nullptr)
{
// m_mutex.lock(); // 加锁: 方式1
lock_guard<mutex> locker(m_mutex); // 加锁: 方式2
queue = m_taskQ.load();
if (queue == nullptr)
{
queue = new TaskQueue;
m_taskQ.store(queue);
}
// m_mutex.unlock();
}
return queue;
}

void print()
{
cout << "hello, world!!!" << endl;
}
private:
TaskQueue() = default;
static atomic<TaskQueue*> m_taskQ;
static mutex m_mutex;
};
atomic<TaskQueue*> TaskQueue::m_taskQ;
mutex TaskQueue::m_mutex;

int main()
{
TaskQueue* queue = TaskQueue::getInstance();
queue->print();
return 0;
}

上面代码中使用原子变量atomic的store() 方法来存储单例对象,使用load() 方法来加载单例对象。在原子变量中这两个函数在处理指令的时候默认的原子顺序是memory_order_seq_cst(顺序原子操作 - sequentially consistent),使用顺序约束原子操作库,整个函数执行都将保证顺序执行,并且不会出现数据竞态(data races),不足之处就是使用这种方法实现的懒汉模式的单例执行效率更低一些。

4.3.3 call_once方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

std::once_flag flag;

class Singleton
{
public:
static Singleton& getInstance()
{
std::call_once(flag, []() {instance_.reset(new Singleton()); });
return *instance_;
}

private:
static std::unique_ptr<Singleton> instance_;

private:
Singleton() = default;
Singleton(const Singleton& other) = delete;
Singleton& operator=(const Singleton&) = delete;
};

std::unique_ptr<Singleton> Singleton::instance_;

void do_onceflag()
{
Singleton& s = Singleton::getInstance();
cout << &s << endl;
}

int main()
{
std::thread t1(do_onceflag);
std::thread t2(do_onceflag);

t1.join();
t2.join();

return 0;
}

单例模式(C++版)
https://tangerine-kitten-lux.netlify.app/单例模式/
作者
lux
发布于
2024年1月21日
许可协议