41. 无锁数据结构与内存序:CAS、ABA 与安全内存回收

从锁的代价出发,讲透原子操作与 C++ 内存序(relaxed/acquire/release/seq_cst)的语义边界,剖析 CAS 循环与 ABA 问题的成因与 tagged pointer 解法,拆解 Treiber 栈与 Michael-Scott 队列的实现要点,最后对比引用计数、Hazard Pointer、EBR 与 RCU 四种安全内存回收方案。

1. 无锁编程的动机

多线程共享数据时,最直观的做法是加互斥锁。但锁在高竞争场景下会暴露三类问题:阻塞导致的延迟抖动、优先级反转(低优先级持锁线程被高优先级线程抢占,而高优先级线程又在等这把锁)、以及持锁线程崩溃或死锁导致的全局停摆。无锁(Lock-Free)数据结构的目标,是让至少一个线程总能在有限步内推进,从而消除「一个线程挂起导致全体等待」的失败模式。

1.1 锁的代价

维度互斥锁无锁
无竞争开销一次原子操作 + 系统调用可能单次 CAS
竞争行为睡眠/唤醒、上下文切换自旋重试
失败模式死锁、优先级反转活锁、饥饿(理论上可避免)
可组合性差(锁顺序敏感)好(单点原子)
实现难度低高(内存回收是核心难点)

1.2 无锁不是银弹

无锁算法通常比加锁版本更难写、更难调试,且在低竞争场景下未必更快。经验法则是:只有当锁的争用被 profiling 证实为瓶颈,且临界区极短(几十条指令)时,才值得引入无锁结构。真实系统里,更多时候是「无锁 + 有界等待」的混合方案。

2. 原子操作与内存序

原子性只保证「读改写不可分割」,不保证其他内存访问的可见顺序。现代 CPU 与编译器都会重排指令,因此必须显式声明内存序(Memory Order)。

2.1 六种内存序

C++11 与 C11 定义了六种内存序,强度递增:

内存序语义典型用途
memory_order_relaxed只保证原子性,不保证顺序计数器、统计量
memory_order_consume数据依赖顺序(实践中多被当作 acquire)极少使用
memory_order_acquire之后的读写不能上移读端加锁
memory_order_release之前的读写不能下移写端解锁
memory_order_acq_rel同时具备 acquire 与 release读改写(RMW)
memory_order_seq_cst全局单一总顺序默认、最易推理

2.2 release/acquire 配对

#include <atomic>
#include <thread>

std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;                                   // 普通写
    ready.store(true, std::memory_order_release); // 之前的写不能下移
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) { // 之后的读不能上移
        // 自旋等待
    }
    // 此处一定能看到 data == 42
    assert(data == 42);
}

如果把 release/acquire 换成 relaxed,assert 可能失败:编译器与 CPU 都可能把 data = 42 重排到 ready.store 之后。

2.3 seq_cst 的代价

seq_cst 在所有原子操作间建立单一全局顺序,代价是在 x86 上需要 MFENCE(或 LOCK 前缀),在 ARM/POWER 上需要更重的屏障。仅在确实需要「多变量之间的一致顺序」时才使用;单纯的生产者-消费者可见性用 release/acquire 即可。

3. CAS 与 ABA 问题

3.1 CAS 语义

比较并交换(Compare-And-Swap,CAS)是大多数无锁算法的基石,语义为:

// 伪代码:若 *ptr == expected,则 *ptr = desired,返回 true
bool compare_exchange_weak(T* ptr, T& expected, T desired);

注意 weak 版本允许伪失败(spurious failure),必须放在循环里;strong 版本只在值不等时失败。x86 对应 CMPXCHG,ARM 对应 LDXR/STXR 循环(LL/SC)。

// 经典 CAS 循环:原子自增
void atomic_increment(std::atomic<int>& x) {
    int old = x.load(std::memory_order_relaxed);
    while (!x.compare_exchange_weak(old, old + 1,
                                    std::memory_order_release,
                                    std::memory_order_relaxed)) {
        // 失败时 old 已被更新为当前值,直接重试
    }
}

3.2 ABA 问题

考虑一个无锁栈:线程 A 读到栈顶 p,准备 CAS 前被挂起;线程 B 弹出 p、弹出 p->next、再把 p 压回。A 恢复后 CAS 成功,但 p->next 已经指向了被弹出的节点——栈结构被破坏。

ABA 的本质:CAS 只比较「值」,无法区分「值相同但对象已换」。解法有三:

  1. Tagged Pointer:把版本号与指针打包进一个机器字,CAS 比较整个字。
  2. Hazard Pointer:让回收器知道某节点仍被引用,禁止复用地址。
  3. 不释放内存:用 GC 或 epoch 延迟回收,从根上消除地址复用。
