一、从单核到多核:内存序为什么成了问题?

说实话,我刚开始学C语言那会儿,根本不知道还有「内存序」这回事。那时候写的都是单线程程序,代码一行一行执行,结果完全可预测。但后来开始搞多线程,问题就来了。

你想想看,现代CPU为了性能,会乱序执行指令。编译器为了优化,也会重新排列你的代码。单线程下这没问题,因为CPU和编译器保证「看起来」顺序是对的。但多线程呢?一个线程写,另一个线程读,顺序就乱套了。

我在项目中遇到过这样一个bug:两个线程共享一个标志变量,A线程设置标志后发送数据,B线程看到标志就去读数据。结果呢?B线程读到的数据是旧的。为什么?因为CPU把「设置标志」和「发送数据」这两个操作重排序了。

嗯,这就是内存序要解决的问题。

二、C11的六种内存序

C11标准引入了 stdatomic.h,定义了六种内存序。我按「强度」从弱到强给你列出来:

内存序 含义 使用场景
memory_order_relaxed 最弱,只保证原子性 计数器、统计量
memory_order_consume 数据依赖序(基本不用) 很少用,C17已废弃
memory_order_acquire 读操作,禁止后面的读写越过它 锁的获取、读标志
memory_order_release 写操作,禁止前面的读写越过它 锁的释放、写标志
memory_order_acq_rel acquire + release 读-改-写操作
memory_order_seq_cst 最强,全局顺序一致 默认值,简单但慢

核心原则:release 和 acquire 必须配对使用。一个线程 release 写,另一个线程 acquire 读,才能建立 happens-before 关系。

三、CAS操作:无锁编程的基石

CAS(Compare-And-Swap)是个原子操作。它的逻辑很简单:

bool atomic_compare_exchange_strong(atomic_int* p, int* expected, int desired);

如果 *p == *expected,就把 *p 改成 desired,返回 true。否则把 *p 写入 *expected,返回 false。

说白了,就是「你觉得值是多少,如果对就改,不对就告诉你实际值」。这个操作是原子的,不会被其他线程打断。

我曾经用CAS实现过一个无锁队列。刚开始觉得挺简单,结果调试了整整两天。问题出在哪?ABA问题。线程1看到值是A,准备CAS改成C。但线程2先把A改成B,又改回A。线程1的CAS成功了,但数据已经变了。

避坑指南:我曾经在无锁栈上栽过跟头。ABA问题可以用带版本号的指针解决,比如 atomic<uint64_t> 高32位存版本号,低32位存指针。或者直接用 std::atomic<shared_ptr>(C++20)。

四、无锁编程:看起来很美,用起来很坑

无锁编程,说白了就是不用互斥锁,靠原子操作保证线程安全。好处很明显:不会死锁,性能好(尤其是高并发场景)。但代价呢?

  • 正确性极难保证——我见过太多无锁代码跑着跑着就崩了
  • 调试困难——bug可能几小时才出现一次
  • 可移植性差——x86上跑得好好的,ARM上就出问题

我个人习惯是:能用锁就用锁。只有锁成为性能瓶颈时,才考虑无锁方案。而且一定要写单元测试,跑压力测试,最好用ThreadSanitizer检查数据竞争。

五、一个完整的例子:无锁计数器

来看个最简单的无锁数据结构——计数器。多个线程并发递增,最后读出正确值。

#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>

atomic_int counter = 0;

int worker(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        // 用 fetch_add 原子递增
        atomic_fetch_add(&counter, 1);
    }
    return 0;
}

int main() {
    thrd_t t1, t2;
    thrd_create(&t1, worker, NULL);
    thrd_create(&t2, worker, NULL);
    thrd_join(t1, NULL);
    thrd_join(t2, NULL);
    
    printf("Counter = %d\n", atomic_load(&counter));
    return 0;
}

这里用 memory_order_seq_cst(默认),保证所有线程看到一致的递增顺序。如果换成 memory_order_relaxed,结果可能不对——因为两个线程的递增操作可能互相覆盖。

小技巧:计数器这种场景,用 memory_order_relaxed 就够了。但前提是你只关心最终值,不关心顺序。我一般用 relaxed 做统计计数,用 seq_cst 做同步控制。

六、内存序与CPU架构的关系

不同CPU架构的内存模型不一样。x86是强内存模型,store操作不会重排序。ARM是弱内存模型,store都可能重排。所以你在x86上测得好好的代码,放到ARM上可能就崩了。

我记得有一次帮同事排查bug,他的无锁队列在Intel机器上跑了三天没问题,一上ARM服务器就挂。最后发现是缺少acquire语义。加上 atomic_thread_fence(memory_order_acquire) 就好了。

所以我的建议是:永远不要依赖CPU架构的「巧合」。该加内存序就加,该用fence就用。别偷懒。

七、知识体系图

下面这张图总结了本章的核心知识点:

内存序与无锁编程知识体系 六种内存序 relaxed → 最弱,仅原子性 acquire/release → 配对使用 acq_rel / seq_cst → 最强 CAS 操作 compare_exchange_strong compare_exchange_weak ABA问题 → 版本号解决 无锁编程 无锁队列 无锁栈 计数器 核心原则:release/acquire配对 | 能用锁就别用无锁 | 测试+验证 不同CPU架构行为不同,不要依赖巧合 内存序是工具,无锁编程是艺术。先理解,再使用。

八、总结

内存序和无锁编程,说白了就是「在并发环境下,如何安全高效地共享数据」。我做了这么多年C语言开发,最大的体会是:不要为了炫技而用无锁。锁虽然慢一点,但正确性容易保证。无锁代码写出来,你自己都未必能证明它是对的。

如果你刚开始接触这些概念,我建议:

  • 先从 memory_order_seq_cst 开始,它最安全
  • 理解 release/acquire 的配对关系
  • 用 CAS 实现简单的无锁结构,跑压力测试
  • 再逐步尝试 weaker 的内存序

嗯,今天就聊到这儿。记住:并发编程没有银弹,理解底层原理才是王道。


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