无锁单向链表在c++中难以简单实现,因需解决aba问题、内存重排序和内存释放时机三大难题;仅用std::atomic和compare_exchange_weak无法保证线程安全与正确性。

为什么无锁单向链表在 C++ 里很难“简单”实现
直接说结论:std::atomic + compare_exchange_weak 可以构建无锁逻辑,但“线程安全”和“无锁”不等于“简单可靠”。真正可用的无锁链表必须处理 ABA 问题、内存重排序、内存释放时机这三座大山。很多初学者写的“无锁链表”在高并发下会崩溃或漏节点,不是因为代码没编译,而是因为 delete 了还在被其他线程读取的节点。
插入操作必须用 compare_exchange_weak 循环重试
头插是最容易实现的无锁操作,但不能直接赋值 head = new_node —— 这不是原子的,且会覆盖其他线程刚写入的更新。必须用 CAS(Compare-And-Swap)保证原子性。
关键点:
-
next字段必须是std::atomic<node></node>,不能是裸指针 - 每次读
head后立即保存快照,再构造新节点,最后 CAS 尝试更新;失败就重读重试 - CAS 失败不等于失败,只是有竞争,要继续循环,不是抛异常或 return
struct Node {
int data;
std::atomic<node> next{nullptr};
};
<p>std::atomic<node> head{nullptr};</node></p>
<p>void push(int val) {
Node<em> node = new Node{val, nullptr};
Node</em> expected;
do {
expected = head.load();
node->next.store(expected);
} while (!head.compare_exchange_weak(expected, node));
}</p></node>
删除操作无法回避 ABA 问题,裸指针 CAS 会出错
假设线程 A 读到 head == X,准备删 X;此时线程 B 把 X 删掉、又新建一个内容相同的节点 Y 并插入头,地址恰好复用了 X(常见于 malloc/free 或 slab 分配器)。线程 A 的 CAS 会成功,但它删掉的是 Y 而不是原来的 X —— 逻辑错误已发生。
解决路径只有两条:
- 用带版本号的指针(如
std::atomic<uintptr_t></uintptr_t>存 “地址+计数”,高位存引用计数或序列号) - 用 Hazard Pointer 或 RCU 等内存回收机制,确保被读取的节点不会被 delete(比 CAS 更重,但更可靠)
- 放弃“完全无锁删除”,改用细粒度互斥锁保护删除路径(实际项目中更常见)
别信网上那些只用 compare_exchange_weak 实现删除的“无锁链表”示例——它们在真实负载下大概率漏删、重复删或访问野指针。
析构与内存释放是最大陷阱,delete 不能随便调
无锁结构里没有“谁负责释放”的中心权威。一个节点可能正被多个线程通过 next 指针遍历,此时调 delete 就是未定义行为。标准做法是:所有读线程先声明自己正在访问哪些节点(hazard pointer),写线程只回收那些“没人声明在用”的节点。
这意味着你至少要引入:
- 每个线程一个
hazard_ptr数组(通常长度为 2~4) - 全局待回收链表(
retired_list) - 周期性扫描:检查所有 hazard pointer,把没被标记的 retired 节点真正
delete
这个逻辑远超“简单链表”范畴。如果只是想避免锁开销,std::shared_ptr 配合 std::atomic 是更务实的选择,靠引用计数延迟释放,但要注意循环引用风险。
真正落地时,绝大多数场景下,用 std::mutex 保护整个链表(或分段锁)反而更安全、更易验证、性能差距也没想象中大——尤其当链表本身不常被高频增删时。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