// Tagged pointer:低 48 位存指针,高 16 位存版本号
struct TaggedPtr {
    uintptr_t raw; // [ version:16 | pointer:48 ]
    Node* ptr() const { return reinterpret_cast<Node*>(raw & 0x0000FFFFFFFFFFFFULL); }
    uint16_t tag() const { return static_cast<uint16_t>(raw >> 48); }
};
// 每次成功修改都递增 tag,ABA 时 tag 不同,CAS 自然失败

4. 无锁栈与无锁队列

4.1 Treiber 栈

Treiber 栈是最简单的无锁栈,push 用 CAS 换头,pop 用 CAS 换头并返回旧头:

template <typename T>
class TreiberStack {
    struct Node { T value; Node* next; };
    std::atomic<Node*> head_{nullptr};
public:
    void push(const T& v) {
        Node* n = new Node{v, nullptr};
        n->next = head_.load(std::memory_order_relaxed);
        while (!head_.compare_exchange_weak(n->next, n,
                                            std::memory_order_release,
                                            std::memory_order_relaxed)) {
            // n->next 被更新为最新 head,重试
        }
    }
    bool pop(T& out) {
        Node* h = head_.load(std::memory_order_acquire);
        while (h && !head_.compare_exchange_weak(h, h->next,
                                                 std::memory_order_acquire,
                                                 std::memory_order_relaxed)) {
        }
        if (!h) return false;
        out = h->value;
        delete h; // ⚠️ 危险:其他线程可能仍持有 h,ABA 就在这里
        return true;
    }
};

Treiber 栈的 delete h 是 ABA 的高发点,必须配合第 5 节的内存回收方案才能安全。

4.2 Michael-Scott 队列

Michael-Scott 队列用**哑节点(dummy node)**分离头尾,入队改 tail->next 再推进 tail,出队推进 head:

// 简化结构
struct Node { T value; std::atomic<Node*> next; };
std::atomic<Node*> head_, tail_;

void enqueue(const T& v) {
    Node* n = new Node{v, nullptr};
    while (true) {
        Node* last = tail_.load(std::memory_order_acquire);
        Node* next = last->next.load(std::memory_order_acquire);
        if (last != tail_.load(std::memory_order_acquire)) continue; // tail 已变
        if (next == nullptr) {
            if (last->next.compare_exchange_weak(next, n,
                    std::memory_order_release, std::memory_order_relaxed))
                break; // 挂接成功
        } else {
            // 帮助其他线程推进 tail(helping)
            tail_.compare_exchange_weak(last, next,
                    std::memory_order_release, std::memory_order_relaxed);
        }
    }
    tail_.compare_exchange_weak(/*last*/nullptr, n, std::memory_order_release);
}

关键设计是 helping:当发现 tail 落后时,任何线程都可帮它推进,从而保证整体无锁进度。

5. 安全内存回收

无锁结构的最大难点不是 CAS 循环,而是何时可以安全释放一个刚被摘除的节点。四种主流方案:

5.1 引用计数

每个节点维护原子引用计数,摘除时递减,归零才释放。

优点缺点
语义直观、可组合每次访问都要原子自增/自减
无全局同步无法处理环形引用
计数溢出风险

5.2 Hazard Pointer

每个线程公布自己正在访问的指针(hazard pointer)。回收线程扫描所有 hazard pointer,只有不被任何线程引用的节点才能释放。

// 线程 A:访问前先公布
hp[tid].store(node, std::memory_order_seq_cst);
// 重新校验 node 仍是 head(防止公布前已被摘除)
if (head_.load() != node) { /* 重试 */ }
// ... 使用 node ...
hp[tid].store(nullptr, std::memory_order_release); // 用完撤销

5.3 Epoch-Based Reclamation(EBR)

维护一个全局 epoch 计数器,每个线程声明自己处于哪个 epoch。线程内的延迟释放队列,只有在「所有线程都已越过该 epoch」时才真正回收。相比 hazard pointer,EBR 的读端开销极低(只读一个 epoch 变量),但需要线程周期性进入「静默态」(quiescent state)来推进 epoch。

5.4 RCU

读-复制-更新(Read-Copy-Update,RCU)是 EBR 的特化:读端零开销(甚至无需原子操作),写端复制出新版本、原子替换指针,再等待宽限期(grace period)后释放旧版本。

