这个问题触及了无锁编程最隐蔽的陷阱。很多人以为ABA只是理论概念,但在内存复用的场景下,它会导致内存损坏甚至安全漏洞。
我们先从硬件视角看为什么CPU会被骗,再深入实际崩溃场景。
1. 硬件层面的"失忆症"
场景重现(用 cmpxchg)
初始状态: 共享指针 ptr 指向 内存块A (地址 0x1000)
[时刻1] 线程1 读取 ptr → 0x1000 (期望值)
[时刻2] 线程2 执行:
ptr 从 0x1000 → 0x2000 (释放A)
ptr 从 0x2000 → 0x1000 (重新分配,又指向A)
[时刻3] 线程1 执行 lock cmpxchg:
比较: 当前 ptr (0x1000) == 期望值 (0x1000) → ✅ 相等!
硬件认为"没人动过",允许写入新值 0x3000
硬件视角:地址0x1000确实没变,CAS成功。
现实视角:0x1000指向的内存内容可能已经被完全改写(线程2在复用期间修改了A的内容),但CAS毫不知情。
2. 为什么传统锁不会遇到ABA?
// 使用互斥锁 (Mutex)
std::mutex mtx;
{
std::lock_guard<std::mutex> lock(mtx);
// 临界区: 执行过程中,其他线程无法进入
// 所以不可能出现"A→B→A"的中间态
}
关键差异:锁是排他性阻塞,而CAS是乐观重试。锁期间其他线程根本动不了数据,自然不会有ABA。
3. ABA的真正危险:内存复用(最典型场景)
无锁栈(Lock-Free Stack)的崩溃现场
struct Node {
int data;
Node* next;
};
std::atomic<Node*> top{nullptr};
// 错误实现(有ABA漏洞)
void pop_buggy() {
Node* old_top = top.load();
Node* new_top;
do {
if (!old_top) return; // 空栈
new_top = old_top->next; // ⚠️ 危险: 读取 old_top 指向的内存
} while (!top.compare_exchange_weak(old_top, new_top));
// 此时 old_top 可能指向已被释放的内存!
delete old_top; // 💥 双重释放 或 释放非法内存
}
ABA攻击流程
初始: top → [节点A (addr=0x1000)] → [节点B] → null
[线程1] 执行 pop():
old_top = 0x1000
new_top = 0x1000->next (0x2000) // 准备将 top 改为 B
此时线程1被操作系统抢占 (上下文切换)
[线程2] 执行两次 pop():
第一次: top → B (删除了A,将A内存释放)
第二次: top → null (删除了B,将B内存释放)
内存分配器将 A 的地址 (0x1000) 重新分配给新节点C
push: 将新节点C (addr=0x1000) 推入栈 → top = 0x1000
[线程1] 恢复执行:
CAS 比较: 当前 top (0x1000) == old_top (0x1000) ✅ 成功!
top 被改为 new_top (0x2000) // ⚠️ 但 0x2000 已经在线程2中被释放!
[结果]: top 指向了已释放的内存 (悬垂指针),程序随机崩溃
4. 硬件"监视器"能解决ABA吗?
不能! 这是初学者最大的误解。
ARM的LL/SC(独占监视器)也不能解决
; 线程1执行LL
LL x0, [top] ; 监视器标记 top 地址 (0x1000)
; 线程2执行了 A→B→A
; 虽然地址最终没变,但监视器在"B→A"的写操作时已经清除了标记
; 线程1执行SC时,因为监视器标记丢失,会返回失败
但是! 这只是因为监视器检测到了写操作,不是因为检测到了ABA。如果线程2用DMA(直接内存访问) 或者其他核心悄悄修改了内存内容但没触发缓存一致性消息,监视器依然会被骗。
结论:硬件监视器只能检测"是否有写操作发生",无法检测"值是否被改过又改回来"。
5. ABA的三种实战解决方案
方案1:标签指针(Tagged Pointer)—— 最常用
在指针的未使用高位中嵌入版本号。
struct TaggedPtr {
uintptr_t ptr : 48; // 地址(假设48位虚拟地址)
uintptr_t tag : 16; // 版本号
};
std::atomic<uintptr_t> tagged_top; // 原子操作整个64位
bool cas_with_aba_protection(TaggedPtr expected, TaggedPtr desired) {
uintptr_t expected_val = pack(expected.ptr, expected.tag);
uintptr_t desired_val = pack(desired.ptr, desired.tag + 1); // 每次修改加1
return tagged_top.compare_exchange_strong(expected_val, desired_val);
}
原理:即使地址变回原值,tag 已经增加了,CAS会因值不同而失败。
硬件支持:x86的 cmpxchg16b 可以原子操作16字节,正好容纳地址+标签。
方案2:Garbage Collection(垃圾回收)—— 最简单但最慢
用 Hazard Pointer(危险指针) 或 RCU(Read-Copy-Update)。
// Hazard Pointer: 线程在读取节点时,先声明"我正在用这个指针"
thread_local HazardPointer hp;
Node* old_top = top.load();
hp.protect(old_top); // 保护该节点不被删除
// 现在即使其他线程pop,也不会释放被保护的节点
原理:延迟释放内存,直到所有线程都不再引用它。
方案3:使用带引用计数的智能指针
std::atomic<std::shared_ptr<Node>> top; // C++20支持
// shared_ptr 内部包含控制块,引用计数+指针的原子操作
// 但性能开销较大(需要原子操作两次)
6. 硬件新增特性:Intel TSX(事务内存)可缓解ABA
Intel在Haswell架构引入的 TSX(Transactional Synchronization Extensions) 可以检测到"地址被写回原值"这种变化。
// 伪代码:使用硬件事务内存
bool atomic_update_with_aba_detection() {
_xbegin();
// 事务开始:硬件记录所有读写的地址
Node* old_top = top.load();
// ... 执行操作
if (top.compare_exchange_strong(old_top, new_top)) {
_xend(); // 提交事务
return true;
}
_xabort(); // 检测到冲突,回滚
return false;
}
但现实:TSX有严重的安全漏洞(被幽灵/熔断影响),Intel已经在新CPU中禁用了该特性。所以现在主要还是靠软件方案。
7. 面试终极追问:ABA是硬件问题还是软件问题?
标准答案:
- 硬件层面:CAS/LL-SC只保证原子性,不保证一致性。它们忠实地执行"比较-交换",如果值相同就成功。
- 软件层面:ABA是内存生命周期管理问题。当被操作的内存可以被释放和重新分配时,ABA才会造成真正危害。
- 根本原因:CAS无法区分“值从未改变"和"值改变后又改回来”。
什么时候ABA无害?
- 操作的是值类型(如整数计数器):值变回原值无所谓,因为内容没变。
- 使用Copy-on-Write:每次修改都分配新内存,旧内存永不修改。
什么时候ABA致命?
- 操作的是指针(无锁数据结构)。
- 内存被释放并复用(如
malloc/free)。
8. 给你的"硬件开发者思维"
下次写无锁代码时,问自己三个问题:
- 内存会被释放吗? 如果不会(如全局数组),ABA可能无害。
- 能使用标签指针吗? 如果能,加16位版本号是最优解。
- 能容忍延迟删除吗? 如果能,用Hazard Pointer。
性能对比:
- 标签指针:最快(只需一次CAS)
- Hazard Pointer:中等(需要额外检查)
- 互斥锁:最慢(但有ABA免疫)