11. 锁优化:互斥锁 vs 读写锁、自旋锁、无锁编程简介(CAS)
说到多线程编程,锁是个绕不开的话题。我见过不少项目,一开始图省事,一把大锁锁住所有共享数据。结果呢?性能随着核数增加反而下降。今天咱们就来聊聊,怎么把锁用对、用巧。
11.1 互斥锁:最基础的同步工具
互斥锁(std::mutex)是最常用的同步原语。它的原理很简单:谁拿到锁,谁就进临界区。没拿到的线程,乖乖去睡觉。
核心特点:
- 线程阻塞时会进入睡眠状态,不消耗CPU
- 上下文切换开销较大(约几微秒)
- 适合临界区操作较重的场景
我习惯用 std::lock_guard 或 std::unique_lock 来管理锁的生命周期。手动 lock/unlock 容易出问题,万一中间抛异常,锁就永远解不开了。
// 推荐写法
std::mutex mtx;
{
std::lock_guard<std::mutex> lock(mtx);
// 临界区操作
shared_data.push_back(value);
} // 自动解锁
注意:互斥锁不是万能的。如果临界区代码执行时间很短(比如几十纳秒),让线程去睡觉再唤醒,反而得不偿失。
11.2 读写锁:读多写少的利器
读写锁(std::shared_mutex)是我在项目中经常用的。它区分了两种锁:共享锁(读锁)和独占锁(写锁)。
说白了就是:多个线程可以同时读,但写的时候谁都不能碰。我在做配置中心的时候,配置数据几乎不变,但每秒有上千次读取。用互斥锁的话,读线程之间也要互相等待,太浪费了。
std::shared_mutex rw_mutex;
// 读操作
void read_config() {
std::shared_lock<std::shared_mutex> lock(rw_mutex);
// 多个线程可以同时进入这里
return config_value;
}
// 写操作
void update_config(int new_val) {
std::unique_lock<std::shared_mutex> lock(rw_mutex);
// 写的时候,所有读线程都得等着
config_value = new_val;
}
| 锁类型 | 读读 | 读写 | 写写 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 互斥 | 互斥 | 互斥 | 读写比例接近1:1 |
| 读写锁 | 不互斥 | 互斥 | 互斥 | 读远多于写 |
我的经验:读写锁也不是免费的。它内部维护了读计数器,每次加锁解锁都有额外开销。如果临界区操作极短(比如就读取一个int),读写锁可能比互斥锁还慢。
11.3 自旋锁:短临界区的选择
自旋锁和互斥锁最大的区别是:拿不到锁时,它不睡觉,而是原地打转(自旋)。
为什么会有人用自旋锁?你想想看,如果临界区只有几条指令,线程切换的开销可能比自旋几百次还大。这时候,自旋锁反而更高效。
// 简单的自旋锁实现
class SpinLock {
std::atomic<bool> flag_{false};
public:
void lock() {
while (flag_.exchange(true, std::memory_order_acquire)) {
// 自旋等待
// 可以加个 _mm_pause() 降低功耗
}
}
void unlock() {
flag_.store(false, std::memory_order_release);
}
};
避坑指南:我曾经在一个项目中用了自旋锁,结果线上CPU飙到100%。排查后发现,临界区里有个文件IO操作,耗时几十毫秒。自旋锁在这种场景下就是灾难——所有等待线程都在疯狂空转。
自旋锁适合的场景:
- 临界区执行时间极短(纳秒级)
- 锁的竞争不激烈
- 线程不能被阻塞(比如中断上下文)
11.4 无锁编程与CAS
无锁编程,说白了就是不用锁,靠原子操作来保证数据一致性。核心武器就是CAS(Compare-And-Swap)。
CAS的原理很简单:比较内存中的值是否等于预期值,如果是,就更新为新值。整个过程是原子的。
// C++11 提供的 CAS
std::atomic<int> counter{0};
int expected = counter.load();
int new_val = expected + 1;
// 如果 counter 还是 expected,就更新为 new_val
// 否则说明被其他线程改了,需要重试
while (!counter.compare_exchange_weak(expected, new_val)) {
new_val = expected + 1;
}
嗯,这里要注意 compare_exchange_weak 和 compare_exchange_strong 的区别。weak版本在某些平台上可能伪失败(即使值相等也返回false),但性能更好。一般在循环里用weak就够了。
无锁编程的典型应用:
- 引用计数(shared_ptr的底层实现)
- 无锁队列(比如生产者-消费者模型)
- 计数器、标志位等简单状态
11.5 知识体系总览
下面这张图,是我梳理的锁优化知识脉络。你可以把它当作一个决策树来看。
11.6 实战建议
说了这么多,到底该怎么选?我总结了几条经验:
- 先测量,再优化。 别凭感觉选锁。用perf或vtune看看锁的争用情况。
- 缩小临界区。 不管用什么锁,临界区越小越好。把不必要的操作移出去。
- 考虑锁的粒度。 一把大锁拆成多把小锁,能显著提升并发度。
- 无锁编程是最后的手段。 它太难调试了。我见过一个无锁队列的bug,查了整整三天。
小技巧:如果你不确定用哪种锁,先用互斥锁。等性能瓶颈出现了,再用perf定位到具体的锁,然后针对性优化。过早优化是万恶之源。
好了,关于锁优化就聊这么多。记住一点:锁不是越多越好,也不是越少越好。关键是要找到适合你场景的那个平衡点。
公众号:蓝海资料掘金营,微信deep3321