一、从单核到多核:内存序为什么成了问题?
说实话,我刚开始学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就用。别偷懒。
七、知识体系图
下面这张图总结了本章的核心知识点:
八、总结
内存序和无锁编程,说白了就是「在并发环境下,如何安全高效地共享数据」。我做了这么多年C语言开发,最大的体会是:不要为了炫技而用无锁。锁虽然慢一点,但正确性容易保证。无锁代码写出来,你自己都未必能证明它是对的。
如果你刚开始接触这些概念,我建议:
- 先从
memory_order_seq_cst开始,它最安全 - 理解 release/acquire 的配对关系
- 用 CAS 实现简单的无锁结构,跑压力测试
- 再逐步尝试 weaker 的内存序
嗯,今天就聊到这儿。记住:并发编程没有银弹,理解底层原理才是王道。
公众号:蓝海资料掘金营,微信deep3321