无锁栈(lock-free stack,通常指 Treiber stack)是理解无锁编程的第一课:它只用一条 compare_exchange 循环就把 push 和 pop 写完了,全程没有一把锁。但「没有锁」不代表「没有坑」。最著名的 ABA 问题就藏在这里:一个节点的地址被别的线程弹出又塞回来后,CAS 会因为「值没变」而误判成功,悄无声息地丢掉节点。这篇把 Treiber 栈完整写出来真跑,讲透 ABA 的成因,再用 tagged pointer(带版本号的指针)破解它,最后用 TSan 验证并发下没有 data race。
1. 引子:一句 head->next 里藏着的并发语义
先看无锁栈的核心。它的数据结构极简。一个原子头指针 + 单向链表:
无锁栈(Treiber stack)的内存结构
head(std::atomic<Node*>)
│
▼
┌─────┐ ┌─────┐ ┌─────┐
│ B │───►│ A │───►│null │
└─────┘ └─────┘ └─────┘
栈顶 B,下面是 A,栈底 null
push(n):n->next = head; CAS(head, n) ← 把头指针换成新节点
pop() :读 head; CAS(head, head->next) ← 把头指针换成下一个
push 和 pop 各自只有一个「读头 → 改头」的动作,compare_exchange 保证这个动作是原子的:只有当「头的当前值」还等于你读到的那份时,才写入新值;否则失败重来。这一条原语就足以让整个栈在无锁下保持结构一致。
2. 最小实现:能真跑、TSan 干净的 Treiber 栈
下面是一个完整实现。为聚焦并发本身,节点用 std::vector 预分配、不做回收。这避开了「节点被释放后再被访问」的内存回收问题(那是 hazard pointer 的事,见 B9),也让 TSan 能干净地验证结构正确性:
// treiber_stack.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread treiber_stack.cpp -o ts
#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
struct Node {
int value;
Node* next{nullptr};
};
class TreiberStack {
public:
void push(Node* n) {
Node* head = head_.load(std::memory_order_relaxed);
do {
n->next = head; // 新节点指向旧栈顶
} while (!head_.compare_exchange_weak(head, n,
std::memory_order_release, std::memory_order_relaxed));
}
Node* pop() {
Node* head = head_.load(std::memory_order_relaxed);
while (head != nullptr &&
!head_.compare_exchange_weak(head, head->next,
std::memory_order_acquire, std::memory_order_relaxed)) {
}
return head; // nullptr 表示栈空
}
private:
std::atomic<Node*> head_{nullptr};
};
int main() {
constexpr int kNodes = 8;
constexpr int kThreads = 4;
std::vector<Node> pool(static_cast<std::size_t>(kNodes));
TreiberStack stack;
for (int i = 0; i < kNodes; ++i) { // 预先把全部节点压栈(单线程)
pool[static_cast<std::size_t>(i)].value = i;
stack.push(&pool[static_cast<std::size_t>(i)]);
}
std::atomic<int> count{0};
std::atomic<long long> sum{0};
std::vector<std::thread> workers;
for (int t = 0; t < kThreads; ++t) { // 4 线程并发 pop,直到栈空
workers.emplace_back([&] {
for (;;) {
Node* n = stack.pop();
if (n == nullptr) break;
count.fetch_add(1, std::memory_order_relaxed);
sum.fetch_add(n->value, std::memory_order_relaxed);
}
});
}
for (auto& t : workers) t.join();
std::printf("弹出总数 = %d(期望 %d)\n", count.load(), kNodes);
std::printf("值之和 = %lld(期望 %d)\n", sum.load(), kNodes * (kNodes - 1) / 2);
}
弹出总数 = 8(期望 8)
值之和 = 28(期望 28)
28 = 0+1+…+7,8 个节点一个没丢、一个没重。这段用 clang 19 配 -fsanitize=thread 真跑过,stderr 为空、没有 data race。因为并发阶段只有「读」和原子的 CAS,没有对同一内存的普通读写竞争。
两个容易漏的细节:
push里n->next = head写在 CAS 失败循环内,因为每次失败head都被compare_exchange_weak刷成了最新值,必须重新挂链。- 内存序是
release(push 发布)/acquire(pop 获取)配对,保证「拿到节点就能看到它的完整内容」。
官方文档:std::memory_order
3. ABA 问题:地址没变,世界已经变了
上面的例子是「先全部压栈、再全部弹出」,没有节点复用,所以一切正常。真正的坑出现在节点被弹出后又被塞回来(node reuse)的场景。看这个交错:
ABA 问题(无锁栈丢节点)—— 时间从上往下
初始栈:A -> B -> C (A 是栈顶)
线程 T1 线程 T2
──────────── ────────────
[1] 读 head = A
[2] 记录 A->next = B(准备 CAS 后让 head = B)
[3] pop:CAS(head A->B) 成功,拿走 A
[4] pop:CAS(head B->C) 成功,拿走 B
[5] push(A):A->next = C,CAS(head C->A) 成功
现在栈:A -> C
[6] T1 执行 CAS(head, 期望 A -> 实际 B)
但此刻 head 恰好又是 A!CAS 成功,head 变成 B
结果:栈变成了 B -> C,而 A 丢了(它还指着 C,成了游离节点)
────────────────────────────────────────────────
根因:T1 的 CAS 只比较「头指针的值」,而「头指针的值」从 A 变成了
B 又变回了 A —— 值相同,但中间已经发生了两次 pop + 一次 push。
这就是 ABA 问题:compare_exchange 只能判断「值是否还是我期望的那个」,无法判断「中间有没有变过又变回来」。当节点 A 的地址被复用后,头指针的值又回到了 A,T1 的 CAS 误以为「一切照旧」,把 head 设成了一个早已不在栈里的 B。于是 A 丢失,栈被破坏。
注意 ABA 和内存回收是两个不同的问题:ABA 是「逻辑上的误判」,导致丢节点;而「节点被释放后指针悬垂」是 use-after-free,需要 hazard pointer / epoch-based reclamation(B9)来兜。两者常被混为一谈,务必分开。
4. tagged pointer:给指针加上版本号
破解 ABA 的思路很直接:让 CAS 比较的不只是地址,还要带上一个每次修改都自增的版本号。这样「A 弹出又塞回」虽然地址还是 A,版本号却从 5 变成了 7,T1 拿旧版本 5 去 CAS 就会失败。
在 x86-64 上,用户态指针实际只用低 48 位,高 16 位可以白嫖来放版本号:这就是 tagged pointer(带标签的指针),把「指针 + 版本号」打包进一个 std::uintptr_t 里做原子 CAS:
// tagged_stack.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread tagged_stack.cpp -o tgs
#include <atomic>
#include <cstdint>
#include <cstdio>
struct Node { int value; Node* next{nullptr}; };
// x86-64 用户态指针只用低 48 位,高 16 位塞版本号
constexpr std::uintptr_t kPtrMask = (std::uintptr_t{1} << 48) - 1;
constexpr int kTagShift = 48;
void push(std::atomic<std::uintptr_t>& head, Node* n) {
std::uintptr_t h = head.load(std::memory_order_relaxed);
for (;;) {
n->next = reinterpret_cast<Node*>(h & kPtrMask);
const std::uintptr_t d = reinterpret_cast<std::uintptr_t>(n)
| (( (h >> kTagShift) + 1) << kTagShift);
if (head.compare_exchange_weak(h, d,
std::memory_order_release, std::memory_order_relaxed)) return;
}
}
Node* pop(std::atomic<std::uintptr_t>& head) {
std::uintptr_t h = head.load(std::memory_order_relaxed);
for (;;) {
Node* n = reinterpret_cast<Node*>(h & kPtrMask);
if (n == nullptr) return nullptr;
const std::uintptr_t d = reinterpret_cast<std::uintptr_t>(n->next)
| (( (h >> kTagShift) + 1) << kTagShift);
if (head.compare_exchange_weak(h, d,
std::memory_order_acquire, std::memory_order_relaxed)) return n;
}
}
int main() {
Node a{1}, b{2};
std::atomic<std::uintptr_t> head{0};
push(head, &a);
std::printf("push(a) 后 版本 = %llu\n", static_cast<unsigned long long>(head.load() >> kTagShift));
Node* x = pop(head);
std::printf("pop() 后 版本 = %llu(拿到 %d)\n",
static_cast<unsigned long long>(head.load() >> kTagShift), x->value);
push(head, x); // 同一个节点 a 的地址又回来了,但版本号已从 1 变到 3
std::printf("push(a) 回 版本 = %llu\n", static_cast<unsigned long long>(head.load() >> kTagShift));
push(head, &b);
std::printf("push(b) 后 版本 = %llu\n", static_cast<unsigned long long>(head.load() >> kTagShift));
}
push(a) 后 版本 = 1
pop() 后 版本 = 2(拿到 1)
push(a) 回 版本 = 3
push(b) 后 版本 = 4
每次 push/pop 都把版本号 +1,所以同一个地址出现两次,版本号一定不同。回到第 3 节的交错:T1 在第 [1] 步读到的「A + 版本 5」,等它第 [6] 步去 CAS 时,栈顶已经是「A + 版本 7」,compare_exchange 发现版本号对不上,失败重读。A 不会再丢了。
需要说明的是:这个实现把版本号塞进指针高位,依赖 x86-64 的 48 位地址约定(AArch64 有 top-byte-ignore 也类似,但不通用)。跨平台要可靠,得用 128 位的双字 CAS(cmpxchg16b / lock cmpxchg16b,对应 GCC/Clang 的 __int128 原子),或者干脆用现成库。
5. compare_exchange_weak vs strong,以及「无锁 vs 有锁」的取舍
compare_exchange 有两个变体,区别只有一个,是否允许「伪失败(spurious failure)」:
| 变体 | 伪失败 | 适用场合 | 底层差异 |
|---|---|---|---|
compare_exchange_weak | 可能(即使值相等也返回 false) | 写在循环里,失败重试即可 | 某些平台少一条指令,更快 |
compare_exchange_strong | 不会 | 只试一次、或循环体很贵不宜重试 | 内部会处理伪失败 |
无锁栈的 push/pop 都在 while/for 循环里,所以一律用 weak。
至于「无锁一定比有锁快吗」,结论务实:不一定。无锁栈在低到中等竞争下往往比 std::mutex + std::stack 快(避免内核态 futex 与上下文切换),但在高竞争下,CAS 的失败重试(缓存行乒乓)也会剧烈退化。真实项目里,无锁栈的价值是免死锁、免优先级反转、在中断/信号上下文里也能用,而不单纯是快。
| 维度 | 无锁栈(Treiber) | 有锁栈(mutex + stack) |
|---|---|---|
| 竞争低时 | 快,纯用户态 CAS | 加锁解锁有固定开销 |
| 竞争高时 | CAS 失败重试,缓存行乒乓 | 失败者进内核挂起,唤醒有上下文切换 |
| 死锁 / 优先级反转 | 无(无锁) | 可能(持锁线程被抢占) |
| 实现复杂度 | 高(ABA、内存回收都是坑) | 低,std::stack 直接套 |
| 正确性验证 | 要 TSan + 模型检查 | 锁本身提供保证,好推理 |
6. 完整示例:多生产者 + 多消费者的 tagged 无锁栈
把 tagged pointer 和多生产者/多消费者串起来:4 个生产者各压 4 个互不重复的节点,4 个消费者再并发弹出全部 16 个,验证总和确定、无丢失:
// treiber_final.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread treiber_final.cpp -o tf
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <thread>
#include <vector>
struct Node { int value; Node* next{nullptr}; };
constexpr std::uintptr_t kPtrMask = (std::uintptr_t{1} << 48) - 1;
constexpr int kTagShift = 48;
class TaggedStack {
public:
void push(Node* n) {
std::uintptr_t h = head_.load(std::memory_order_relaxed);
for (;;) {
n->next = reinterpret_cast<Node*>(h & kPtrMask);
const std::uintptr_t d = reinterpret_cast<std::uintptr_t>(n)
| (( (h >> kTagShift) + 1) << kTagShift);
if (head_.compare_exchange_weak(h, d,
std::memory_order_release, std::memory_order_relaxed)) return;
}
}
Node* pop() {
std::uintptr_t h = head_.load(std::memory_order_relaxed);
for (;;) {
Node* n = reinterpret_cast<Node*>(h & kPtrMask);
if (n == nullptr) return nullptr;
const std::uintptr_t d = reinterpret_cast<std::uintptr_t>(n->next)
| (( (h >> kTagShift) + 1) << kTagShift);
if (head_.compare_exchange_weak(h, d,
std::memory_order_acquire, std::memory_order_relaxed)) return n;
}
}
private:
std::atomic<std::uintptr_t> head_{0};
};
int main() {
constexpr int kProducers = 4;
constexpr int kPerProducer = 4; // 共 16 个节点,值 0..15
constexpr int kConsumers = 4;
std::vector<Node> pool(static_cast<std::size_t>(kProducers * kPerProducer));
TaggedStack stack;
std::vector<std::thread> producers;
for (int t = 0; t < kProducers; ++t) { // 多生产者并发压栈
producers.emplace_back([&, t] {
for (int i = 0; i < kPerProducer; ++i) {
std::size_t idx = static_cast<std::size_t>(t * kPerProducer + i);
pool[idx].value = t * kPerProducer + i;
stack.push(&pool[idx]);
}
});
}
for (auto& t : producers) t.join();
std::atomic<int> count{0};
std::atomic<long long> sum{0};
std::vector<std::thread> consumers;
for (int t = 0; t < kConsumers; ++t) { // 多消费者并发弹出
consumers.emplace_back([&] {
for (;;) {
Node* n = stack.pop();
if (n == nullptr) break;
count.fetch_add(1, std::memory_order_relaxed);
sum.fetch_add(n->value, std::memory_order_relaxed);
}
});
}
for (auto& t : consumers) t.join();
constexpr int kTotal = kProducers * kPerProducer;
std::printf("弹出总数 = %d(期望 %d)\n", count.load(), kTotal);
std::printf("值之和 = %lld(期望 %d)\n", sum.load(), kTotal * (kTotal - 1) / 2);
}
弹出总数 = 16(期望 16)
值之和 = 120(期望 120)
120 = 0+1+…+15。这个版本在 clang 19 的 TSan 下同样没有 data race(节点只被压入一次、弹出一次,next 字段在并发阶段只读不写)。
最后必须强调:这里的节点都是预分配、永不回收的。真正的生产级无锁栈,节点要在 pop 后被释放,这时「读 head->next 的瞬间节点可能正被别的线程释放」的 use-after-free 问题就冒出来了,需要 hazard pointer 或 epoch-based reclamation(B9)配合。所以务实的建议是:要无锁栈,用现成库(Boost.Lockfree 的 boost::lockfree::stack、liblfds、concurrentqueue),它们把这些坑都踩平了;自己手写只适合学习和验证理解。
7. 延伸阅读
- std::atomic::compare_exchange — cppreference —— CAS 语义、
weak/strong与内存序参数 - std::memory_order — cppreference —— release/acquire 配对的正式定义
- std::atomic — cppreference —— 原子类型总览与 lock-free 相关说明
- std::uintptr_t — cppreference —— tagged pointer 打包时用到的整数类型
- C++ Core Guidelines · 并发章节 CP.1/CP.8 —— 无锁编程的规范性建议与陷阱提醒
本知识库内的相关篇目:
- 《无锁内存回收:hazard pointer 与 epoch-based reclamation 原理》 —— 无锁栈 pop 出来的节点为什么不能立刻 delete?因为另一个线程可能还攥着它的指针。
- 《无锁队列(MPSC/MPMC):Vyukov 环形队列与 slot 未就绪问题》 —— 队列比栈难,因为它有两个端点——头尾都要协调。
- 《锁进阶:atomic_flag 自旋锁、shared_mutex 读写锁与无锁的代价》 —— std::mutex 一竞争就把失败线程挂进内核(futex)
8. 一句话总结
无锁栈靠一条 compare_exchange_weak 循环就实现无锁的 push/pop,但 compare_exchange 只比较「值」、不比较「中间有没有变过又变回来」——节点地址被复用后会产生 ABA 问题,CAS 误判成功而丢节点;破解办法是 tagged pointer,把版本号塞进指针高位、每次修改自增,让「同地址重现」也能被识别出来;再往深走还有节点回收的 use-after-free(要 hazard pointer,见 B9),所以生产级别请直接用现成库。
291



被折叠的 条评论
为什么被折叠?



