单例模式的几种 C++ 实现
2024-03-26 09:00:00

写在前面

在开发中,有时候会需要一些类在项目的生命周期中只有一个实例。这样的设计被称作单例模式。

本文用于记录项目开发过程中,设计日志管理器时用到的单例模式,以及开发过程中的一些学习和思考。

饿汉与懒汉

单例模式中有饿汉和懒汉两种模式,饿汉模式指在程序启动时即进行单例的构造;懒汉模式为懒加载,只有第一次调用时才会进行单例的构造。

在多线程开发中,要求对象的 this 指针在构造期间不被泄漏。饿汉模式只要选择合适的单例构造时间,即可保证;但是懒汉模式可能会在多线程并发调用时,触发线程安全问题1

本文主要关注懒汉模式下单例构造时的多线程安全问题。

最简单的版本

在设计单例模式时,考虑到生命周期中只有一个实例,那么类的构造函数应当是不应该被使用者随意调用的,但是又需要生成单例使用。基于这个要求,可以得到最基本的两点:

  1. 类的构造函数应当是 private 的。
  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
41
42
43
// log_manager.h

class LogManager
{
public:
static LogManager* getInstance();

LogManager(const LogManager&) = delete;

LogManager(LogManager&&) = delete;

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

LogManager& operator=(LogManager&&) = delete;

bool writeLog(Buffer& logs);

~LogManager()
{
// clean up
}

private:
explicit LogManager()
{}

private:
static LogManager* m_log_manager_;
};

// log_manager.cpp

LogManager* LogManager::m_log_manager_ = nullptr;

LogManager* LogManager::getInstance()
{
if (m_log_manager_ == nullptr)
{
m_log_manager_ = new LogManager();
}

return m_log_manager_;
}

delete 掉了拷贝构造函数、拷贝复制操作符、移动构造函数和移动复制操作符。在getInstance() 中判断指向实例的指针 m_log_manager_ 的状态,来决定是否需要构造实例。

这个版本足够简单,也可以使用(但是不正常)。这样一个单例,在多线程并发的情况下,是否会触发问题呢?

考虑并发

在并发状态下,如果有多个线程同时使用 getInstance(),可能这多个线程都能通过指针是否为空的检测,然后去创建实例。这样的话,不仅与单例的初衷背道而驰,这些创建出来的实例也没有被保存,造成了内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// log_manager.cpp

LogManager* LogManager::m_log_manager_ = nullptr;
std::mutex LogManager::m_mutex_;

LogManager* LogManager::getInstance()
{
std::lock_guard lock(m_mutex_); // (1)
if (m_log_manager_ == nullptr)
{
m_log_manager_ = new LogManager();
}

return m_log_manager_;
}

easy,加一个锁即可解决的事情。但是很明显,(1) 这个锁加的不是很合适,如果单例已经存在,每次调用时却上锁,锁了个寂寞,还浪费了资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
LogManager* LogManager::getInstance()
{
if (m_log_manager_ == nullptr)
{
std::lock_guard lock(m_mutex_); // (1)
if (m_log_manager_ == nullptr) // (2)
{
m_log_manager_ = new LogManager(); // (3)
}
}

return m_log_manager_;
}

现在把锁放到里面(1),那么在单例已经存在的情况下,就无需上锁。

如果多个线程同时通过外层的检测,那么是否会如一开始的版本一样,创建多个实例呢?通过 (2) 的检测,就可以杜绝这个情况。

new 的原子性问题

当 new 一个实例时,大致做了什么?

  1. 分配一块大小合适的内存。
  2. 在该内存上调用类的构造函数。
  3. 将内存地址写回变量。

可以看到,这是一个由很多指令构成的操作。但是这个顺序可能只是我们想象的顺序,实际上从编译器过一遍,在 CPU 上 2 和 3 的顺序是不一定的。如果先执行 3,然后在执行 2 前,另一个线程调用该方法,进来以后发现指针上有值了,尽管指向的是一块未经初始化的空白内存,但是该线程依然会直接使用这个指针。

而在实例构造期间,就取得该实例的 this 指针,违反了线程安全1

因此可以通过原子操作,避免此处的问题。可见以下代码2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::atomic<LogManager*> LogManager::m_log_manager_ = nullptr;
std::mutex LogManager::m_mutex_;

LogManager* LogManager::getInstance()
{
auto tmp = m_log_manager_.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);

if (tmp == nullptr)
{
std::lock_guard<std::mutex> lock(m_mutex_);
tmp = m_log_manager_.load(std::memory_order_relaxed);
if (tmp == nullptr)
{
tmp = new LogManager();
std::atomic_thread_fence(std::memory_order_release);
m_log_manager_.store(tmp, std::memory_order_relaxed);
}
}

return tmp;
}

std::call_once

以原子操作做处理,比较麻烦。《C++ 并发编程实战》3中提出了另外两种方式。

一种是通过 std::call_once 4 实现,。这个函数准确执行一次可调用对象,即使同时从多线程中调用。

1
2
3
4
5
6
7
8
9
std::atomic<LogManager*> LogManager::m_log_manager_ = nullptr;
std::once_flag resource_flag; // 1

LogManager* LogManager::getInstance()
{
std::call_once(resource_flag,initInstance); // 定义 init_instance() 用于初始化

return m_log_manager;
}

第二种方式提出,使用静态局部变量可能更加高效5

静态局部变量

cppreference 和 《C++ 并发编程实战》都更倾向于在 getInstance() 中通过静态局部变量来实现单例构造。

在之前翻阅文档时,也提到了这种方式。但是当时考虑到静态局部变量在构造时会不会有多线程访问的安全问题,于是没有考虑此方式。但是后续看书时提到,自 C++11 起,多线程同时初始化同一静态局部变量,标准要求初始化严格发生一次6。编译器的相关实现也多是依靠双重检查锁。

由编译器实现双重检查锁总好过自己实现。

1
2
3
4
5
6
LogManager* LogManager::getInstance()
{
static LomManager m_log_manager{};

return &m_log_manager;
}

单例模式的析构

考虑到上文中提到的,除去静态局部变量的方式,其他方式都是 new 出一个实例,那么在使用过程中就要考虑到 delete 的问题。

最简单的方式是提供一个 static void destroyInstance() 在程序结束时调用。但既然是懒汉模式,自然也希望析构也无需关心。

出于这个目的,考虑是否可以通过智能指针管理单例的构造和析构。但是构造函数和析构函数都被设置为 private,智能指针就无法指定用于析构的函数。

构造函数设置为private,是为了单例模式;析构函数设置为private,是为了防止使用者显式析构。

静态局部变量则可以在程序结束时自动析构。


  1. 1.《Linux 多线程服务端编程》1.2 节
  2. 2.代码来源于李建忠的《C++ 设计模式》
  3. 3.《C++ 并发编程实战》3.3.1 节
  4. 4.std::call_once
  5. 5.std::call_once 注解
  6. 6.静态局部变量