C++ 无锁数据结构:栈、队列与安全内存回收

互斥锁在高竞争下会引发上下文切换与优先级反转,无锁数据结构用 CAS 等原子原语把同步下沉到硬件层面。本文从原子原语与 CAS 循环讲起,完整实现无锁栈与 Michael-Scott 队列,剖析 ABA 问题的成因与带标记指针的解法,重点讨论无锁编程真正的难点——内存回收,对比 hazard pointer、RCU 与 epoch-based reclamation 三种方案,给出内存序选型清单与压测验证方法。

当临界区极短、竞争又极其激烈时,std::mutex 的开销往往超过它保护的那几行代码本身:一次未争用的加解锁约 20 纳秒,而一次争用导致的线程挂起与唤醒可能耗费数微秒。无锁(lock-free)数据结构把「谁先拿到锁」的仲裁下沉到 CPU 的原子指令,用 CAS 循环取代阻塞等待。代价是复杂度陡增——ABA、内存回收、内存序,每一个都能让一个看似正确的实现随机崩溃。本文在 https://plumephp.com/cpp-atomic-memory-order/ 的内存序基础之上,聚焦数据结构实现与工程落地。

一、无锁编程的基础与代价

1.1 三种进度保证

并发算法的进度保证由弱到强分为三级,务必先分清:

级别含义典型实现
阻塞(blocking)持锁线程挂起会阻塞其他线程std::mutex
无锁(lock-free)任意时刻至少有一个线程能推进CAS 循环
无等待(wait-free)每个线程都能在有限步内完成部分计数器、SPSC 队列

「无锁」并不等于「更快」,它保证的是系统整体不会因为某个线程被调度器暂停而停滞,这对实时系统与高优先级线程有实质意义。

1.2 硬件原语

所有无锁结构最终都构建在少数几条原子指令上:

#include <atomic>
#include <cstdint>

std::atomic<int> counter{0};
// 1. 读-改-写:fetch_add 返回旧值
int old = counter.fetch_add(1, std::memory_order_relaxed);

// 2. 比较并交换:仅当当前值等于 expected 时写入 desired
int expected = 5;
bool ok = counter.compare_exchange_strong(
    expected, 6, std::memory_order_acq_rel);

// 3. 弱版本:可能伪失败,适合放在循环里
int exp2 = 5;
while (!counter.compare_exchange_weak(exp2, 7,
        std::memory_order_acq_rel, std::memory_order_relaxed)) {
    // 失败时 exp2 已被更新为当前值,可直接重试
}

在 x86 上,compare_exchange 编译为 lock cmpxchg,天然带全屏障;在 ARM/POWER 等弱内存模型上则会展开为 ldaxr/stlxr 循环,必须显式提供内存序。compare_exchange_weak 允许「伪失败」(值其实相等却返回 false),但在重试循环中它往往生成更优代码,因为无需内层循环。

二、无锁栈的实现

2.1 push 与 pop 的 CAS 循环

无锁栈是最简单的无锁结构,本质是「用 CAS 更新头指针」:

template <typename T>
class LockFreeStack {
    struct Node { T value; Node* next; };
    std::atomic<Node*> head_{nullptr};

public:
    void push(T value) {
        Node* n = new Node{std::move(value), nullptr};
        n->next = head_.load(std::memory_order_relaxed);
        while (!head_.compare_exchange_weak(      // 失败则用新 head_ 重试
                   n->next, n, std::memory_order_release,
                   std::memory_order_relaxed)) {}
    }

    bool pop(T& out) {
        Node* old = head_.load(std::memory_order_acquire);
        while (old && !head_.compare_exchange_weak(
                          old, old->next, std::memory_order_acquire,
                          std::memory_order_relaxed)) {}
        if (!old) return false;
        out = std::move(old->value);
        // 危险:old 可能已被重新推入(ABA),直接 delete 会 use-after-free
        // delete old;
        return true;
    }
};

注意 push 用 release、pop 用 acquire:前者保证节点内容在链接前对其他线程可见,后者保证读到节点后能看到其内容。这是最经典的 release/acquire 配对。

2.2 ABA 问题

上面的 pop 有两个致命缺陷。第一个是 ABA 问题:线程 1 读到 head_ = A,被调度器挂起;线程 2 弹出 A、再压入 B、又弹出 B、重新压入 A;线程 1 恢复后 CAS 成功,但此时 A->next 已被改写,栈结构被破坏。

解法是为指针附加上单调递增的版本号,使「值相同」不再等同于「状态相同」:

struct TaggedPtr {
    std::uintptr_t ptr = 0;
    std::uint64_t  tag = 0;     // 单调递增版本号
};

// 把 ptr 与 tag 打包进 128 位,用 16 字节 CAS(x86-64 支持 cmpxchg16b)
class AtomicTaggedPtr {
    std::atomic<TaggedPtr> v_{TaggedPtr{}};
public:
    bool cas(TaggedPtr& expected, TaggedPtr desired) {
        return v_.compare_exchange_weak(expected, desired,
                                        std::memory_order_acq_rel);
    }
    TaggedPtr load() const { return v_.load(std::memory_order_acquire); }
    void store(TaggedPtr p) { v_.store(p, std::memory_order_release); }
};

注意 16 字节原子的 compare_exchange 在部分平台上并非无锁,编译器会退化为内部加锁,此时应当检测:

static_assert(std::atomic<TaggedPtr>::is_always_lock_free,
              "16 字节 CAS 在该平台非无锁,需改用 hazard pointer 方案");

三、无锁队列

3.1 Michael-Scott 队列

MS 队列是教科书级的 MPMC(多生产者多消费者)无锁队列,用两个原子指针 head 与 tail,配合「哑结点(dummy node)」简化边界处理:

#include <atomic>
#include <utility>

template <typename T>
class MSQueue {
    struct Node {
        T value;
        std::atomic<Node*> next{nullptr};
    };
    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;

public:
    MSQueue() {
        Node* dummy = new Node{};
        head_.store(dummy, std::memory_order_relaxed);
        tail_.store(dummy, std::memory_order_relaxed);
    }

    void enqueue(T value) {
        Node* n = new Node{std::move(value)};
        Node* tail = tail_.load(std::memory_order_relaxed);
        while (true) {
            Node* next = tail->next.load(std::memory_order_acquire);
            if (next == nullptr) {
                if (tail->next.compare_exchange_weak(   // 链接到尾部
                        next, n, std::memory_order_release,
                        std::memory_order_relaxed)) break;
            } else {
                tail_.compare_exchange_weak(            // tail 落后,帮忙推进
                    tail, next, std::memory_order_release,
                    std::memory_order_relaxed);
            }
        }
        tail_.compare_exchange_weak(n, n, std::memory_order_release,
                                    std::memory_order_relaxed);
    }

    bool dequeue(T& out) {
        Node* head = head_.load(std::memory_order_relaxed);
        while (true) {
            Node* tail = tail_.load(std::memory_order_relaxed);
            Node* next = head->next.load(std::memory_order_acquire);
            if (next == nullptr) return false;          // 空队列
            if (head == tail) {                          // tail 落后,帮忙推进
                tail_.compare_exchange_weak(
                    tail, next, std::memory_order_release,
                    std::memory_order_relaxed);
            } else {
                out = std::move(next->value);
                if (head_.compare_exchange_weak(         // 摘除哑结点
                        head, next, std::memory_order_release,
                        std::memory_order_relaxed)) return true;
            }
        }
    }
};

MS 队列的核心思想是 helping:当发现 tail 指针落后时,任何线程都可以帮忙把它推进,从而避免某个被挂起的线程拖住整个队列。代价是平均需要约 2 次 CAS 才能完成一次入队。

3.2 SPSC 环形缓冲区

如果生产者与消费者各只有一个,可以用无锁环形缓冲区,它不需要 CAS,仅靠 load/store 与内存序即可实现无等待:

#include <atomic>
#include <array>

template <typename T, std::size_t N>
class SpscRing {
    static_assert((N & (N - 1)) == 0, "容量必须是 2 的幂");
    std::array<T, N> buf_;
    std::atomic<std::size_t> head_{0};   // 消费者位置
    std::atomic<std::size_t> tail_{0};   // 生产者位置
public:
    bool push(const T& v) {
        std::size_t t = tail_.load(std::memory_order_relaxed);
        std::size_t next = (t + 1) & (N - 1);
        if (next == head_.load(std::memory_order_acquire))
            return false;                  // 满
        buf_[t] = v;
        tail_.store(next, std::memory_order_release);  // 发布
        return true;
    }

    bool pop(T& out) {
        std::size_t h = head_.load(std::memory_order_relaxed);
        if (h == tail_.load(std::memory_order_acquire))
            return false;                  // 空
        out = buf_[h];
        head_.store((h + 1) & (N - 1), std::memory_order_release);
        return true;
    }
};

