19. 模板与多线程:线程安全的单例模板、编译期线程ID
多线程编程,说白了就是让程序同时干好几件事。但问题来了——多个线程同时访问同一份数据,很容易出乱子。我早年做游戏服务器时,就吃过这个亏。一个全局配置对象,两个线程同时读写,结果配置读到一半数据就变了,整个逻辑全乱套。
模板在这里能帮上大忙。我们可以用模板把「线程安全」这个能力封装起来,让任何类型都能轻松获得线程安全的单例特性。今天我们就聊聊这个。
19.1 线程安全的单例模板
单例模式,大家都不陌生。但多线程环境下的单例,坑特别多。你想想看,两个线程同时第一次访问单例,如果没做好同步,可能会创建出两个实例来。
我见过不少新手这么写:
// 不安全的写法
static T* instance = nullptr;
if (!instance) {
instance = new T(); // 多线程下可能执行多次
}
return instance;
嗯,这里要注意。这种「延迟初始化」在多线程下是典型的竞态条件。两个线程同时判断 instance == nullptr,然后都执行 new T(),结果就创建了两个对象。
那怎么解决?C++11 之后,我们有几种靠谱的方案。
方案一:Meyers' Singleton(最推荐)
这是 Scott Meyers 提出的经典做法。利用 C++11 规定的「函数局部静态变量初始化是线程安全的」这个特性。
template<typename T>
class Singleton {
public:
static T& instance() {
static T obj; // C++11 保证这行只初始化一次
return obj;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
~Singleton() = default;
};
我个人习惯用这个方案。代码简洁,没有锁,没有复杂的同步机制。C++11 标准明确说了:static 局部变量的初始化是线程安全的,编译器会帮你加好屏障。
方案二:双重检查锁定 + 原子变量
有些场景下,你可能需要更精细的控制。比如单例对象的构造参数需要动态传入。这时候可以用双重检查锁定。
template<typename T>
class Singleton {
public:
template<typename... Args>
static T& instance(Args&&... args) {
T* ptr = instance_.load(std::memory_order_acquire);
if (!ptr) {
std::lock_guard<std::mutex> lock(mutex_);
ptr = instance_.load(std::memory_order_relaxed);
if (!ptr) {
ptr = new T(std::forward<Args>(args)...);
instance_.store(ptr, std::memory_order_release);
}
}
return *ptr;
}
private:
static std::atomic<T*> instance_;
static std::mutex mutex_;
};
这里用了 std::memory_order_acquire 和 std::memory_order_release 来保证内存可见性。我曾经在调试一个诡异的崩溃问题时,发现就是有人把 memory_order 写成了 relaxed,结果一个线程看到的是半构造的对象。
方案三:call_once + once_flag
C++11 提供了 std::call_once,专门用来解决「只执行一次」的问题。
template<typename T>
class Singleton {
public:
static T& instance() {
std::call_once(flag_, []() {
obj_ = new T();
});
return *obj_;
}
private:
static std::once_flag flag_;
static T* obj_;
};
这个方案的好处是语义清晰,一看就知道「只调用一次」。但性能上比 Meyers' Singleton 略差一点,因为 call_once 内部有锁操作。
19.2 三种方案的对比
| 方案 | 线程安全 | 性能 | 灵活性 | 推荐场景 |
|---|---|---|---|---|
| Meyers' Singleton | ✅ 由标准保证 | 最高(无锁) | 低(无法传参) | 大多数场景 |
| 双重检查锁定 | ✅ 需正确使用原子操作 | 高(只有首次加锁) | 高(可传参) | 需要动态构造参数 |
| call_once | ✅ 标准库保证 | 中等(每次检查标志位) | 中 | 需要明确语义 |
19.3 编译期线程ID
聊完单例,我们来看看另一个有趣的话题——编译期线程ID。你可能会问:「线程ID不都是运行时才有的吗?」没错,但我们可以用模板在编译期生成一个「逻辑线程ID」,用来做静态分发。
为什么要这么做?我举个例子。假设你有一个日志系统,希望每个线程的日志写入不同的缓冲区。运行时获取 std::this_thread::get_id() 当然可以,但它的返回值是 std::thread::id 类型,不能作为模板参数。
我们可以用 __thread 或者 C++11 的 thread_local 来生成编译期可用的 ID:
// 编译期线程ID生成器
template<typename Tag, int N>
struct ThreadIdGenerator {
static thread_local int id;
};
template<typename Tag, int N>
thread_local int ThreadIdGenerator<Tag, N>::id = 0;
// 使用方式
struct LogTag {};
using LogThreadId = ThreadIdGenerator<LogTag, 0>;
void init_thread_id(int tid) {
LogThreadId::id = tid;
}
int get_thread_id() {
return LogThreadId::id;
}
这个技巧的核心是:thread_local 变量在每个线程中有一份独立的拷贝。配合模板的 Tag 参数,我们可以为不同的子系统生成独立的线程ID空间。
19.4 编译期线程ID的应用场景
我在一个网络库中用过这个技巧。每个连接对应一个工作线程,我们需要在线程内部维护一个「无锁队列」来接收数据。用编译期线程ID做索引,可以避免运行时查找的开销。
template<typename T, size_t N>
class ThreadLocalQueue {
public:
void push(const T& item) {
int tid = get_thread_id();
// 直接索引,没有锁
queues_[tid].push(item);
}
T pop() {
int tid = get_thread_id();
return queues_[tid].pop();
}
private:
std::array<LockFreeQueue<T>, N> queues_;
};
这里 N 是最大线程数,在编译期就确定了。每个线程只操作自己的队列,完全不需要锁。性能比全局队列高出一个数量级。
19.5 知识体系总览
下面这张图总结了本章的核心内容:
19.6 一些实用建议
最后,分享几个我在实际项目中积累的经验:
- 优先用 Meyers' Singleton。除非你有特殊需求,否则这个方案最省心。代码少,性能好,不容易出错。
- 别滥用单例。单例本质上是全局状态,会让代码耦合变高。我见过一个项目,里面十几个单例互相依赖,启动顺序调了整整一周。
- 编译期线程ID适合高性能场景。如果你的系统对延迟敏感,比如高频交易、游戏引擎,这个技巧能帮你省掉不少锁的开销。
- 注意线程数量上限。编译期线程ID通常需要预分配数组,所以线程数量是固定的。如果线程数动态变化很大,这个方案就不太合适了。
嗯,关于模板与多线程的内容,今天就聊到这里。这些技巧看起来简单,但用好了能让你的代码既安全又高效。下次遇到多线程下的全局对象,不妨试试这些模板方案。