11、锁优化策略:读写锁、自旋锁、无锁编程(CAS原子操作)、减少锁粒度

说到多线程编程,锁是个绕不开的话题。很多新手一上来就怼个大锁,把所有操作都包进去——嗯,这样确实简单,但性能嘛……惨不忍睹。我这些年调过的性能问题,十有八九都跟锁有关。今天咱们就聊聊锁优化的那些事儿。

11.1 读写锁:读多写少的场景利器

先问个问题:你的代码里,读操作多还是写操作多?

如果是读多写少,那普通互斥锁就太浪费了。你想啊,多个线程同时读一个变量,根本不会出问题,但互斥锁却让它们排队等着——这不是白等吗?

读写锁就是干这个的。它允许多个读者同时访问,但写者必须独占。

#include <pthread.h>

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

void read_data() {
    pthread_rwlock_rdlock(&rwlock);
    // 读取共享数据
    pthread_rwlock_unlock(&rwlock);
}

void write_data() {
    pthread_rwlock_wrlock(&rwlock);
    // 修改共享数据
    pthread_rwlock_unlock(&rwlock);
}

我的经验:读写锁在配置管理、路由表查找这类场景下效果特别好。我曾经在一个网关项目里,把普通互斥锁换成读写锁,吞吐量直接翻了一倍。但要注意——如果写操作很频繁,读写锁反而可能比互斥锁更慢,因为写者会被读者饿死。

11.2 自旋锁:短小精悍的等待

自旋锁和普通锁最大的区别是:它不睡眠,而是原地打转

为什么会有人用这种看起来浪费CPU的方式?因为线程睡眠再唤醒的开销很大,上下文切换可能要几微秒。如果你锁的持有时间只有几十纳秒,那自旋反而更划算。

#include <linux/spinlock.h>

spinlock_t my_lock = SPIN_LOCK_UNLOCKED;

void critical_section() {
    spin_lock(&my_lock);
    // 极短的操作,比如修改一个计数器
    spin_unlock(&my_lock);
}

避坑指南:我曾经在一个项目中,有人在自旋锁保护的临界区里调用了sleep()——结果整个系统卡死了。自旋锁的临界区里绝对不能睡眠、不能调用可能阻塞的函数。记住:自旋锁是给微秒级操作用的,不是给你做复杂计算的。

11.3 无锁编程:CAS原子操作

说到无锁编程,很多人觉得高深莫测。其实说白了,就是用CPU提供的原子指令来替代锁。

最常用的就是CAS(Compare And Swap)。它做一件事:比较内存值和期望值,如果相等就更新为新值,整个过程不可中断

#include <stdatomic.h>

atomic_int counter = 0;

void safe_increment() {
    int expected;
    int desired;
    do {
        expected = atomic_load(&counter);
        desired = expected + 1;
    } while (!atomic_compare_exchange_weak(&counter, &expected, desired));
}

这段代码看起来有点绕,但核心逻辑很简单:先读当前值,计算新值,然后尝试用CAS更新。如果别人改了,就重试。

关键点:无锁编程不是真的没有锁,而是把锁的粒度降到了CPU指令级别。它的优势在于:不会引起线程调度、不会死锁、在高并发下性能更好。

但无锁编程也有坑。我记得有一次调试一个无锁队列,ABA问题折腾了我整整两天。简单说就是:线程A读到值是X,然后被挂起;线程B把X改成Y又改回X;线程A醒来后CAS成功,但数据已经变了。解决方案是加版本号,或者用双字CAS。

11.4 减少锁粒度:把大锁拆小

这个思路最直接:锁的范围越小,竞争就越少

具体做法有几种:

  • 锁分解:把一个大锁拆成多个小锁,每个保护不同的数据
  • 锁分段:比如哈希表,每个桶一把锁
  • 读写分离:读操作不加锁,写操作加锁
// 粗粒度锁
pthread_mutex_t big_lock;
void process_all() {
    pthread_mutex_lock(&big_lock);
    // 处理1000个元素
    for (int i = 0; i < 1000; i++) {
        process_item(i);
    }
    pthread_mutex_unlock(&big_lock);
}

// 细粒度锁
pthread_mutex_t locks[10];
void process_all_fine() {
    for (int i = 0; i < 1000; i++) {
        int bucket = i % 10;
        pthread_mutex_lock(&locks[bucket]);
        process_item(i);
        pthread_mutex_unlock(&locks[bucket]);
    }
}

我的建议:别一上来就搞细粒度锁。先分析热点在哪里,然后只优化瓶颈。我见过有人把锁拆得太细,结果锁本身的管理开销比竞争开销还大——得不偿失。

11.5 四种策略对比

策略 适用场景 优点 缺点
读写锁 读多写少 读并发高 写者可能饿死
自旋锁 临界区极短 无上下文切换 浪费CPU
CAS无锁 简单操作 无阻塞 实现复杂
减少粒度 大数据结构 降低竞争 管理开销增加

11.6 知识体系图

锁优化策略 读写锁 自旋锁 CAS无锁编程 减少锁粒度 读多写少场景 读者可并发 临界区极短 无上下文切换 原子指令实现 无阻塞无死锁 锁分解/分段 降低竞争概率 核心原则:根据场景选择策略,避免过度优化 没有银弹,只有最适合的方案

11.7 实战建议

说了这么多,到底该怎么选?我个人的经验是:

  1. 先测量,再优化。别猜,用perf或者gprof看看锁竞争到底有多严重
  2. 从最简单的方案开始。普通互斥锁不行了,再考虑读写锁或自旋锁
  3. 无锁编程是最后的手段。除非你真的很熟悉内存模型和CPU架构,否则别轻易碰
  4. 测试要覆盖高并发场景。有些bug只在压力下才会出现

血的教训:我曾经在一个金融交易系统里,为了追求极致性能,用了无锁队列。结果上线后偶尔出现数据不一致,查了整整一周才发现是内存重排序导致的。从那以后,我学乖了——能用锁解决的问题,别搞花活。

锁优化没有银弹。读写锁、自旋锁、CAS、减少粒度——每种方案都有自己的适用场景。关键是要理解你的业务特点,然后对症下药。嗯,今天就聊到这儿,希望这些经验对你有帮助。

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