第24章 多线程安全接口:锁的粒度控制与无锁编程入门

多线程安全,说白了就是「多个线程同时访问同一份数据,不会出乱子」。

我刚开始做嵌入式多线程时,觉得加锁就完事了。后来被坑过几次才明白——锁的粒度才是真正的技术活。锁太粗,性能崩;锁太细,死锁找上门。

这一章,我们聊聊怎么设计线程安全的接口,怎么控制锁的粒度,以及什么时候可以试试无锁编程。

24.1 线程安全接口的基本要求

一个接口要线程安全,至少得做到三点:

  • 原子性:操作要么全做,要么全不做
  • 可见性:一个线程的修改,其他线程能立刻看到
  • 有序性:编译器/CPU别乱优化指令顺序

嗯,听起来像教科书。我换个说法:
你写一个函数,两个线程同时调,结果不能跟单线程调两次不一样。这就是线程安全。

核心原则:接口的设计者负责保证线程安全,调用者不需要关心锁的事。

24.2 锁的粒度控制——粗 vs 细

锁的粒度,指的是锁保护的代码范围大小。

粒度 优点 缺点 适用场景
粗粒度 实现简单,不易死锁 并发度低,性能差 低频访问、数据结构简单
细粒度 并发度高,性能好 实现复杂,容易死锁 高频访问、复杂数据结构

我在项目中遇到过这样一个案例:一个共享队列,最开始用一把大锁保护整个队列。压测时发现,生产者线程和消费者线程互相阻塞,吞吐量上不去。

后来改成读写锁,读操作不互斥,写操作才互斥。吞吐量直接翻倍。

我的建议:先粗后细。先拿一把大锁跑通功能,性能瓶颈出现时再细化。别一开始就搞细粒度,容易把自己绕进去。

24.3 锁的常见模式

24.3.1 互斥锁(Mutex)

最基础的锁。一次只有一个线程能拿到。

// 一个线程安全的计数器接口
typedef struct {
    int count;
    pthread_mutex_t lock;
} SafeCounter;

void SafeCounter_Init(SafeCounter* c) {
    c->count = 0;
    pthread_mutex_init(&c->lock, NULL);
}

void SafeCounter_Increment(SafeCounter* c) {
    pthread_mutex_lock(&c->lock);
    c->count++;
    pthread_mutex_unlock(&c->lock);
}

int SafeCounter_Get(SafeCounter* c) {
    pthread_mutex_lock(&c->lock);
    int val = c->count;
    pthread_mutex_unlock(&c->lock);
    return val;
}

注意:Get 也要加锁。为什么?因为 int 在32位系统上读写是原子的,但在64位系统上不一定。别赌编译器行为。

24.3.2 读写锁(RWLock)

适合读多写少的场景。

// 读操作:可以多个线程同时读
pthread_rwlock_rdlock(&rwlock);
// 读数据...
pthread_rwlock_unlock(&rwlock);

// 写操作:独占
pthread_rwlock_wrlock(&rwlock);
// 写数据...
pthread_rwlock_unlock(&rwlock);

注意:读写锁不是银弹。如果写操作频繁,读线程会被频繁阻塞,性能反而比互斥锁差。

24.4 无锁编程入门

无锁编程,不是真的不用锁,而是用 CPU 提供的原子指令(CAS、原子加减)来保证线程安全。

为什么需要无锁?

  • 锁会导致线程阻塞、上下文切换
  • 实时系统中,锁可能引发优先级反转
  • 中断上下文里不能加锁

24.4.1 原子操作

C11 标准提供了 stdatomic.h,嵌入式编译器一般也支持。

#include <stdatomic.h>

atomic_int counter = 0;

// 原子自增
atomic_fetch_add(&counter, 1);

// 原子交换
int old = atomic_exchange(&counter, 10);

// CAS:比较并交换
int expected = 10;
int desired = 20;
bool success = atomic_compare_exchange_strong(&counter, &expected, desired);

我曾经在一个网络协议栈里,用原子操作替换了频繁加锁的统计计数器。性能提升很明显,而且代码更简洁。

24.4.2 无锁队列(简单版)

单生产者单消费者场景下,可以做到完全无锁。

#define QUEUE_SIZE 16

typedef struct {
    int buffer[QUEUE_SIZE];
    atomic_int head;  // 生产者写入位置
    atomic_int tail;  // 消费者读取位置
} LockFreeQueue;

bool Queue_Push(LockFreeQueue* q, int val) {
    int h = atomic_load(&q->head);
    int t = atomic_load(&q->tail);
    if (h - t >= QUEUE_SIZE) {
        return false;  // 队列满
    }
    q->buffer[h % QUEUE_SIZE] = val;
    atomic_store(&q->head, h + 1);
    return true;
}

bool Queue_Pop(LockFreeQueue* q, int* val) {
    int t = atomic_load(&q->tail);
    int h = atomic_load(&q->head);
    if (t >= h) {
        return false;  // 队列空
    }
    *val = q->buffer[t % QUEUE_SIZE];
    atomic_store(&q->tail, t + 1);
    return true;
}

关键点:这个队列只适用于单生产者单消费者。多生产者多消费者需要更复杂的无锁算法,比如 Michael-Scott 队列。

24.5 锁粒度控制的核心逻辑

下面这张图,是我自己总结的锁粒度决策流程。

锁粒度决策流程 开始设计接口 访问频率高? 粗粒度互斥锁 实现简单,够用 读多写少? 读写锁 读并发 可用原子操作? 无锁编程 原子操作 细粒度锁 分段/分桶

24.6 避坑指南

我踩过的坑,列出来给你参考:

  • 锁的嵌套导致死锁:两个线程各自持有一把锁,又去等对方的锁。我曾经在调试一个音频驱动时,花了整整两天才找到这个死锁。解决方案:固定锁的获取顺序,或者用 pthread_mutex_trylock
  • 忘记解锁:函数中间有 return 分支,忘了 unlock。我的习惯是:在函数开头 lock,然后在所有出口处 unlock,或者用 goto 统一出口。
  • 中断里加锁:中断上下文里调用 pthread_mutex_lock 会导致内核 panic。解决方案:用 spinlock 或原子操作。
  • 无锁编程的 ABA 问题:CAS 操作中,值从 A 变成 B 又变回 A,CAS 会认为没变过。我遇到过这个问题,后来加了版本号才解决。

重要提醒:无锁编程非常难调试。如果你不是特别熟悉内存模型和 CPU 架构,建议先用锁。性能不够时再考虑无锁。

24.7 总结

线程安全接口的设计,说白了就是平衡——在正确性和性能之间找平衡点。

我的经验是:

  • 80% 的场景,一把粗粒度互斥锁就够了
  • 15% 的场景,读写锁或细粒度锁能明显提升性能
  • 剩下 5%,才值得用无锁编程

别为了炫技而用无锁。你想想看,一个 bug 在线上跑了三个月才复现,那感觉……嗯,我不想再体验第二次。


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