不能直接用 std::atomic 操作链表节点指针,因其仅保证指针读写原子性,不保证“读→解引用→改next→写回”整套操作原子性,易引发ABA问题和use-after-free;需结合版本号与延迟回收机制。

为什么不能直接用 std::atomic<t></t> 操作链表节点指针
因为 std::atomic<t></t> 只保证指针本身的读写原子性,不保证「读指针 → 解引用 → 修改 next 字段 → 写回」这一整套操作的原子性。典型问题出现在 pop():你读到 head,准备把它摘下来,但另一线程可能刚把新节点插入到它前面,此时你 CAS 更新 head 会失败——这本身没问题;但更危险的是,若你已解引用了旧 head 并准备 delete 它,而它其实在被其他线程当作中间节点使用(比如正在被 push() 链入),就会造成 use-after-free。
根本矛盾在于:无锁结构必须避免 ABA 问题,且内存回收不能依赖引用计数或垃圾收集器。C++11 起,std::atomic<t></t> 不提供内置的 ABA 防御机制,必须手动扩展指针位宽(如用 std::atomic<uint64_t></uint64_t> 打包指针+版本号)或借助 std::atomic<:shared_ptr></:shared_ptr>(但后者有性能开销且非真正无锁)。
用 std::atomic<uint64_t></uint64_t> 实现带版本号的原子指针
将指针低 48 位存地址、高 16 位存版本号(假设指针是 48 位有效地址,常见于 x86_64 Linux 的用户空间),每次 CAS 前递增版本号,即可规避 ABA。注意:不能直接对裸指针做位运算,需先转为 uintptr_t。
- 定义节点结构时,
next字段类型必须是std::atomic<uint64_t></uint64_t>,而非Node* - 读取 next 指针:用
next.load(std::memory_order_acquire),再用位掩码提取地址(如ptr & 0x0000FFFFFFFFFFFFULL) - CAS 更新 next:构造新值
(new_ptr & mask) | ((old_version + 1) ,再调用 <code>next.compare_exchange_weak() - 务必使用
std::memory_order_acq_rel或更强序,尤其在push()的 CAS 处,否则其他线程可能看到部分初始化的节点
示例片段:
立即学习“C++免费学习笔记(深入)”;
struct Node {
int data;
std::atomic<uint64_t> next{0};
<pre class="brush:php;toolbar:false;">Node(int d) : data(d) {}};
struct LockFreeStack {
std::atomic
void push(Node* node) {
uint64_t old = head.load(std::memory_order_acquire);
do {
node->next.store(old, std::memory_order_relaxed);
// 提取当前 head 地址和版本
uintptr_t ptr = old & 0x0000FFFFFFFFFFFFULL;
uint16_t ver = (old >> 48) & 0xFFFF;
uint64_t desired = (reinterpret_cast<uintptr_t>(node) & 0x0000FFFFFFFFFFFFULL)
| (static_cast<uint64_t>(ver + 1) << 48);
if (head.compare_exchange_weak(old, desired, std::memory_order_acq_rel))
break;
} while (true);
}};
pop() 必须分离「摘链」和「释放内存」两个阶段
这是最容易出错的地方。无锁结构中,一个节点可能被多个线程同时观察到(比如正被遍历),因此不能在 pop() 中直接 delete。必须引入延迟回收机制,例如:Hazard Pointer 或 RCU。对于简单场景,可采用「惰性删除 + 周期性扫描」:每个线程维护本地待回收列表,pop() 仅把节点加入该列表;另起后台线程定期检查是否所有线程都已不再访问这些节点,再统一 delete。
- 切勿在
pop()的 CAS 成功后立刻delete node—— 此时别的线程可能刚读到该节点地址,正准备访问其next - 如果不用 Hazard Pointer,至少应让
pop()返回std::unique_ptr<node></node>,把内存管理责任交给调用方,明确语义边界 - 即使使用
std::shared_ptr管理节点,也要注意:shared_ptr的控制块本身不是无锁的,use_count的修改仍涉及锁,不符合“无锁”定义
单向链表的无锁 find() 和遍历为何天然安全
因为只读操作不修改结构,只要节点一旦被 push() 成功链入,其 next 字段就不会再被原线程改写(后续修改只发生在新插入节点上)。所以从 head 开始逐个读 next 是安全的,无需额外同步。
- 遍历时用
std::memory_order_acquire读每个节点的next,确保看到该节点完整初始化的数据(如data字段) - 但要注意:遍历结果不具快照一致性 —— 中间可能有节点被其他线程
pop()掉,你仍会读到它的data,只是下一步next会是 null 或跳到别处 - 如果业务需要强一致性遍历(比如统计长度),就必须加锁,或者改用带标记删除的双链表 + 垃圾收集方案
真正的难点不在链表逻辑本身,而在内存生命周期管理。很多人卡在 CAS 成功后删节点,却忘了其他线程栈上可能还存着那个地址的拷贝。


















