并发编程基础:线程与进程、互斥锁、条件变量、读写锁、死锁的预防
并发编程,说白了就是让程序同时干多件事。很多初学者觉得这玩意儿离自己很远,其实不然。你写个网络服务器、做个图像处理、甚至写个简单的日志系统,都绕不开并发。我当年刚入行时,第一次接手一个多线程下载模块,结果各种崩溃、数据错乱,调试了整整三天。嗯,从那以后我就老老实实把并发基础啃透了。
进程 vs 线程:到底选哪个?
先搞清楚这两个概念。进程是资源分配的最小单位,线程是CPU调度的最小单位。一个进程里可以跑多个线程,它们共享进程的地址空间。
我个人的习惯是:需要强隔离、高安全性时用进程;需要高性能、频繁数据交换时用线程。举个例子,Chrome浏览器每个标签页是一个进程,这样某个页面崩溃不会影响其他页面。而一个游戏服务器里,处理玩家连接的往往是线程,因为要共享玩家数据。
| 特性 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 创建开销 | 大 | 小 |
| 同步难度 | 低(IPC机制) | 高(需锁机制) |
| 崩溃影响 | 仅自身 | 可能拖垮整个进程 |
互斥锁:最基础的同步工具
多线程访问共享数据时,必须用互斥锁保护。不然就会出现「数据竞争」——两个线程同时写一个变量,结果谁也不知道最终值是什么。
C++11提供了std::mutex,配合std::lock_guard使用最安全。我见过太多人手动lock/unlock,结果某个分支忘记unlock,直接死锁。
#include <mutex>
#include <thread>
std::mutex mtx;
int shared_counter = 0;
void safe_increment() {
std::lock_guard<std::mutex> lock(mtx); // 自动加锁解锁
++shared_counter;
// 离开作用域自动解锁
}
条件变量:让线程学会等待
互斥锁解决了「互斥」问题,但没解决「同步」问题。比如生产者-消费者模型:消费者要等生产者生产出数据才能消费。轮询?太浪费CPU了。这时候就需要条件变量。
std::condition_variable配合std::unique_lock使用。核心是wait()和notify_one()/notify_all()。
#include <condition_variable>
#include <queue>
std::queue<int> data_queue;
std::mutex q_mtx;
std::condition_variable cv;
void producer() {
for (int i = 0; i < 100; ++i) {
std::lock_guard<std::mutex> lock(q_mtx);
data_queue.push(i);
cv.notify_one(); // 通知消费者
}
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(q_mtx);
cv.wait(lock, []{ return !data_queue.empty(); });
int val = data_queue.front();
data_queue.pop();
lock.unlock();
// 处理val...
}
}
这里有个坑:wait()的第二个参数是谓词,用来防止「虚假唤醒」。为什么会有虚假唤醒?操作系统底层实现决定的,有些平台在信号量机制上会有这种问题。所以永远要在wait后面加条件判断。
读写锁:读多写少的利器
互斥锁太粗暴了——读操作之间其实不需要互斥。如果共享数据读多写少,用读写锁能大幅提升性能。
C++17引入了std::shared_mutex,配合std::shared_lock(读锁)和std::unique_lock(写锁)。
#include <shared_mutex>
class ThreadSafeCache {
std::map<std::string, int> cache_;
mutable std::shared_mutex rw_mutex_;
public:
int get(const std::string& key) const {
std::shared_lock lock(rw_mutex_); // 读锁,可共享
auto it = cache_.find(key);
return it != cache_.end() ? it->second : -1;
}
void set(const std::string& key, int val) {
std::unique_lock lock(rw_mutex_); // 写锁,独占
cache_[key] = val;
}
};
死锁的预防:别把自己锁死
死锁就是两个线程互相等待对方释放资源,结果谁也动不了。经典的「哲学家就餐问题」就是典型例子。
死锁产生的四个必要条件:
- 互斥:资源一次只能被一个线程占用
- 持有并等待:线程持有资源的同时等待其他资源
- 不可剥夺:资源只能由持有者主动释放
- 循环等待:存在线程-资源的环形链
预防死锁,说白了就是破坏上面任意一个条件。我常用的方法:
- 固定锁顺序:所有线程按相同顺序加锁。比如先锁A再锁B,绝不反过来。
- 使用std::lock():一次性锁住多个互斥量,避免分步加锁。
- 超时机制:用
try_lock_for()或try_lock_until(),超时后释放已持有的锁。
// 固定锁顺序示例
std::mutex mtx1, mtx2;
void thread_a() {
std::lock(mtx1, mtx2); // 同时锁两个
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// 安全操作...
}
void thread_b() {
// 同样顺序,不会死锁
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::recursive_mutex,要么重构代码避免这种嵌套。
并发编程的核心知识体系
下面这张图梳理了本章的核心脉络,方便你建立整体认知:
并发编程没有银弹。不同的场景要选不同的工具。我个人建议:先从互斥锁+条件变量入手,这两个组合能解决90%的问题。等遇到性能瓶颈了,再考虑读写锁、无锁数据结构这些进阶方案。
gdb调试多线程程序时,info threads查看所有线程,thread apply all bt打印所有线程的调用栈。死锁时,看看每个线程在等哪把锁,基本就能定位问题。
嗯,并发编程的内容就讲到这里。记住:锁不是越多越好,而是越少越好。能不用锁就别用锁,能用原子操作就别用互斥锁,能用读写锁就别用互斥锁。这个道理,我是在被线上事故教育了好几次之后才真正理解的。
公众号:蓝海资料掘金营,微信deep3321