文档目录

这个问题触及了无锁编程最隐蔽的陷阱。很多人以为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. 给你的"硬件开发者思维"

下次写无锁代码时,问自己三个问题:

  1. 内存会被释放吗? 如果不会(如全局数组),ABA可能无害。
  2. 能使用标签指针吗? 如果能,加16位版本号是最优解。
  3. 能容忍延迟删除吗? 如果能,用Hazard Pointer。

性能对比:

  • 标签指针:最快(只需一次CAS)
  • Hazard Pointer:中等(需要额外检查)
  • 互斥锁:最慢(但有ABA免疫)