方案读端开销写端开销内存放大适用场景
引用计数高中低通用、无环结构
Hazard Pointer中高中无锁栈/队列
EBR低中中高高频读、低频写
RCU极低中高内核链表、路由表

Linux 内核的 list_for_each_entry_rcu 与 synchronize_rcu() 就是 RCU 的经典实现。

6. 无锁哈希表与动态扩容

数组与链表能无锁化,哈希表则要面对**扩容(resize)**这个全局操作。扩容期间旧桶到新桶的映射会变化,若用一把大锁保护整表,就退化成了有锁实现。

6.1 分段锁 vs 无锁

方案并发度扩容代价实现复杂度
单锁哈希表1全局停顿低
分段锁(ConcurrentHashMap 思路)段数逐段迁移中
无锁 + 跳表(Split-Ordered List)全并发增量迁移高

6.2 Split-Ordered List

Split-Ordered List 的核心技巧是把「桶下标 + 桶内序号」通过位反转(bit reversal)映射到一个递归可拆分的序上。这样新桶只会在已有桶的相邻位置插入,扩容时无需整体重建链表,只需把新增的哨兵节点用 CAS 挂进去:

// 递归拆分用的位反转哈希:保证新桶紧邻旧桶
static size_t reverse_bits(size_t x, int n) {
    size_t r = 0;
    for (int i = 0; i < n; ++i) {
        r = (r << 1) | (x & 1);
        x >>= 1;
    }
    return r;
}
// bucket = reverse_bits(hash, bits),bits 从 0 递增到 log2(capacity)

扩容线程只需按 bits 逐层插入新桶,读线程通过「先查新桶、未命中再查旧桶」也能正确命中,从而实现无停顿扩容。

6.3 内存序在扩容中的角色

扩容时新桶的初始化必须先于其被其他线程可见,否则读者可能读到「半初始化」的桶。标准做法是用 release 发布桶指针、acquire 读取桶指针,并在读者侧做双重检查(double-checked locking 的无锁版本)。

7. 活锁、饥饿与公平性

无锁只保证「系统整体有进展」,不保证「每个线程都有进展」。

  • 活锁(Livelock):多个线程 CAS 反复互相击败,CPU 空转。缓解手段是指数退避:失败后随机延时或让出 CPU。
  • 饥饿(Starvation):某线程长期 CAS 失败。严格的**无等待(Wait-Free)**算法能杜绝,但实现难度极高,实践中罕见。
  • 进度层级:阻塞(Blocking)< 无锁(Lock-Free)< 无等待(Wait-Free),无锁是工程上的性价比甜点。
// 指数退避:降低活锁概率
#include <random>
#include <thread>
void backoff(int& attempt) {
    if (++attempt < 8) {
        for (volatile int i = 0; i < (1 << attempt); ++i) { /* 自旋 */ }
    } else {
        std::this_thread::yield(); // 让出 CPU
    }
}

8. 实战建议与常见陷阱

  • 先测量再加锁改无锁:perf、mutex 争用计数、futex 唤醒次数都是判断依据。
  • 默认用 seq_cst,确认热点后再降级:内存序降级是纯性能优化,必须配合压力测试与 TSan/ASan。
  • 用工具而非肉眼:ThreadSanitizer 能抓数据竞争,Relacy、CDSChecker 可穷举内存序模型。
  • 警惕伪共享(False Sharing):两个原子变量落在同一 cache line 会互相失效,用 alignas(64) 填充。
  • 有界重试兜底:即使算法理论无锁,工程上仍应设置重试上限并降级到锁,避免活锁拖垮整机。
// 伪共享修复
struct alignas(64) PaddedCounter {
    std::atomic<long> value{0};
    char pad[64 - sizeof(std::atomic<long>)];
};

9. 小结

无锁数据结构的正确性由三根支柱共同支撑:原子操作保证单点不可分割,内存序保证跨变量的可见顺序,安全内存回收保证节点不会在使用中被复用。CAS 循环负责无阻塞推进,ABA 由 tagged pointer 或延迟回收消解,而回收方案的选择取决于读写比例与内存预算。它与https://plumephp.com/cs-concurrency-synchronization/中的锁原语互为补充,理解其底层还需回到https://plumephp.com/cs-cache-coherence/的缓存一致性协议。

参考文章

  • 内存分配与回收机制:https://plumephp.com/cs-memory-management/
  • 线程模型与调度:https://plumephp.com/cs-process-thread/

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 46. 排队论与容量估算:利特尔法则与尾延迟
  2. 45. 编译器优化与中间表示:SSA、内联与循环优化
  3. 44. 并发模型对比:Actor、CSP 与数据并行