一、并发与多线程安全:从混乱到有序
并发编程,说白了就是让多个任务同时干活。听起来很美好,对吧?但我在项目中见过太多因为并发问题导致的线上事故——数据错乱、程序崩溃、甚至死锁到整个服务卡死。嗯,今天我们就来聊聊怎么把这些坑填上。
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_guard 或 std::unique_lock,而不是手动 lock()/unlock()。为什么?因为异常安全。手动加锁,万一中间抛出异常,锁就永远解不开了。
我的习惯:
- 短临界区用
lock_guard,简单高效 - 需要条件变量或手动控制锁生命周期时用
unique_lock - 永远不要裸用
mutex::lock()
1.3 死锁预防:别把自己锁死
死锁是并发编程里最让人头疼的问题之一。两个线程互相等待对方释放锁,结果谁也动不了。我见过最离谱的一次,一个服务因为死锁导致所有请求超时,整整影响了线上业务20分钟。
死锁产生的四个必要条件(嗯,这是教科书上的,但确实有用):
| 条件 | 说明 |
|---|---|
| 互斥 | 资源一次只能被一个线程占用 |
| 持有并等待 | 线程持有资源的同时等待其他资源 |
| 不可剥夺 | 资源不能被强制剥夺 |
| 循环等待 | 存在线程-资源的环形链 |
预防死锁,我总结了三条实用原则:
- 固定锁顺序:所有线程按相同顺序加锁。比如先锁A再锁B,不要有的先A后B,有的先B后A。
- 使用 std::lock():一次性锁住多个互斥量,避免死锁。
- 尽量缩小临界区:锁的代码越少,死锁概率越低。
// 正确的做法:使用 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;
}
核心原则:线程安全不是靠"加锁"堆出来的,而是靠设计模式规避掉的。能不用锁就不用锁,能少用锁就少用锁。
知识体系总览
下面这张图总结了本章的核心内容,你可以把它当作并发安全的"作战地图":
这张图把并发安全的四个核心领域串在了一起。你想想看,从竞态条件到设计模式,其实是一条从"发现问题"到"解决问题"再到"规避问题"的进化路径。我个人习惯把这张图贴在工位上,写代码前先看一眼,提醒自己别走弯路。
最后提醒一句:并发安全没有银弹。锁、原子操作、设计模式,都是工具。真正重要的是你对问题的理解——先想清楚数据是怎么流动的,再决定用什么方案。我曾经见过有人为了"炫技"用了一堆原子操作,结果代码复杂到没人敢改。嗯,别学他。