第三章:多核同步原语——原子操作、自旋锁、互斥锁、读写锁、屏障的实现与选择

多核编程里,同步原语是绕不开的话题。说白了,就是多个核同时干活时,怎么保证数据不乱、逻辑不错。我刚开始接触多核开发时,总觉得这些锁啊屏障啊很烦人,后来被坑过几次才明白——选对原语,比写对代码更重要。

3.1 原子操作:最轻量的同步手段

原子操作是硬件级别的保证。它保证一个操作要么全部完成,要么完全不执行。中间不会被任何线程打断。

我在项目中遇到过一个问题:两个线程同时对一个全局计数器做 count++。你以为结果是加2,实际上可能只加了1。为什么?因为 count++ 在底层是三步:读、改、写。两个线程同时读,都读到5,然后各自加1写回,结果还是6。

解决方式就是用原子操作。C++11 里是 std::atomic,Linux 内核里是 atomic_t

// C++11 原子操作示例
#include <atomic>
std::atomic<int> counter(0);

// 线程安全的自增
counter.fetch_add(1, std::memory_order_relaxed);

// 或者直接用 ++
counter++;

核心要点:原子操作适用于简单的读-改-写场景。比如计数器、标志位。它不阻塞线程,性能开销极小。

我的习惯:能用原子操作解决的问题,绝不用锁。它比锁快一个数量级。

3.2 自旋锁:忙等待的代价

自旋锁的原理很简单:线程在获取锁之前,一直循环检查锁是否可用。它不会让线程睡眠,而是「原地打转」。

你想想看,如果锁被持有的时间很短,自旋锁就很高效。因为它避免了线程切换的开销。但如果锁持有时间很长,自旋锁就是灾难——CPU 空转,浪费电。

// Linux 内核自旋锁示例
spinlock_t my_lock;
spin_lock_init(&my_lock);

spin_lock(&my_lock);
// 临界区代码,执行时间要短
spin_unlock(&my_lock);

我曾经踩过的坑:在自旋锁保护的临界区里调用了 sleep()。结果其他线程等得死去活来,CPU 占用率飙到 100%。记住:自旋锁的临界区里不能睡眠,不能做耗时操作。

什么时候用自旋锁?我个人习惯是:临界区执行时间不超过几十微秒,而且锁竞争不激烈的时候。比如保护一个链表头指针的插入操作。

3.3 互斥锁:让线程睡个好觉

互斥锁(Mutex)和自旋锁最大的区别是:拿不到锁时,线程会主动让出 CPU,进入睡眠状态。等锁可用时,内核会唤醒它。

这听起来很美好,对吧?但代价是线程切换的开销。一次上下文切换大概要几微秒。如果临界区本身只执行几百纳秒,那用互斥锁就亏大了。

// POSIX 线程互斥锁示例
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&mutex);
// 临界区代码,可以执行较长时间
pthread_mutex_unlock(&mutex);
特性 自旋锁 互斥锁
等待方式 忙等待(CPU 空转) 睡眠等待(让出 CPU)
适用场景 临界区极短 临界区较长或可能阻塞
上下文切换 有(开销较大)
中断上下文 可用 不可用

选择原则:临界区短用自旋锁,临界区长用互斥锁。中断上下文只能用自旋锁。

3.4 读写锁:读多写少的利器

读写锁把锁分成了两种模式:读模式和写模式。多个线程可以同时持有读锁,但写锁是独占的。读锁和写锁互斥。

说白了,就是「大家一起读没问题,但有人写的时候,谁都不许碰」。

我在项目中遇到过典型的场景:一个全局配置表,99% 的时间是读操作,只有偶尔更新配置时才写。用普通互斥锁的话,读操作之间也会互相阻塞,太浪费了。读写锁正好解决这个问题。

// POSIX 读写锁示例
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

// 读操作
pthread_rwlock_rdlock(&rwlock);
// 读取配置表
pthread_rwlock_unlock(&rwlock);

// 写操作
pthread_rwlock_wrlock(&rwlock);
// 更新配置表
pthread_rwlock_unlock(&rwlock);

我的建议:读写锁不是万能的。如果写操作很频繁,读写锁的性能可能还不如普通互斥锁。因为写锁的优先级处理会带来额外开销。读多写少(比例至少 10:1)时才值得用。

3.5 屏障(Barrier):让线程步调一致

屏障的作用是让一组线程在某个点上「集合」。所有线程都到达屏障后,才能继续往下执行。有点像接力赛里的交接棒区——所有人都到了才能开跑。

嗯,这里要注意:屏障和内存屏障(Memory Barrier)是两回事。内存屏障是防止指令重排的硬件指令,而屏障(Barrier)是线程同步原语。

// POSIX 屏障示例
pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, 4); // 等待4个线程

// 每个线程中
pthread_barrier_wait(&barrier);
// 所有4个线程都到达这里后,才继续执行

我记得有一次做并行计算,需要把一个大矩阵分块处理。每个线程处理完自己的块后,必须等所有线程都完成,才能进入下一阶段计算。屏障就是为这种场景设计的。

适用场景:并行算法中的分阶段计算、多线程初始化同步、并行排序中的归并阶段。

3.6 如何选择?我的经验总结

说了这么多,到底怎么选?我整理了一个简单的决策思路:

  1. 先问自己:能不能用原子操作?能的话就别用锁。
  2. 再问:临界区有多短?几十微秒以内可以考虑自旋锁。
  3. 然后问:读操作远多于写操作吗?是的话考虑读写锁。
  4. 最后问:需要所有线程在某个点同步吗?需要的话用屏障。
  5. 默认选择:互斥锁。它最通用,不容易出错。

避坑指南:我曾经在一个项目中过度优化,把互斥锁全换成了自旋锁。结果锁竞争激烈时,CPU 全部在空转,系统吞吐量反而下降了。后来我加了一个自适应策略:如果自旋超过一定次数还没拿到锁,就主动睡眠。嗯,这其实就是 Linux 内核里 mutex 的实现思路。

最后说一句:同步原语的选择没有银弹。你得根据实际场景、锁的持有时间、竞争激烈程度来权衡。多测、多试、多观察性能数据,比纸上谈兵靠谱得多。