SPSC 环形队列在实践中用途极广:音频处理、日志采集、高频交易的行情分发,几乎都用它。它也是少数真正「无等待」的结构之一。

四、安全内存回收

4.1 真正的难点

上面所有 delete 都被刻意省略了。原因是:当线程 A 刚从队列摘下节点、尚未释放时,线程 B 可能仍持有指向该节点的指针正在读取。判断「还有没有别人在用这个节点」是无锁编程最难的部分。三种主流方案对比如下:

方案思路回收延迟每操作开销代表库
Hazard Pointer线程声明正在读的指针立即可回收每次读要发布 + 扫描folly、libcds
RCU读者零开销,写者延迟回收宽限期后读者零,写者高Linux 内核、liburcu
Epoch-Based全局 epoch 计数 + 退役链表跨过 epoch 后很低crossbeam、folly

4.2 Hazard Pointer 最小实现

Hazard pointer 的核心是:每个线程有一个公开的「危险指针」槽位,声明自己正在访问哪个节点;回收者遍历所有槽位,只释放无人声明的节点。

template <typename T>
class HazardPointer {
    static constexpr int MAX_THREADS = 64;
    static constexpr int MAX_HAZARDS = 2;   // 每线程最多声明 2 个
    std::atomic<T*> hazards_[MAX_THREADS][MAX_HAZARDS];
    std::mutex retire_mtx_;
    std::vector<T*> retired_;

public:
    void protect(int tid, int slot, T* p) {
        hazards_[tid][slot].store(p, std::memory_order_seq_cst);
    }
    void clear(int tid, int slot) {
        hazards_[tid][slot].store(nullptr, std::memory_order_release);
    }
    // 回收时判断 p 是否被任何线程声明
    bool is_hazardous(T* p) {
        for (int t = 0; t < MAX_THREADS; ++t)
            for (int s = 0; s < MAX_HAZARDS; ++s)
                if (hazards_[t][s].load(std::memory_order_seq_cst) == p)
                    return true;
        return false;
    }
    void retire(T* p) {
        std::lock_guard<std::mutex> lk(retire_mtx_);
        retired_.push_back(p);
        for (auto it = retired_.begin(); it != retired_.end(); ) {  // 尝试回收
            if (!is_hazardous(*it)) { delete *it; it = retired_.erase(it); }
            else ++it;
        }
    }
};

生产级实现(如 folly 的 HazptrDomain)会用批量回收、按域划分、以及「危险指针阈值」来降低扫描成本,但骨架思想与上面一致。

4.3 Epoch-Based Reclamation 简介

Epoch 方案把所有线程划分为「活跃(active)」与「静止(quiescent)」两种状态,并维护一个全局 epoch 计数器。节点退役时挂到当前 epoch 的链表上;当所有线程都跨过至少一个 epoch 后,旧 epoch 的链表即可整体释放。它比 hazard pointer 的每次读开销更低,但要求线程周期性进入静止态,否则会导致内存无限堆积。

五、内存序选型与压测验证

5.1 内存序选择清单

无锁结构的正确性一半取决于内存序。经验法则:

  • 发布数据(写指针后让别人看到内容):release
  • 订阅数据(读指针后访问内容):acquire
  • 纯计数、无数据关联:relaxed
  • CAS 需要同时同步两侧:acq_rel
  • 实在不确定:先用 seq_cst 保证正确,再用 perf 与压力测试逐步放宽
// 反例:用 relaxed 发布节点,其他线程可能读到未初始化的 value
node->next.store(old, std::memory_order_relaxed);
head_.store(node, std::memory_order_relaxed);   // 错误:无发布语义

// 正确:store-release 建立 happens-before
head_.store(node, std::memory_order_release);

5.2 压测与验证方法

无锁 bug 的特征是「低概率、依赖调度、只在特定 CPU 上复现」,因此必须用专门的验证手段:

  • 高并发压测:线程数远超核心数(如 8 核跑 64 线程)制造频繁抢占
  • TSan(ThreadSanitizer):-fsanitize=thread 能捕获大部分数据竞争,但对无锁代码会报告「benign race」,需用 __tsan_acquire/__tsan_release 注解
  • 长时间运行:至少跑 10^8 次操作,配合 taskset 绑定 CPU 制造争用
  • 不变量检查:每 N 次操作后单线程遍历结构,校验节点数、无环、无重复
  • CPU 架构差异:x86 的强内存模型会掩盖 bug,务必在 ARM 机器或 QEMU 上验证
