18、STL与多线程:线程安全概念、共享数据保护、锁的使用、原子操作
多线程编程,说白了就是让程序同时干好几件事。但问题来了——当多个线程同时访问同一个数据时,会发生什么?
我刚开始接触多线程时,觉得这玩意儿挺酷。直到有一次,我在一个高并发服务里用了一个全局的 std::vector 来缓存用户状态,结果线上频繁崩溃。查了两天才发现,两个线程同时 push_back 导致内存错乱。嗯,从那以后,我对「线程安全」这四个字再也不敢掉以轻心了。
什么是线程安全?
一个数据结构或函数,如果被多个线程同时调用,不会出现数据竞争、不会产生未定义行为,那它就是线程安全的。
但这里有个坑:STL 容器默认不是线程安全的。你想想看,std::vector 的 push_back 可能会触发 reallocation,如果两个线程同时触发,内存就乱套了。
核心原则:多个线程可以同时读取同一个 STL 容器,但只要有一个线程在写,就必须加锁。
共享数据保护:锁的使用
保护共享数据,最直接的方式就是加锁。C++11 提供了 std::mutex,配合 std::lock_guard 或 std::unique_lock 使用。
我个人习惯用 std::lock_guard,因为它简单、安全,出了作用域自动解锁,不会忘。
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
std::vector<int> data;
std::mutex mtx;
void add_data(int value) {
std::lock_guard<std::mutex> lock(mtx);
data.push_back(value);
}
int main() {
std::thread t1(add_data, 1);
std::thread t2(add_data, 2);
t1.join();
t2.join();
return 0;
}
你看,lock_guard 在构造时自动加锁,析构时自动解锁。就算 push_back 抛出异常,锁也会被释放——这叫 RAII,C++ 里最优雅的设计之一。
小技巧:如果你需要手动控制锁的粒度(比如提前解锁),用 std::unique_lock。它支持 lock() 和 unlock() 的显式调用。
死锁:你踩过这个坑吗?
我曾经在一个项目里写过这样的代码:线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1。结果两个线程互相等,程序卡死了。
这就是死锁。避免死锁的通用方法是:所有线程按相同的顺序加锁。
C++11 还提供了 std::lock 函数,可以一次性锁住多个 mutex,避免死锁:
std::mutex mtx1, mtx2;
void safe_func() {
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// 安全操作共享数据
}
std::adopt_lock 告诉 lock_guard:锁已经拿到了,你只管管理生命周期就行。
原子操作:比锁更轻量的选择
有些场景其实不需要锁。比如一个简单的计数器,多个线程只做自增操作。用锁的话,性能开销有点大。
这时候就该 std::atomic 出场了。它利用 CPU 的原子指令,保证操作的不可分割性。
#include <atomic>
#include <thread>
std::atomic<int> counter(0);
void increment() {
for (int i = 0; i < 10000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
// counter 一定是 20000
return 0;
}
注意 std::memory_order_relaxed,它告诉编译器:不需要做额外的内存屏障。对于简单的计数器,这个就够了。如果你需要更强的顺序保证,可以用 memory_order_seq_cst(默认值)。
避坑指南:我曾经把 std::atomic<int> 和普通 int 混用,以为原子操作能保护整个表达式。结果 counter = counter + 1 这种写法,读取和写入是分开的,中间可能被其他线程打断。一定要用 fetch_add 或 operator++。
什么时候用锁,什么时候用原子操作?
我个人的经验是:
- 简单整数/布尔操作:用
std::atomic,性能好,代码也简洁。 - 复杂数据结构(vector、map、list):必须用 mutex 加锁,原子操作搞不定。
- 读多写少:可以考虑
std::shared_mutex(C++17),允许多个线程同时读,写时独占。
STL 容器与线程安全:一张表说清楚
| 容器 | 多线程读 | 一写多读 | 多线程写 |
|---|---|---|---|
| std::vector | 安全 | 需加锁 | 需加锁 |
| std::map | 安全 | 需加锁 | 需加锁 |
| std::list | 安全 | 需加锁 | 需加锁 |
| std::atomic<T> | 安全 | 安全 | 安全 |
说白了,STL 容器本身不提供任何线程安全保证。你得自己负责。
知识体系图
下面这张图帮你理清本章的核心脉络:
避坑总结
最后,分享几个我踩过的坑:
- 不要用
volatile做线程同步——它只告诉编译器不要优化,不保证原子性,也不保证内存顺序。 - 锁的粒度要适中。锁太大,性能差;锁太小,容易出 bug。我一般先粗粒度,性能瓶颈时再细化。
- 优先用
std::atomic,实在搞不定再上锁。原子操作不仅快,而且不容易死锁。
多线程编程,说白了就是「共享数据 + 同步机制」的组合。选对工具,想清楚场景,代码就能跑得又快又稳。