目录
二、读写锁:std::shared_mutex (C++17)
[代码示例:原子计数器与 CAS](#代码示例:原子计数器与 CAS)
四、条件变量:std::condition_variable
[用途: 避免线程间竞争、存储线程特定的状态(如随机数生成器状态、数据库连接)。](#用途: 避免线程间竞争、存储线程特定的状态(如随机数生成器状态、数据库连接)。)
[推荐方案:Meyers Singleton](#推荐方案:Meyers Singleton)
引言
在现代软件开发中,多线程编程是提升程序性能、充分利用多核处理器的关键手段。然而,当多个线程同时访问共享资源时,如果没有适当的同步机制,就会引发"线程安全"问题。
简单来说,线程安全指的是代码在多线程并发执行时,其行为依然符合预期,不会产生数据竞争(Data Race)或不一致的状态。如果不加控制,后果可能是灾难性的:数据被意外修改导致程序崩溃(脏读)、关键资源被锁定导致程序死循环(死锁),或者程序运行结果完全不可预测。
对于 C++ 开发者而言,由于语言本身不强制内存管理,线程安全的责任完全落在开发者肩上。本文将带你深入浅出地掌握 C++ 中保证线程安全的核心工具箱。
一、互斥锁:std::mutex
互斥锁(Mutex)是多线程编程中最基础、最常用的同步原语。你可以把它想象成一个"厕所的门锁":同一时间,只能有一个线程(人)进入厕所(临界区)进行操作,其他线程必须在门外排队等待。
在 C++ 中,我们通常不直接手动调用
lock()和unlock(),而是利用 RAII 机制,通过智能包装器来自动管理锁的生命周期,防止因异常或提前返回导致的死锁。
核心组件:
std::lock_guard:最简单的守卫,构造时加锁,析构时解锁,不可手动移动或释放。std::unique_lock:更灵活的守卫,支持延迟锁定、手动解锁、条件变量配合等。
代码示例:线程安全的计数器
cpp#include <iostream> #include <thread> #include <vector> #include <mutex> class Counter { private: int value; mutable std::mutex mtx; // mutable 允许在 const 函数中加锁 public: Counter() : value(0) {} void increment() { std::lock_guard<std::mutex> lock(mtx); // 自动加锁 ++value; // 临界区操作 // 离开作用域时,lock_guard 自动析构并释放锁 } int get() const { std::lock_guard<std::mutex> lock(mtx); return value; } }; // 测试函数 void worker(Counter& counter) { for (int i = 0; i < 1000; ++i) { counter.increment(); } } int main() { Counter counter; std::vector<std::thread> threads; // 创建 10 个线程 for (int i = 0; i < 10; ++i) { threads.emplace_back(worker, std::ref(counter)); } for (auto& t : threads) { t.join(); } std::cout << "Final counter value: " << counter.get() << std::endl; return 0; }
二、读写锁:std::shared_mutex (C++17)
有时候,我们的场景是"读多写少"。如果使用普通的互斥锁,多个读线程也会相互阻塞,这就好比图书馆里,如果一个人在看书(读),其他人哪怕只是想翻阅,也得排队等着,效率极低。
C++17 引入了
std::shared_mutex(或std::shared_timed_mutex):
- 共享锁(
std::shared_lock):用于读操作。多个线程可以同时持有共享锁。- 独占锁(
std::unique_lock):用于写操作。同一时间只能有一个线程持有,且此时不允许读。
代码示例:线程安全的缓存
cpp
#include <map>
#include <string>
#include <shared_mutex> // C++17
#include <thread>
class Cache {
private:
std::map<std::string, int> data;
mutable std::shared_mutex rw_mutex; // 读写锁
public:
// 读操作:获取数据
int getValue(const std::string& key) const {
std::shared_lock<std::shared_mutex> lock(rw_mutex); // 获取共享锁(读锁)
auto it = data.find(key);
return (it != data.end()) ? it->second : -1;
}
// 写操作:设置数据
void setValue(const std::string& key, int value) {
std::unique_lock<std::shared_mutex> lock(rw_mutex); // 获取独占锁(写锁)
data[key] = value;
}
};
三、原子操作:std::atomic
如果共享的数据非常简单,比如只是一个整数或者布尔标志,使用互斥锁可能会因为"上下文切换"和"内核态切换"带来较大的性能开销。这时候,我们可以使用硬件支持的原子操作。
原子操作是不可分割的,CPU 保证这些操作要么完全执行,要么完全没执行,不存在中间状态。
适用场景: 计数器、状态标志(如停止信号)。 性能优势: 通常比 Mutex 快 5-10 倍,因为它通常由单条 CPU 指令完成。
代码示例:原子计数器与 CAS
cpp
#include <atomic>
#include <thread>
#include <vector>
class AtomicCounter {
private:
std::atomic<int> value{0};
public:
// 原子自增
void increment() {
value.fetch_add(1, std::memory_order_relaxed);
}
// 比较并交换 (Compare-and-Swap) - 无锁编程的核心
bool compareAndSet(int expected, int desired) {
// 尝试将 value 从 expected 改为 desired
// 如果 value 等于 expected,则修改并返回 true;否则更新 expected 为当前值并返回 false
return value.compare_exchange_strong(expected, desired);
}
int get() const {
return value.load();
}
};
四、条件变量:std::condition_variable
互斥锁解决了"互斥"的问题,但如何解决"同步"问题呢?例如,生产者生产了数据,如何通知消费者来取?如果让消费者一直轮询(while 循环检查),会白白消耗 CPU 资源。
条件变量允许线程阻塞(睡眠)在一个条件上,直到另一个线程改变该条件并发出通知。
核心逻辑:
wait()会自动释放锁并阻塞;被唤醒后,会重新获取锁继续执行。
代码示例:生产者-消费者队列
cpp
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
template<typename T>
class ThreadSafeQueue {
private:
std::queue<T> q;
mutable std::mutex mtx;
std::condition_variable cv;
bool finished; // 结束标志
public:
ThreadSafeQueue() : finished(false) {}
void push(T value) {
std::lock_guard<std::mutex> lock(mtx);
q.push(value);
cv.notify_one(); // 唤醒一个等待的消费者
}
bool pop(T& value) {
std::unique_lock<std::mutex> lock(mtx);
// 使用 while 循环防止虚假唤醒
cv.wait(lock, [this] { return !q.empty() || finished; });
if (q.empty()) return false; // 队列为空且结束
value = q.front();
q.pop();
return true;
}
void setFinished() {
{
std::lock_guard<std::mutex> lock(mtx);
finished = true;
}
cv.notify_all(); // 通知所有消费者结束
}
};
五、线程局部存储:thread_local
有时候,我们不希望数据被共享,而是希望每个线程都有自己独立的一份拷贝。这就像是每个线程都有自己的"私有笔记本",互不干扰。
thread_local是 C++11 引入的存储期说明符。
用途: 避免线程间竞争、存储线程特定的状态(如随机数生成器状态、数据库连接)。
cpp#include <iostream> #include <thread> void threadFunc() { // 每个线程都有独立的 counter thread_local int counter = 0; ++counter; std::cout << "Thread " << std::this_thread::get_id() << " counter: " << counter << std::endl; } int main() { std::thread t1(threadFunc); // 输出 1 std::thread t2(threadFunc); // 输出 1 (独立副本) t1.join(); t2.join(); return 0; }这个输出混乱的问题是由于多个线程同时向
std::cout输出导致的竞争条件 ,而不是thread_local的问题。
六、单例模式的线程安全实现
单例模式是面试常客,而线程安全的单例更是重中之重。
C++11 标准规定:局部静态变量的初始化是线程安全的。这是最简洁、最安全的实现方式。
推荐方案:Meyers Singleton
cpp
class Singleton {
public:
// 获取唯一实例
static Singleton& getInstance() {
static Singleton instance; // C++11 线程安全
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
~Singleton() = default;
};
// C++11 之前的做法(了解即可)
// std::call_once 和 std::once_flag
std::once_flag flag;
void init() {
std::call_once(flag, [](){ /* 初始化代码 */ });
}
七、常见陷阱与最佳实践
在编写多线程代码时,请务必警惕以下陷阱:
| 类别 | 描述 | 解决方案 |
|---|---|---|
| 陷阱 1:返回内部数据引用 | 函数返回了受保护数据的指针或引用,调用者使用时锁可能已经释放,导致悬空指针/引用问题。 | • 返回数据的副本 而非引用 • 返回智能指针(如 std::shared_ptr) • 通过回调函数在锁内处理数据 • 使用线程安全的数据结构(如 concurrent_queue) |
| 陷阱 2:const 函数不加锁 | 认为 const 函数只是只读操作,忘记在多线程环境下读操作也需要同步,可能导致读到不一致的中间状态。 | • const 成员函数也要使用锁(如 std::shared_lock) • 将共享数据成员声明为 mutable,以便在 const 函数中加锁 • 使用读写锁优化读多写少的场景 |
| 陷阱 3:持有锁时调用外部函数 | 持有锁期间调用了用户提供的回调函数、虚函数或外部接口,这些函数可能尝试获取其他锁,导致死锁 或锁顺序反转。 | • 缩小锁的范围 ,在调用外部函数前释放锁 • 明确文档化锁的层级约定 • 使用 std::lock 或 std::try_lock 同时获取多个锁 • 避免在持有锁时调用不可控的外部代码 |
| 陷阱 4:死锁与锁顺序 | 多个线程以不同顺序获取多个互斥锁,导致相互等待,形成死锁。 | • 统一锁的获取顺序 (如按地址大小排序) • 使用 std::lock 一次性获取多个锁 • 使用 std::scoped_lock(C++17)自动管理多锁 • 使用层级锁(Hierarchical Mutex)检测死锁 |
| 陷阱 5:锁的范围不当 | 锁的范围过大,持有锁的时间过长,导致并发性能严重下降,甚至退化为串行执行。 | • 只保护真正需要同步的临界区 • 将耗时操作(如 I/O、计算)移到锁外 • 使用细粒度锁或无锁数据结构 • 考虑使用读写锁或原子操作替代互斥锁 |
| 实践类别 | 核心原则 |
|---|---|
| 最小化锁范围 | 只锁定必要的临界区,耗时操作放在锁外执行 |
| 统一锁顺序 | 所有线程以相同顺序获取多个互斥锁,避免死锁 |
| 优先 RAII | 使用 std::lock_guard、std::unique_lock 等 RAII 管理锁,防止异常导致锁未释放 |
| 避免嵌套锁 | 尽量减少同时持有多个锁,如无法避免则确保使用 std::lock 或 std::scoped_lock |
| 复制优于引用 | 优先返回数据的副本而非内部引用,避免悬空指针风险 |
| 文档化线程安全保证 | 明确标注哪些函数是线程安全的,以及锁的使用约定 |
总结
多线程编程是一把双刃剑,既能带来性能的飞跃,也可能引入难以排查的 Bug。掌握线程安全的核心在于理解不同工具的适用场景。
选择指南:
- 简单整数/标志位: 优先使用
std::atomic。- 复杂数据结构(读写): 使用
std::mutex。- 读多写少场景: 使用
std::shared_mutex(C++17)。- 线程间同步/通信: 使用
std::condition_variable。- 线程私有数据: 使用
thread_local。
核心原则:
- 保护共享数据:只要有数据被多个线程访问,就必须同步。
- 最小化锁持有时间:只在必须时持有锁,尽快释放。
- 避免死锁:按顺序加锁,避免锁中锁。

