12、C++11 并发编程:std::mutex与std::lock_guard,保护共享数据

多线程编程,说白了就是让程序同时干好几件事。听起来很爽对吧?但有个大坑——共享数据。两个线程同时读写同一个变量,结果往往不是你想要的。我早年做服务器开发时,就因为这个bug排查了整整三天,最后发现是计数器没加锁。嗯,从那以后我对互斥锁就格外上心了。

12.1 为什么需要互斥锁?

先看一个经典问题。两个线程同时对一个全局变量做 counter++。你猜结果是多少?

#include <iostream>
#include <thread>

int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // 这行不是原子操作!
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;
    return 0;
}

运行几次你会发现,结果每次都不一样,而且几乎永远不是200000。为什么会这样?因为 ++counter 在CPU层面其实是三步:读、改、写。两个线程可能同时读到同一个旧值,各自加1,再写回去——结果只加了1次。

核心问题:多个线程同时访问同一块内存,至少有一个线程在写,就会产生数据竞争(data race)。C++标准说这是未定义行为,程序可能崩溃、死循环、或者给你一个莫名其妙的结果。

12.2 std::mutex:最基本的互斥工具

std::mutex 是C++11提供的最基础的互斥锁。它的用法很简单:

  • lock():获取锁。如果锁已被其他线程持有,当前线程阻塞等待。
  • unlock():释放锁。让其他等待的线程有机会获取。

std::mutex 修复上面的问题:

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;  // 全局互斥锁

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++counter;
        mtx.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;
    return 0;
}

现在结果稳定在200000。但这样写有个隐患——你忘了 unlock() 怎么办?或者中间抛出异常了怎么办?锁一旦没释放,其他线程就永远卡死了。

警告:直接使用 lock()unlock() 是危险的。异常安全、提前返回、忘记解锁——任何一个场景都能让你头疼。我在项目中见过太多因为手动管理锁导致的死锁问题。

12.3 std::lock_guard:RAII风格的锁管理

C++的RAII(资源获取即初始化)思想在这里大放异彩。std::lock_guard 是一个包装类,构造时自动加锁,析构时自动解锁。你根本不需要手动调用 unlock()

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> guard(mtx);  // 构造时加锁
        ++counter;
        // 离开作用域时自动解锁
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;
    return 0;
}

你看,代码简洁多了。而且不管函数是正常返回、提前return、还是抛出异常,lock_guard 的析构函数都会被执行,锁一定会被释放。这就是RAII的魅力。

个人建议:只要涉及互斥锁,优先使用 std::lock_guard。我自己的代码里几乎从不直接调用 lock()/unlock()。除非你需要更精细的控制(比如手动提前解锁),否则 lock_guard 就是最佳选择。

12.4 知识体系图

下面这张图展示了本章的核心逻辑:

C++11 并发编程:互斥锁保护共享数据 问题:多线程数据竞争 解决方案:互斥锁 (std::mutex) 手动管理:lock() / unlock() RAII管理:std::lock_guard 风险:忘记解锁 / 异常不安全 优势:自动解锁 / 异常安全

12.5 使用场景与最佳实践

不是所有地方都需要加锁。你想想看,如果每个线程只读不写,那根本不需要锁。只有「至少一个线程在写」的时候才需要。

场景 是否需要锁 推荐做法
多个线程只读 不需要 直接访问,无需保护
一个线程写,其他线程读 需要 std::lock_guard + std::mutex
多个线程写 需要 std::lock_guard + std::mutex
读多写少 需要 std::shared_mutex (C++17)

核心原则:锁的粒度要尽量小。只保护真正需要保护的代码段,不要在锁里做耗时操作(比如文件读写、网络请求)。否则其他线程会等得很痛苦。

12.6 避坑指南

我总结几个常见的坑,都是我在项目中踩过的:

  • 死锁:两个线程各自持有一把锁,又去请求对方的锁。解决办法是保证加锁顺序一致,或者用 std::lock 一次性锁住多个互斥量。
  • 锁的粒度太大:把整个函数都锁住,导致并发性能急剧下降。应该只锁住临界区。
  • 忘记解锁:lock_guard 可以彻底避免这个问题。
  • 锁了非共享数据:只保护那些真正被多个线程访问的变量,不要无脑加锁。

我曾经在一个项目中,把锁加在了循环外面,结果一个线程占着锁跑了很久,其他线程全部饿死。后来改成在循环内部加锁,虽然每次加解锁有开销,但整体吞吐量反而上去了。记住:锁的粒度要细,但也不能太细(比如每次加一个int都加锁,那性能就崩了)。

12.7 小结

多线程编程的核心就是管理好共享数据。std::mutex 提供了基础的互斥能力,而 std::lock_guard 用RAII思想让锁的管理变得安全又简单。我个人习惯是:只要看到需要保护共享数据,第一反应就是 std::lock_guard。它几乎能解决90%的并发保护问题。

记住一句话:能不用锁就不用锁,用锁就用 lock_guard。这样你的代码既安全又清晰。

专注资料整理