18、STL与多线程:线程安全概念、共享数据保护、锁的使用、原子操作

多线程编程,说白了就是让程序同时干好几件事。但问题来了——当多个线程同时访问同一个数据时,会发生什么?

我刚开始接触多线程时,觉得这玩意儿挺酷。直到有一次,我在一个高并发服务里用了一个全局的 std::vector 来缓存用户状态,结果线上频繁崩溃。查了两天才发现,两个线程同时 push_back 导致内存错乱。嗯,从那以后,我对「线程安全」这四个字再也不敢掉以轻心了。

什么是线程安全?

一个数据结构或函数,如果被多个线程同时调用,不会出现数据竞争、不会产生未定义行为,那它就是线程安全的。

但这里有个坑:STL 容器默认不是线程安全的。你想想看,std::vectorpush_back 可能会触发 reallocation,如果两个线程同时触发,内存就乱套了。

核心原则:多个线程可以同时读取同一个 STL 容器,但只要有一个线程在写,就必须加锁。

共享数据保护:锁的使用

保护共享数据,最直接的方式就是加锁。C++11 提供了 std::mutex,配合 std::lock_guardstd::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_addoperator++

什么时候用锁,什么时候用原子操作?

我个人的经验是:

  • 简单整数/布尔操作:用 std::atomic,性能好,代码也简洁。
  • 复杂数据结构(vector、map、list):必须用 mutex 加锁,原子操作搞不定。
  • 读多写少:可以考虑 std::shared_mutex(C++17),允许多个线程同时读,写时独占。

STL 容器与线程安全:一张表说清楚

容器 多线程读 一写多读 多线程写
std::vector 安全 需加锁 需加锁
std::map 安全 需加锁 需加锁
std::list 安全 需加锁 需加锁
std::atomic<T> 安全 安全 安全

说白了,STL 容器本身不提供任何线程安全保证。你得自己负责。

知识体系图

下面这张图帮你理清本章的核心脉络:

多线程数据保护 互斥锁 (mutex) std::lock_guard std::unique_lock 死锁风险 → 按序加锁 原子操作 std::atomic<T> fetch_add / compare_exchange memory_order 控制 读写锁 (shared_mutex) C++17: shared_lock / unique_lock

避坑总结

最后,分享几个我踩过的坑:

  • 不要用 volatile 做线程同步——它只告诉编译器不要优化,不保证原子性,也不保证内存顺序。
  • 锁的粒度要适中。锁太大,性能差;锁太小,容易出 bug。我一般先粗粒度,性能瓶颈时再细化。
  • 优先用 std::atomic,实在搞不定再上锁。原子操作不仅快,而且不容易死锁。

多线程编程,说白了就是「共享数据 + 同步机制」的组合。选对工具,想清楚场景,代码就能跑得又快又稳。