并发编程优化:原子操作、内存屏障、锁优化策略

并发编程,说白了就是让多个任务同时干活。但多个人抢一把锤子,肯定得乱套。我早年做嵌入式系统时,就吃过并发的亏——两个线程同时修改一个全局计数器,结果数据对不上,排查了整整两天。从那以后,我对并发编程就多了几分敬畏。

今天咱们聊聊C语言里的并发优化。核心就三块:原子操作内存屏障锁优化。这三板斧用好了,并发代码既安全又高效。

并发编程优化知识体系 原子操作 内存屏障 锁优化策略 CAS / 原子加减 / 原子位操作 __sync_ / __atomic 系列 编译器屏障 vs CPU屏障 acquire / release 语义 自旋锁 / 读写锁 / 乐观锁 锁粒度 / 锁消除 / 无锁编程 目标:线程安全 + 高性能 + 可移植

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

原子操作,就是不可分割的操作。执行过程中不会被其他线程打断。我习惯把它比作「银行柜台的一笔转账」——要么全部完成,要么什么都没发生。

C11标准引入了 stdatomic.h,咱们终于不用再依赖GCC扩展了。来看个例子:

#include <stdatomic.h>

atomic_int counter = 0;

void increment(void) {
    atomic_fetch_add(&counter, 1);
}

int main() {
    // 启动多个线程...
    printf("counter = %d\n", atomic_load(&counter));
    return 0;
}

你可能会问:直接用 counter++ 不行吗?不行。因为 counter++ 在底层是「读-改-写」三步,不是原子的。两个线程同时执行,结果就丢了。

核心原则:只要多个线程共享一个变量,并且至少有一个线程在写,就必须用原子操作或锁保护。

我在项目中遇到过一个问题:用原子变量做统计计数器,性能非常好。但注意,原子操作不是万能的——它只保证单个变量的原子性。如果你需要保护多个变量的「一致性」,那就得上锁了。

二、内存屏障:看不见的秩序守护者

内存屏障,也叫内存栅栏。它的作用是防止编译器和CPU对内存访问指令进行重排序。嗯,这里要注意:编译器为了优化,可能会把代码顺序打乱。CPU为了流水线效率,也可能乱序执行。

举个例子:

int flag = 0;
int data = 0;

// 线程A
data = 42;
flag = 1;

// 线程B
if (flag == 1) {
    // 能保证 data 一定是 42 吗?
    printf("%d\n", data);
}

答案是:不一定!CPU可能先把 flag=1 写出去,再写 data=42。线程B看到flag为1时,data可能还是0。

解决办法?加内存屏障:

#include <stdatomic.h>

atomic_int flag = 0;
int data = 0;

// 线程A
data = 42;
atomic_store_explicit(&flag, 1, memory_order_release);

// 线程B
if (atomic_load_explicit(&flag, 1, memory_order_acquire)) {
    printf("%d\n", data);  // 现在安全了
}

release 屏障保证:之前的所有写操作,在屏障之后对其他线程可见。acquire 屏障保证:之后的所有读操作,能看到屏障之前其他线程的写。

我的经验:大多数场景用 memory_order_seq_cst(默认顺序一致性)就够了。只有在性能敏感的热路径上,才去抠 acquire/release 的细节。别为了炫技把代码搞复杂了。

三、锁优化策略:别让锁成为瓶颈

锁用不好,多线程比单线程还慢。我见过一个项目,加了锁之后性能反而下降了30%。原因很简单——锁竞争太激烈了。

下面是我总结的几条锁优化策略:

策略 说明 适用场景
减小锁粒度 只锁真正需要保护的数据 大数据结构,操作只涉及部分字段
读写锁分离 读多写少时,读锁可共享 配置表、缓存、路由表
自旋锁替代互斥锁 锁持有时间极短时,避免线程切换 内核编程、中断上下文
锁消除 编译器分析发现锁无竞争时自动去掉 JIT编译场景(C语言较少)
无锁编程 用CAS等原子操作替代锁 高性能队列、计数器

来看一个自旋锁的简单实现:

typedef struct {
    atomic_flag lock;
} spinlock_t;

void spin_lock(spinlock_t *s) {
    while (atomic_flag_test_and_set(&s->lock)) {
        // 忙等待,CPU空转
        // 可以加 _mm_pause() 指令降低功耗
    }
}

void spin_unlock(spinlock_t *s) {
    atomic_flag_clear(&s->lock);
}

警告:自旋锁适合锁持有时间极短的场景(比如几十条指令)。如果锁持有时间较长,或者线程可能被抢占,请用互斥锁。否则CPU会一直空转,浪费电。

我曾经在一个网络包处理模块中,把互斥锁换成了自旋锁。锁持有时间只有几个内存访问,结果吞吐量提升了15%。但换到另一个磁盘I/O场景,自旋锁反而让CPU飙到100%,最后又改回了互斥锁。

四、实战建议:如何选择

说了这么多,到底怎么选?我个人的决策流程是这样的:

  1. 先问自己:这个变量真的需要同步吗?能不能用线程本地存储(TLS)避免共享?
  2. 能用原子操作吗?单个变量、简单操作,优先用 atomic
  3. 需要保护多个变量?用锁。先上互斥锁,性能不够再优化。
  4. 读多写少?考虑读写锁。
  5. 锁竞争严重?检查锁粒度,考虑分段锁或无锁数据结构。

记住一句话:先保证正确,再追求性能。并发bug最难排查,别为了省几微秒,埋下几个通宵debug的坑。

我曾经花了一周时间,才定位到一个内存屏障缺失导致的bug。现象是程序跑几小时才崩溃一次,复现极其困难。从那以后,我对并发代码的审查就格外严格——每一处共享变量的访问,都要问一句:这里安全吗?

好了,并发编程优化就聊到这儿。原子操作是基础,内存屏障是细节,锁策略是艺术。多写、多测、多思考,你也能写出既快又稳的并发代码。