如何在现代 C++(C++17/20)中实现线程安全的单例模式?
正文:
在多线程环境下,单例模式常用于共享资源(如日志器、配置管理器、数据库连接池等)。传统的单例实现容易产生竞争条件或双重检查锁定(Double-Check Locking)缺陷。自 C++11 起,标准库提供了对线程安全的静态局部变量初始化的保证,结合 std::call_once,我们可以轻松实现高效且安全的单例。
下面给出一个完整的实现示例,并说明其工作原理、性能特点以及常见误区。
1. 需求分析
- 单例对象:只能有一个实例。
- 懒加载:对象在第一次使用时才创建。
- 线程安全:多线程并发访问时不会产生竞态。
- 高性能:创建后每次访问不需要加锁。
2. 关键技术点
-
局部静态变量
- C++11 之后,局部静态变量的初始化是线程安全的。第一次进入作用域时,编译器会生成必要的同步代码。
- 适合懒加载,避免一次性构造全局对象导致的“静态初始化顺序问题”。
-
std::call_once与std::once_flag- 通过
std::call_once可以在多线程环境中保证某个函数只被调用一次,常用于实现单例或延迟初始化。 - 与
std::once_flag配合使用。
- 通过
-
构造函数私有化
- 防止外部直接实例化,保持单例完整性。
-
删除拷贝与移动构造
- 防止复制或移动导致多个实例。
3. 代码实现
// singleton.hpp
#pragma once
#include <mutex>
#include <memory>
#include <iostream>
// 线程安全的单例模板(C++17 兼容)
template <typename T>
class Singleton
{
public:
// 获取单例实例(引用)
static T& instance()
{
// 1. 静态局部变量,保证懒加载且线程安全
static std::once_flag init_flag;
static std::unique_ptr <T> ptr;
// 2. 仅初始化一次
std::call_once(init_flag, []{
ptr = std::make_unique <T>();
});
return *ptr;
}
// 禁止拷贝构造和移动构造
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
protected:
Singleton() = default;
~Singleton() = default;
};
// 业务类示例:日志器
class Logger : private Singleton <Logger>
{
friend class Singleton <Logger>; // 允许 Singleton 访问构造函数
public:
void log(const std::string& msg)
{
std::lock_guard<std::mutex> lock(mutex_);
std::cout << "[LOG] " << msg << std::endl;
}
private:
Logger() { std::cout << "Logger constructed\n"; }
std::mutex mutex_;
};
使用方式
#include "singleton.hpp"
#include <thread>
void worker(int id)
{
auto& logger = Logger::instance(); // 线程安全获取
logger.log("Worker " + std::to_string(id) + " started");
}
int main()
{
std::thread t1(worker, 1);
std::thread t2(worker, 2);
std::thread t3(worker, 3);
t1.join(); t2.join(); t3.join();
return 0;
}
运行结果(示例)
Logger constructed
[LOG] Worker 1 started
[LOG] Worker 2 started
[LOG] Worker 3 started
4. 细节说明
-
构造顺序
- 由于使用
std::once_flag与std::call_once,ptr的初始化在第一次调用instance()时完成,避免了静态初始化顺序错误(static initialization order fiasco)。
- 由于使用
-
异常安全
- 如果
T的构造函数抛异常,std::call_once会再次尝试调用,直至成功为止。
- 如果
-
销毁时机
- `unique_ptr ` 的析构会在程序结束时自动销毁单例实例。若需自定义销毁时机,可将 `ptr` 替换为 `shared_ptr` 或手动管理。
-
性能
- 第一次调用需要
std::call_once的同步;随后调用只需访问静态局部变量,无需加锁,几乎无开销。
- 第一次调用需要
-
多继承
- 如果业务类多继承自多个单例,可能导致二义性。可使用 CRTP(Curiously Recurring Template Pattern)或
std::shared_ptr的组合方式解决。
- 如果业务类多继承自多个单例,可能导致二义性。可使用 CRTP(Curiously Recurring Template Pattern)或
5. 常见误区
| 误区 | 说明 |
|---|---|
| 使用宏实现单例 | 宏无法捕获异常,缺乏类型安全,难以维护。 |
| 单例作为全局对象 | 可能导致全局初始化顺序问题。 |
| 手动加锁 | 过度加锁会导致性能下降;正确使用 std::call_once 可避免。 |
| 未删除拷贝构造 | 允许复制会破坏单例。 |
| 在 C++11 之前使用局部静态 | 不是线程安全,需使用 std::call_once 或其他同步。 |
6. 进一步阅读
- Herb Sutter,“C++ Concurrency in Action”(第 3 章:单例与懒加载)
- Bjarne Stroustrup,“The C++ Programming Language”(第 13 章:单例模式)
- ISO C++ 标准草案([N4861])关于 “statics” 的线程安全保证
结语
通过结合 C++11 的线程安全局部静态初始化与 std::call_once,我们可以在不牺牲性能的前提下,实现高效且安全的单例。只需遵循上述模板,即可在任何业务类中快速部署单例模式,减少手工同步的麻烦,让代码更简洁、可靠。