// 压测骨架:多生产者多消费者 + 计数校验
void stress_test(MSQueue<int>& q, int producers, int consumers) {
    std::atomic<long> produced{0}, consumed{0};
    constexpr int PER_THREAD = 1'000'000;
    std::vector<std::thread> ts;
    for (int i = 0; i < producers; ++i)
        ts.emplace_back([&] { for (int k = 0; k < PER_THREAD; ++k)
            { q.enqueue(k); produced.fetch_add(1); } });
    for (int i = 0; i < consumers; ++i)
        ts.emplace_back([&] { int v;
            while (consumed.load() < (long)producers * PER_THREAD)
                if (q.dequeue(v)) consumed.fetch_add(1); });
    for (auto& t : ts) t.join();
    assert(produced.load() == consumed.load());
}

六、工程建议与陷阱

无锁数据结构是把双刃剑,落地前请权衡以下几点:

  • 先测量再决定:多数场景下,std::mutex 配合短临界区已经足够快;无锁只在 profile 证明锁是瓶颈时才值得引入
  • 优先用成熟库:boost::lockfree、folly::ProducerConsumerQueue、moodycamel::ConcurrentQueue 都经过大规模生产验证,自研风险极高
  • ABA 与回收是两大雷区:没有版本号或安全回收方案的无锁结构,几乎必然在某次线上高并发时崩溃
  • 弱内存模型平台必须实测:x86 上正确的代码在 ARM 上可能因缺少屏障而失效
  • 不要在无锁结构里做重活:CAS 循环内只做指针操作,任何可能阻塞或耗时的调用都会放大重试成本

与 https://plumephp.com/cpp-concurrency-patterns/ 中的线程池结合时,SPSC 环形队列常被用作任务提交队列,既避免了锁,又天然契合「单生产者提交、单消费者执行」的模型。

相关阅读

  • https://plumephp.com/cpp-atomic-memory-order/ — 内存序模型、happens-before 与 ABA 问题的基础
  • https://plumephp.com/cpp-concurrency-patterns/ — 线程池、future 与异步任务调度模式
  • https://plumephp.com/cpp-threading-basics/ — 线程生命周期与 mutex/条件变量的正确用法

延伸阅读

  • https://plumephp.com/posts/os/ — 内核中的 RCU、自旋锁与调度器如何影响无锁代码
  • https://plumephp.com/posts/hpc/ — 大规模并行计算中的无锁通信与原子操作实践

文末完整示例

// 完整可运行示例:无等待 SPSC 环形队列 + 并发校验
// 编译:g++ -std=c++20 -O2 -pthread -o lockfree_demo lockfree_demo.cpp

#include <atomic>
#include <array>
#include <thread>
#include <iostream>

template <typename T, std::size_t N>
class SpscRing {
    static_assert((N & (N - 1)) == 0, "容量必须是 2 的幂");
    std::array<T, N> buf_{};
    std::atomic<std::size_t> head_{0};
    std::atomic<std::size_t> tail_{0};
public:
    bool push(const T& v) {
        std::size_t t = tail_.load(std::memory_order_relaxed);
        std::size_t next = (t + 1) & (N - 1);
        if (next == head_.load(std::memory_order_acquire)) return false;
        buf_[t] = v;
        tail_.store(next, std::memory_order_release);
        return true;
    }
    bool pop(T& out) {
        std::size_t h = head_.load(std::memory_order_relaxed);
        if (h == tail_.load(std::memory_order_acquire)) return false;
        out = buf_[h];
        head_.store((h + 1) & (N - 1), std::memory_order_release);
        return true;
    }
};

int main() {
    SpscRing<int, 1024> ring;
    constexpr int TOTAL = 1'000'000;
    long sum = 0;

    std::thread producer([&] {
        for (int i = 1; i <= TOTAL; )
            if (ring.push(i)) ++i;
    });
    std::thread consumer([&] {
        int v, got = 0;
        while (got < TOTAL)
            if (ring.pop(v)) { sum += v; ++got; }
    });
    producer.join();
    consumer.join();
    long expect = (long)TOTAL * (TOTAL + 1) / 2;
    std::cout << "SPSC 校验:sum=" << sum << " 期望=" << expect
              << (sum == expect ? " OK" : " FAIL") << std::endl;
    return 0;
}

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cpp」更多文章

  1. C++ 移动语义与完美转发:从右值引用到引用折叠
  2. C++ 模糊测试与覆盖率:libFuzzer、AFL++ 与 Sanitizer
  3. C++ 序列化库选型实战:从 JSON 到 FlatBuffers