接手过线上C++服务的人,大概率都被“程序莫名其妙就崩了”折磨过。查到最后,多数时候不是业务逻辑有问题,而是一行看似无害的 ++counter 在多线程下翻了车。C++并发编程里,原子操作(std::atomic)就是解决这类问题的最底层地基,也是面试官最爱往深挖的一个点:内存序、CAS、ABA问题,每一个都能问到人冒汗。这篇文章我会从CPU缓存、指令重排这些底层原理讲起,一路聊到内存序怎么选、ABA问题怎么破,最后给出自旋锁、原子计数器、无锁栈的完整实现和排查工具,希望对所有写多线程C++的同行,以及正在准备C++面试的朋友,能提供一份可以直接参考的实战笔记。
1. 先搞清楚:原子操作到底解决了什么问题
1.1 一次 ++ 引发的“血案”
先看一段再普通不过的代码:
cpp复制#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int counter = 0;
void worker(int times) {
for (int i = 0; i < times; ++i) {
++counter;
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 8; ++i) {
threads.emplace_back(worker, 1000000);
}
for (auto& t : threads) {
t.join();
}
std::cout << counter << std::endl;
return 0;
}
8个线程各自执行100万次自增,理论结果是800万,但实际上跑出来常常是几百万,甚至每次结果都不一样。原因很简单:++counter 不是一条不可分割的操作,它实际上包含三步——读取count的旧值、把旧值加1、把新值写回去。假设两个线程同时读到0,各自加1之后都写回1,那么一次更新就凭空丢失了。
用生活里的场景类比一下。饭店前台放着一本纸质签到本,10个客人同时去签到,每个人都看到空白页,都写下了自己的大名,结果本子上只留下一个名字。其他9个人的签到全被覆盖了。多线程数据竞争的问题,本质上就是这样:多个执行流同时读写同一块内存,互相覆盖,谁也说不清最终状态是什么。
C++标准把这个情况定义得很清楚:只要存在两个线程,其中一个在写一个非原子变量,另一个在读或写同一个变量,且没有任何同步机制,那就是数据竞争,属于未定义行为。一旦触发未定义行为,程序崩溃、结果错误、内存损坏,什么都可能发生,排查起来极其痛苦。
1.2 原子操作的三要素
std::atomic 就是C++11为解决这个问题引入的标准组件。一个原子操作需要同时满足三个特性。
第一是原子性。read-modify-write这种复合操作,要么整体执行完成,要么整体不执行,中间状态对任何线程都不可见。比如 fetch_add 就能保证“读-加-写”是一个不可拆的整体。
第二是可见性。一个线程修改了原子变量,其他线程在合理的同步语义下能及时看到最新值,而不是永远读到自己CPU缓存里的旧副本。
第三是有序性。通过内存序(memory order)机制,原子操作能约束编译器重排和CPU乱序执行,确保关键的数据访问顺序不被破坏。
这三个特性缺一不可。有些程序员以为只要把变量声明成 volatile 就能解决多线程问题,这是个大坑。volatile 只告诉编译器“这个变量可能被外部修改,别优化掉我的读写”,它既不保证原子性,也不提供内存序约束,完全不能替代 std::atomic。这个问题后面我会单独展开讲。
1.3 为什么说它是整个并发体系的地基
一个容易被忽略的事实是:互斥锁(mutex)、条件变量、信号量这些高级并发原语,底层全都是靠原子操作实现。std::mutex::lock() 内部就是对某个标志位做原子test-and-set或exchange操作,抢不到锁的线程才进入休眠队列。所以如果不理解原子操作,你对锁的理解也永远停留在API调用层面。
C++11之前,想写可移植的多线程原子操作几乎不可能。要么用gcc内置的 __sync_fetch_and_add 这类编译器扩展,要么用Windows的 InterlockedIncrement,要么用pthread自带的 pthread_spinlock_t。代码写出来一堆 #ifdef _WIN32,丑且难维护。C++11把 std::atomic 正式纳入标准库之后,一套代码处处编译,语义也统一了。C++20又补了 atomic_ref、wait/notify 等新能力,但核心还是那套内存模型。把这套东西吃透,对理解现代C++并发非常有帮助。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 底层原理:CPU缓存、乱序执行与内存模型
2.1 每个CPU核都有自己的“小账本”
要理解原子操作,先得理解为什么多线程会读到过期数据。现代CPU里,每个核心都有自己的L1、L2缓存,多个核心共享L3缓存,之后再是主内存。比如线程A跑在核0上,线程B跑在核1上,A修改了变量x,这个修改可能先落在核0的L1缓存里,根本没有同步到主内存,B从核1的L1或L2里读到的还是旧值。
这时候就需要缓存一致性协议。最经典的MESI协议把缓存行(cache line,通常64字节)分成四种状态:Modified(已修改,未写回主存)、Exclusive(独占,未修改)、Shared(多个核心共享)、Invalid(已失效)。当一个核心要修改一个Shared状态的缓存行时,它必须先向总线发出通知,让其他核心把自己持有的副本标记为Invalid。下一次其他核心再读这个地址时,发现自己的副本失效了,就得重新从主存或其他缓存拉取最新值。
生活里类似场景就是合租室友用共享购物清单。你在自己手机备忘录里记了一笔“买牛奶”,室友的手机备忘录不会自动更新。只有你把这条记录同步到共享空间,并且告知对方“你之前那份缓存的副本作废了”,对方才能看到新内容。CPU缓存一致性的本质也是这套逻辑。
2.2 重排序:编译器与CPU的“自作主张”
即使缓存一致性没问题,仍然会遇到另一个坑:代码顺序不等于执行顺序。编译器为了指令流水线更顺畅、寄存器利用率更高,可能把没有依赖关系的两条语句调换位置。CPU硬件也类似,现代处理器都支持乱序执行(out-of-order execution),只要不影响单线程语义,它就可能把后续指令提前执行。
经典例子是这个:
cpp复制int x = 0;
int flag = 0;
// 线程1
x = 42;
flag = 1;
// 线程2
while (flag != 1);
int value = x;
直觉上,线程2应该等flag变成1之后再去读x,value应当是42。但如果编译器或CPU把x=42和flag=1这两条写的顺序调换了,线程2可能看到flag=1,此时x还没写进去,读到的是0。在x86平台上,store之间的乱序相对少见,但在ARM这类弱内存模型平台上,这种重排是真实存在的。所以“看起来不会重排”的直觉,在边界条件下不堪一击。
2.3 内存模型与happens-before关系
C++11内存模型的厉害之处,在于它定义了一个抽象层,让同一份代码在不同CPU架构上有一致的语义。核心概念是happens-before(先行发生)。当线程A对一个原子变量执行release store,线程B对同一个原子变量执行acquire load并成功读到A写入的值时,A在release之前的所有写操作,对B在acquire之后的所有读操作都是可见的。这就是所谓的synchronizes-with关系。
C++内存序主要分三档:memory_order_relaxed 只保证原子性,不做任何顺序约束;memory_order_acquire / memory_order_release 提供单向的顺序屏障;memory_order_seq_cst 提供全局一致顺序,也是 std::atomic 默认的语义。
这里有个跨平台经验值得多说一句:x86是强内存模型,store不会乱序,load也不会乱序,所以relaxed在x86上编译出来的指令可能和seq_cst差不多,性能差异不明显。但ARM是弱内存模型,relaxed和seq_cst编译出的指令差别很大,seq_cst往往会多出内存屏障指令(如ARM的dmb),性能差距一下子就能拉开。所以判断内存序的好坏,不能只在开发机上用x86测,还要考虑生产环境的CPU架构。
3. 内存序:实战中怎么选
3.1 三种内存序速查
| 内存序 | 核心语义 | 能保证什么 | 典型场景 |
|---|---|---|---|
| memory_order_relaxed | 原子性 | 单条原子操作不可分割,不做顺序约束 | 计数器、统计量、标志位 |
| memory_order_release / acquire | 成对同步 | release前的写不被重排到release之后;acquire后的读不被重排到acquire之前 | 锁、发布消息、生产者-消费者 |
| memory_order_seq_cst | 全局一致顺序 | 所有原子操作在所有线程看来顺序一致 | 默认选择,复杂并发状态 |
可以看到,relaxed不是“性能更差”的选项,而是“约束更少”的选项,能用它的前提是操作之间没有顺序依赖。具体选择其实是个权衡题:你愿意花多少性能,去换取多少顺序保证。
3.2 计数器场景:relaxed就够
最典型的使用场景是性能统计。比如在服务里统计接口调用次数:
cpp复制std::atomic<long long> event_count{0};
void on_event() {
event_count.fetch_add(1, std::memory_order_relaxed);
}
void print_stats() {
long long v = event_count.load(std::memory_order_relaxed);
std::cout << v << std::endl;
}
为什么这里可以用relaxed?因为计数器本身没有依赖关系:我只关心“总数是多少”,不关心这次累加之前内存里发生了什么。即使某个线程读到旧值,也不会影响统计的最终正确性。在这种情况下,使用seq_cst不会带来额外的正确性收益,只会白白付出性能成本。
实测经验是:在x86上,fetch_add(1, relaxed)和seq_cst编译出来可能都是 lock xadd,差距不明显;但在ARM上,seq_cst会多出内存屏障,而relaxed可以用 ldrex/strex 循环实现,开销明显小。所以写高并发统计模块时,我习惯显式用relaxed,并且写上注释说明“此处不需要顺序约束,仅为原子计数”。
3.3 数据发布:release/acquire必须配对
当原子操作除了自身值之外,还要“携带”其他普通变量的可见性时,就必须用release/acquire。经典例子是消息发布:
cpp复制std::atomic<bool> ready{false};
std::string message;
// 生产者线程
message = "hello, atomics";
ready.store(true, std::memory_order_release);
// 消费者线程
if (ready.load(std::memory_order_acquire)) {
std::cout << message << std::endl;
}
这里的运行逻辑是:生产者在ready.store(release)之前对message的写入,通过release屏障保证不会重排到store之后;消费者一旦在ready.load(acquire)中读到true,就建立了“先行发生”关系,可以放心使用message。如果把消费者的acquire误写成relaxed,那么message的读写和ready的读写之间没有同步关系,这属于数据竞争,程序可能输出未定义内容。
这也是无锁编程的核心奥秘:原子变量本身只是“信使”,真正有价值的普通数据通过release/acquire这类内存屏障完成安全传递。锁的本质也是这个,只不过mutex封装好了这些细节。
3.4 seq_cst什么时候真的需要
默认的seq_cst适合大多数场景,使用起来最简单,因为所有原子操作在所有线程看来都遵循同一个全局顺序。但某些场景下acquire/release不够,必须用seq_cst。典型例子是IRIW(独立读取独立写入)问题:两个线程分别写两个原子变量,另外两个线程分别读这两个变量,relaxed或acquire/release不能保证读线程们看到相同的写入顺序,但seq_cst可以。
不过说实话,工程上遇到这类场景的概率很低。我的建议是:先用默认的seq_cst把功能做对,然后通过性能分析确认瓶颈确实在原子操作上,再逐步降级到acquire/release,最后才考虑relaxed。一上来就用relaxed炫技,等线上出问题再抓bug,那代价可就不是一点性能能补回来的了。
4. CAS操作与ABA问题:无锁编程的核心与陷阱
4.1 compare_exchange:原子比较并交换
无锁编程最重要的原子原语是CAS(Compare-And-Swap),在C++里对应 compare_exchange_strong 和 compare_exchange_weak。它的逻辑是:如果原子变量的当前值等于期望值,就把变量更新为目标值,返回true;否则更新期望值为当前值,返回false。
cpp复制std::atomic<int> val{0};
int expected = val.load();
int desired = expected + 1;
while (!val.compare_exchange_weak(expected, desired)) {
desired = expected + 1; // 失败时expected会被更新为最新值
}
重点强调:CAS失败之后,expected 会被自动更新为原子变量当前的实际值。所以循环里要紧跟一句“重新计算desired”,不然第二次循环仍然用老的desired去尝试,很可能陷入死循环。这是新手写CAS最容易踩的坑。
weak 和 strong 也有讲究。compare_exchange_weak 允许“伪失败”,即使值相等也可能返回false,通常需要在循环里配合使用。compare_exchange_strong 则保证值相等时一定返回true。在缺少单条CAS指令的平台上(比如部分ARM架构),weak编译效率更高,所以循环CAS优先用weak;如果只尝试一次,不重试,必须用strong。
4.2 ABA问题:值相等不等于状态没变
CAS最经典的陷阱就是ABA问题。考虑一个无锁栈:
cpp复制struct Node {
int value;
Node* next;
};
std::atomic<Node*> head{nullptr};
void push(Node* node) {
Node* old_head = head.load();
do {
node->next = old_head;
} while (!head.compare_exchange_weak(old_head, node));
}
ABA问题发生的流程是这样的:线程1读取head,此时栈顶是节点A,它打算执行CAS,把head从A改成自己的新节点。但线程1在CAS之前被操作系统切出去了。线程2接着执行:把A弹出栈,释放了A的内存;然后又压入一个节点B,再压入一个新节点A'——注意A'的内存地址恰好和之前的A相同。当线程1恢复执行时,它再次比较head,发现head还是那个地址值A,于是CAS成功。但此时栈的真实结构已经和线程1读取时完全不同了,而线程1毫不知情,以为自己把新节点压到了原来的A节点之上,链表结构就此损坏。
一句话概括:ABA问题就是“内存地址值相同,但内容已经变过一轮了”。CAS只比较了值的相等性,没有比较值的版本变化,所以“值相等”并不能代表“状态没变”。
4.3 解决ABA的主流方案
| 方案 | 核心思路 | 成本 | 适用场景 |
|---|---|---|---|
| 版本号/标记指针 | 在指针高位塞入递增计数,CAS同时比较地址和版本 | 需要额外位,或使用双字CAS | 经典方案,可落地 |
| Hazard Pointer | 读取前标记指针在使用,延迟释放内存 | 需要线程局部槽位,回收逻辑复杂 | 无锁容器库 |
| Epoch-based Reclamation | 按“代”延迟回收内存,活跃读线程不被影响 | 线程要汇报活跃区间 | 读多写少场景 |
| 业务层避免地址复用 | 不在链上复用同一块内存,push前new,pop后延迟释放 | 减小触发概率,不能根治 | 快速验证原型 |
版本号方案最直观。在64位平台上,指针实际只用低48位,高16位可以塞一个版本号。把指针和版本号打包进 std::atomic<uintptr_t>,每次成功CAS时版本号加1。即使节点地址因为内存复用回到旧值,版本号不同,CAS也会失败。需要注意,这依赖平台地址空间布局,Windows开启5级页表后可用地址位会变化,所以生产环境要谨慎,必要时用 __int128 或双字CAS实现。
另一种更工程化的思路是:不要在链表上复用同一个节点。push节点时用 new 新分配,pop之后延迟到“安全点”再释放。这不能根治ABA,但能大幅降低触发概率。对很多性能要求没那么极端的业务来说,先配一个线程池做延迟回收,比手写hazard pointer更简单可靠。
4.4 面试高频追问:从ABA再挖一层
C++面试里“ABA问题”几乎是必考题。我建议准备面试的朋友不要只背结论,要能把这个链条完整说清楚:++x 为什么不是原子的→原子操作怎么保证read-modify-write整体性→CAS的原理是“比较再交换”→ABA问题为什么会出现→解决思路有哪些。面试官往往还会追问weak和strong的区别,以及为什么无锁栈的pop不能直接delete。这些都能答上的话,说明你对并发底层的理解是成体系的,而不是背了段八股文。
5. 实战:从自旋锁到无锁计数器的完整实现
5.1 用atomic_flag实现自旋锁
std::atomic_flag 是最简单的原子类型,只支持 test_and_set 和 clear 两个操作,专门用来做自旋锁:
cpp复制#include <atomic>
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 自旋等待
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
这个实现能用,但在高竞争场景下性能不理想。因为每个等待的线程都在反复执行 test_and_set,这个操作会写出独占请求,反复抢占缓存行,导致总线上充满冲突流量,反而拖慢持锁线程的进度。更专业的做法是TTAS(test-and-test-and-set):先用只读的 load 自旋,发现锁释放了,再执行 test_and_set 去抢锁:
cpp复制void lock() {
while (true) {
while (flag.load(std::memory_order_relaxed)) {
// 只读自旋,不产生写流量
}
if (!flag.test_and_set(std::memory_order_acquire)) {
break;
}
}
}
如果临界区平均长度非常短,这个锁比 std::mutex 快得多,因为它不需要操作系统介入、不需要线程切换。但前提是临界区必须极短,否则大量线程空转烧CPU,反而拖垮吞吐。什么时候用自旋锁、什么时候用mutex,判断标准就是“临界区耗时和线程切换耗时的比值”。
锁的 test_and_set 用的是 acquire,clear 用的 release,这两者配对才能保证临界区里的写操作对下一个获得锁的线程可见。如果把内存序全部省略,默认是seq_cst,功能也是对的,只是会有额外的屏障开销。这里是个典型的“功能正确+性能优化”二选一场景。
5.2 原子计数器:从互斥锁到atomic的优化
实际业务里最常见的一个优化,就是把 mutex + int 改成 std::atomic<int>。我之前在8核x86机器上做过一个简单压测:8个线程各自对计数器执行100万次自增,互斥锁版本耗时大约是原子操作版本的5倍以上,而且线程越多,mutex版本受锁竞争影响越明显,atomic版本基本能维持水平扩展。
| 实现 | 1线程 | 8线程 |
|---|---|---|
| mutex + int | 约1500万次/秒 | 约300万次/秒 |
| atomic + relaxed | 约5000万次/秒 | 约5000万次/秒 |
注意这个数据只是量级参考,不同机器差异很大,但趋势是一致的:锁竞争会随线程数增加恶化,而无锁的原子计数则没有这个瓶颈。
再往深一层说,多个线程同时更新同一个atomic变量时,依然共享同一个缓存行,竞争不可避免。如果追求极致扩展性,可以把计数器拆成per-thread的分桶,每个线程只写自己的桶,最后汇总所有桶的值。这就是“避免伪共享”思路的正向运用——同一个缓存行不同字节被不同线程写,也会造成相互踢失效,性能损耗极大。原子变量解决的是“同一块数据的原子性”,但它并不能解决“不同线程频繁访问同一块缓存行”的物理争用问题。
5.3 无锁栈的实现思路与风险提示
无锁栈是最简单的无锁数据结构,适合用来理解CAS的写法:
cpp复制#include <atomic>
struct Node {
int value;
Node* next;
};
class LockFreeStack {
std::atomic<Node*> head{nullptr};
public:
void push(int v) {
Node* n = new Node{v, nullptr};
Node* h = head.load(std::memory_order_relaxed);
do {
n->next = h;
} while (!head.compare_exchange_weak(h, n,
std::memory_order_release, std::memory_order_relaxed));
}
bool pop(int& out) {
Node* h = head.load(std::memory_order_relaxed);
while (h) {
if (head.compare_exchange_weak(h, h->next,
std::memory_order_acquire, std::memory_order_relaxed)) {
out = h->value;
delete h;
return true;
}
}
return false;
}
};
push的逻辑很清楚:先读取当前栈顶,把新节点的next指向栈顶,然后CAS把head更新为新节点。CAS失败就说明有别的线程抢先修改了head,循环重试。
但这里的pop直接 delete h 是有隐患的。线程1执行CAS成功,拿到了节点h,准备读取h->value并释放h。此时线程2可能已经在执行另一个pop,读到的head恰好还是h,于是也拿到了h。两个线程同时操作同一块已释放内存,use-after-free就发生了。更隐蔽的是ABA问题:h被释放后,内存又被新分配的节点复用,地址相同但内容不同,后续CAS会得出完全错误的结果。
所以这个版本只能用来学习CAS写法,不能直接投产。生产级无锁栈需要配合hazard pointer、epoch-based reclamation或引用计数机制。业界的boost.lockfree已经实现了这些细节,如果项目允许,直接使用成熟的库,比自己从头造轮子安全得多。无锁编程真正的难点不是CAS,而是内存回收。
5.4 atomic与标准库其他并发组件的关系
std::atomic 不是孤立存在的。std::mutex 内部用原子标志位完成抢锁;std::condition_variable 的等待和通知依赖内部原子状态和锁;std::shared_ptr 的控制块引用计数是用原子操作实现的;异步任务、future、信号量,底层全都离不开原子操作。可以说,整个C++并发库都是建立在原子操作之上的一层封装。
理解这一层关系,有助于在做技术选型时心里有数。如果业务需要的是“多个线程协作完成任务”,优先考虑任务队列、消息传递、future这些高级抽象;如果业务只是“一个计数器要被高频更新”,直接用atomic就够,没必要上锁;如果是复杂的并发容器,优先找成熟库。低级原语适合在明确场景下做精细化控制,但不是所有并发现象都要拿原子操作手搓。
6. 高频踩坑记录与排查工具
6.1 volatile不是原子操作,别再混淆
这个误区我在代码评审里见了太多次。volatile 只做一件事:告诉编译器“这个变量的值可能会在编译器不可见的地方被修改,请不要优化掉对该变量的访问”。它不保证读改写操作的原子性,不提供内存屏障,更不会阻止CPU乱序执行。你可以用volatile读一个内存映射寄存器,但用它做多线程共享标志位,该崩还是崩。
经典面试题“volatile和std::atomic有什么区别”,标准答案就是:volatile不具备原子性和内存序语义,不能用于线程间同步;atomic专门为此设计。如果面到这道题,还能顺手提到MESI协议和内存屏障,基本就能证明你不是背答案的。
6.2 std::atomic的使用限制
std::atomic<T> 要求T是TriviallyCopyable类型,因为原子操作底层需要直接操作内存字节。所以 std::atomic<std::string> 这么写是编译不过的。如果你真的需要原子地共享一个字符串,常见的做法是把它放在 std::shared_ptr 里,然后原子地修改指针。
另外,不是所有atomic变量都是无锁的。通过 is_lock_free() 成员函数可以查询当前类型在当前平台上是否无锁。对于基本类型(int、long long、指针),在x86/ARM上一般是无锁的;但自定义结构体如果大于机器字长,编译器可能默默地在内部加一把锁,性能特征和无锁完全不同。所以性能敏感场景,优先用基本类型,少用自定义大结构体。
还有一个容易被忽略的细节:std::atomic 禁用了拷贝构造和拷贝赋值。直接写 std::atomic<int> b = a; 是编译错误,必须用 b.store(a.load()) 显式完成,因为“读一个原子变量再写另一个”这个组合本身不是原子的,标准特意禁止了这种危险写法。
6.3 用TSan定位数据竞争
原子操作用得再熟练,代码里仍可能存在隐藏的数据竞争。最可靠的排查工具是线程消毒器ThreadSanitizer(TSan)。用Clang或GCC编译时加上 -fsanitize=thread -g -O1,跑测试用例时,只要发生数据竞争,程序会直接打印出参与竞争的线程栈和变量地址。
bash复制g++ -std=c++20 -fsanitize=thread -g -O1 test.cpp -o test
./test
比如开头的 ++counter 数据竞争示例,用TSan编译后会输出类似“WARNING: ThreadSanitizer: data race”的警告,能精确定位到是哪一个变量、在哪一个线程的哪一行。线上偶发崩溃的问题,十有八九是数据竞争,而这种问题靠普通压测很难稳定复现,因为线程交织是概率性的。所以我的经验是:涉及多线程的代码,在测试阶段就常态化开TSan跑一遍,成本低、效果好。
如果需要排查use-after-free或内存越界,配一个AddressSanitizer(-fsanitize=address)即可。无锁容器如果配了错误的内存回收策略,ASan能帮你快速抓到踩踏痕迹。
6.4 辨析几条实战经验
回看这些年和原子操作打交道的经历,有几条心得特别想分享。
第一,不要过早优化内存序。先用默认的seq_cst把逻辑写对,线上跑一段时间,确认这块确实是性能热点,再考虑降级到acquire/release或relaxed。内存序不是性能炫耀的资本,用错了正确性就没了。
第二,能用锁就先用锁。无锁数据结构只有在明确测量出锁竞争确实成了瓶颈时,才值得投入。无锁的调试难度比锁高一个数量级,特别是ABA和内存回收问题,很容易在线上埋雷。
第三,设计并发程序时,永远反问一句:这个数据真的需要共享吗?如果能通过消息传递、任务队列、线程局部存储规避共享,那连原子操作都可以省掉。并发编程的终极优化,是减少共享,而不是更高效地共享。
第四,面试和工程是两回事。面试讲清楚CAS和ABA体现了你的理论深度,但工程里直接手搓无锁容器则大概率是过度设计。能用成熟库解决的,就别拿生产环境当实验室。
我自己现在写多线程代码时,养成一个习惯:凡是共享变量的读写,如果不是std::atomic,就会在代码审查时打个问号——凭什么它不需要同步?这个习惯帮我抓出过好几个潜伏的并发隐患。原子操作的难点从来不在那几条API,而在于想清楚:我的数据在什么时刻、以什么方式,对其他线程可见。把这个问题想透了,再回来看这些底层机制,会发现一切都顺理成章。
