一、并发与多线程安全:从混乱到有序

并发编程,说白了就是让多个任务同时干活。听起来很美好,对吧?但我在项目中见过太多因为并发问题导致的线上事故——数据错乱、程序崩溃、甚至死锁到整个服务卡死。嗯,今天我们就来聊聊怎么把这些坑填上。

1.1 竞态条件与数据竞争:两个最基础的敌人

先说说竞态条件。它指的是程序的行为依赖于多个线程的执行顺序。举个例子:

int counter = 0;

// 线程A
counter++;

// 线程B
counter++;

你猜最终结果是多少?可能是1,可能是2。为什么?因为 counter++ 不是原子操作。它实际上分三步:读、加、写。两个线程同时执行,就会互相覆盖。

核心区别:

  • 数据竞争:多个线程同时访问同一内存位置,且至少有一个是写操作,没有同步机制
  • 竞态条件:程序结果依赖于线程执行顺序,即使没有数据竞争也可能发生

我记得有一次,一个同事写的日志系统就栽在了竞态条件上。两个线程同时写日志文件,结果日志内容互相穿插,根本没法看。这就是典型的竞态条件——没有数据竞争(因为用了文件锁),但顺序乱了。

1.2 互斥锁(mutex)的正确使用

解决数据竞争最直接的办法就是加锁。C++11 提供了 std::mutex,用法很简单:

#include <mutex>

std::mutex mtx;
int shared_data = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);
    shared_data++;
}

这里我强烈建议用 std::lock_guardstd::unique_lock,而不是手动 lock()/unlock()。为什么?因为异常安全。手动加锁,万一中间抛出异常,锁就永远解不开了。

我的习惯:

  • 短临界区用 lock_guard,简单高效
  • 需要条件变量或手动控制锁生命周期时用 unique_lock
  • 永远不要裸用 mutex::lock()

1.3 死锁预防:别把自己锁死

死锁是并发编程里最让人头疼的问题之一。两个线程互相等待对方释放锁,结果谁也动不了。我见过最离谱的一次,一个服务因为死锁导致所有请求超时,整整影响了线上业务20分钟。

死锁产生的四个必要条件(嗯,这是教科书上的,但确实有用):

条件 说明
互斥 资源一次只能被一个线程占用
持有并等待 线程持有资源的同时等待其他资源
不可剥夺 资源不能被强制剥夺
循环等待 存在线程-资源的环形链

预防死锁,我总结了三条实用原则:

  1. 固定锁顺序:所有线程按相同顺序加锁。比如先锁A再锁B,不要有的先A后B,有的先B后A。
  2. 使用 std::lock():一次性锁住多个互斥量,避免死锁。
  3. 尽量缩小临界区:锁的代码越少,死锁概率越低。
// 正确的做法:使用 std::lock 同时锁住两个互斥量
std::mutex mtx1, mtx2;

void safe_operation() {
    std::lock(mtx1, mtx2);
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    // 安全操作...
}

避坑指南:我曾经见过一个项目,为了"优化性能"把锁拆得很细,结果引入了死锁。记住:锁越少越好,不是越细越好。

1.4 原子操作(std::atomic)

有些场景其实不需要锁。比如简单的计数器、标志位,用 std::atomic 就够了。它利用CPU提供的原子指令,比锁轻量得多。

#include <atomic>

std::atomic<int> counter{0};

void increment() {
    counter.fetch_add(1, std::memory_order_relaxed);
}

这里有个内存序的问题。很多人一上来就用 memory_order_seq_cst(默认值),其实性能有损耗。我一般这样选:

  • memory_order_relaxed:只保证原子性,不保证顺序。适合计数器。
  • memory_order_acquire/release:保证读写顺序。适合生产者-消费者模式。
  • memory_order_seq_cst:最强的顺序保证。除非必要,否则别用。

小技巧:如果你不确定用哪个内存序,先用 seq_cst 保证正确性,性能瓶颈时再优化。别一开始就玩花活。

1.5 线程安全的设计模式

光靠锁和原子操作还不够,设计模式才是根本。我常用的几个:

1.5.1 不可变对象

对象一旦创建就不允许修改。所有线程只能读,不需要同步。说白了,就是"没有写操作,就没有数据竞争"。

1.5.2 线程本地存储

每个线程有自己的数据副本,互不干扰。C++11 的 thread_local 关键字就是干这个的。

thread_local int cache = 0;  // 每个线程独立一份

1.5.3 读写锁

读多写少的场景,用 std::shared_mutex。多个线程可以同时读,但写操作独占。

#include <shared_mutex>

std::shared_mutex rw_mutex;
int data = 0;

void read_data() {
    std::shared_lock lock(rw_mutex);
    // 读取 data,多个线程可以同时读
}

void write_data(int val) {
    std::unique_lock lock(rw_mutex);
    data = val;
}

核心原则:线程安全不是靠"加锁"堆出来的,而是靠设计模式规避掉的。能不用锁就不用锁,能少用锁就少用锁。

知识体系总览

下面这张图总结了本章的核心内容,你可以把它当作并发安全的"作战地图":

并发与多线程安全 · 知识体系 并发安全核心 竞态条件 互斥锁 (mutex) 原子操作 (atomic) 线程安全设计模式 数据竞争 · 顺序依赖 lock_guard · unique_lock 死锁预防 · std::lock fetch_add · compare_exchange 内存序 (memory_order) 不可变对象 thread_local · 读写锁 核心原则:能不用锁就不用锁 能少用锁就少用锁 · 设计模式优先

这张图把并发安全的四个核心领域串在了一起。你想想看,从竞态条件到设计模式,其实是一条从"发现问题"到"解决问题"再到"规避问题"的进化路径。我个人习惯把这张图贴在工位上,写代码前先看一眼,提醒自己别走弯路。

最后提醒一句:并发安全没有银弹。锁、原子操作、设计模式,都是工具。真正重要的是你对问题的理解——先想清楚数据是怎么流动的,再决定用什么方案。我曾经见过有人为了"炫技"用了一堆原子操作,结果代码复杂到没人敢改。嗯,别学他。

公众号:蓝海资料掘金营,微信deep3321