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 知识体系总览

下面这张图,是我梳理的锁优化知识脉络。你可以把它当作一个决策树来看。

锁优化决策 临界区长? 临界区短? 读多写少? 竞争激烈? 读写锁 互斥锁 自旋锁 CAS无锁 决策依据:临界区耗时、读写比例、竞争程度 没有银弹!实际项目中需要结合性能测试做选择

11.6 实战建议

说了这么多,到底该怎么选?我总结了几条经验:

  1. 先测量,再优化。 别凭感觉选锁。用perf或vtune看看锁的争用情况。
  2. 缩小临界区。 不管用什么锁,临界区越小越好。把不必要的操作移出去。
  3. 考虑锁的粒度。 一把大锁拆成多把小锁,能显著提升并发度。
  4. 无锁编程是最后的手段。 它太难调试了。我见过一个无锁队列的bug,查了整整三天。

小技巧:如果你不确定用哪种锁,先用互斥锁。等性能瓶颈出现了,再用perf定位到具体的锁,然后针对性优化。过早优化是万恶之源。

好了,关于锁优化就聊这么多。记住一点:锁不是越多越好,也不是越少越好。关键是要找到适合你场景的那个平衡点。


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