无锁数据结构里最反直觉的一件事:你从无锁栈 pop() 拿到的节点,不能随手 delete 掉。因为你没法保证没有别的线程此刻正握着这个节点的指针、马上就要解引用它。这一篇讲清楚无锁环境下的内存回收为什么难、有哪三种主流思路,每种都给一个能跑的简化实现,最后落到一句话:生产级直接上现成库。
1. 引子:pop 出来的节点凭什么不能 delete
先看一个「看起来正确、实则危险」的无锁栈骨架。它的 push/pop 用 CAS 维护头指针,正确性没问题,但 pop() 里那句 delete 是颗雷:
// 反例,不要这么写:pop 后直接 delete 节点,别的线程可能还在用它
template <typename T>
struct UnsafeStack {
struct Node {
T value;
Node* next; // 反例:裸指针,只为演示内存回收问题
};
std::atomic<Node*> head_{nullptr};
void push(T v) {
Node* n = new Node{v, head_.load()};
while (!head_.compare_exchange_weak(n->next, n)) { }
}
bool pop(T& out) {
Node* old = head_.load();
while (old && !head_.compare_exchange_weak(old, old->next)) { }
if (!old) return false;
out = old->value;
delete old; // ← 危险:old 可能正被另一个线程读取
return true;
}
};
危险来自下面这种交错:
线程 A 和线程 B 同时操作无锁栈,A pop 出的节点被 B 读取
时间 ──────────────────────────────────────────────────────────►
线程 A load head → old(=N1) ── CAS 成功,head 指向 N2 ── delete N1 ──►
线程 B load head → 拿到 N1 的指针 ── 解引用 N1->value ──► ✗ use-after-free
问题:B 在「load 到 N1」之后、「解引用」之前,A 已经 delete 了 N1。
所以「节点不在结构里了」≠「节点没人在用了」。
这就是无锁内存回收的核心矛盾:结构上的移除(逻辑删除)和物理上的释放(delete)之间,隔着一段「可能还有人引用」的窗口。有锁的世界里,锁天然保证了「拿到锁 = 别人进不来 = 没人再用」,所以 delete 是安全的;无锁的世界里没有这层保护,必须自己造一套机制。
2. 三种回收思路总览
解决这个矛盾,工业界有三条主流路线,本质都是「延迟释放 + 判定无人引用」:
| 方案 | 核心思想 | 读侧开销 | 适用场景 |
|---|---|---|---|
| 引用计数 | 每个节点带引用计数,没人引用才释放 | 一次原子自增/自减 | 通用,实现简单,开销略高 |
| hazard pointer | 读者声明「我正在用这几个指针」,回收者只释放没人声明的 | 一次原子写(声明) | 读多写少,节点数有限 |
| epoch-based reclamation | 读者只登记自己「处于第几个 epoch」,回收者等所有读者都走过静止点再释放 | 极低(一次 store) | 读侧极频繁、对延迟最敏感(RCU 思路) |
下面逐个给简化实现。先声明:这三段的代码都是「讲原理」的最小实现,管理结构里有意用了锁来简化正确性;真正的无锁内存回收器(folly::hazptr、libcds、Linux 内核的 RCU)把「声明」「退休」「回收」全做成了无锁,并处理了 ABA、内存序等细节,工程里不要自己重造。
3. 方案一:引用计数(shared_ptr + 原子操作)
最简单也最通用的办法:让节点自带引用计数,谁拿到指针谁 +1,用完 -1,归零自动释放。C++ 里现成就有 std::shared_ptr,而 std::atomic_load / std::atomic_store / std::atomic_compare_exchange_weak 对 std::shared_ptr 的重载(<memory> 提供,C++11 起就有)能让我们把它塞进无锁结构里,还不产生 data race:
// rc_stack.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread rc_stack.cpp -o rc_stack
#include <atomic>
#include <cstdio>
#include <memory>
#include <thread>
#include <vector>
template <typename T>
class RcStack {
struct Node {
T value;
std::shared_ptr<Node> next;
};
std::shared_ptr<Node> head_; // 头节点,shared_ptr 自动管引用计数
public:
void push(T v) {
auto n = std::make_shared<Node>();
n->value = v;
n->next = std::atomic_load(&head_);
while (!std::atomic_compare_exchange_weak(&head_, &n->next, n)) { }
}
bool pop(T& out) {
auto old = std::atomic_load(&head_);
while (old && !std::atomic_compare_exchange_weak(&head_, &old, old->next)) { }
if (!old) return false;
out = old->value; // old 是 shared_ptr,此刻引用计数 ≥1,节点不会被释放
return true;
}
};
int main() {
RcStack<int> s;
std::vector<std::thread> ws;
for (int t = 0; t < 4; ++t) {
ws.emplace_back([&s, t] {
for (int k = 0; k < 2500; ++k) s.push(t * 2500 + k);
});
}
for (auto& w : ws) w.join();
int got = 0;
int v;
while (s.pop(v)) ++got;
std::printf("pushed=10000 popped=%d\n", got);
}
pushed=10000 popped=10000
关键在 pop() 里的局部变量 old 是 shared_ptr:CAS 失败也好、成功也罢,只要 old 这个局部 shared_ptr 还活着,它指向的节点引用计数就 ≥1,别的线程无论怎么折腾都 delete 不掉它。等 pop 返回、old 析构,引用计数归零,节点才被自动释放。这就是「用引用计数把 use-after-free 堵死」。引用计数就是最朴素的「延迟释放 + 无人引用判定」。
用 ThreadSanitizer(clang 19)复验一遍,确认这套共享指针的原子操作没有 data race:
$ clang++ -std=c++17 -O1 -fsanitize=thread -g -pthread rc_stack.cpp -o rc_tsan && ./rc_tsan
pushed=10000 popped=10000
(stderr 无任何 ThreadSanitizer 报告 —— 说明没有 data race)
代价也摆在那里:每次 push/pop 都伴随一次原子引用计数的加减(shared_ptr 的控制块操作),在高竞争下比纯裸指针慢。所以它适合「正确性优先、竞争不极端」的场景。
官方文档:std::atomic_load / atomic_store / atomic_compare_exchange(shared_ptr 重载) · std::shared_ptr
4. 方案二:hazard pointer(声明「我在用」)
引用计数每次访问都要动引用计数,读侧不便宜。hazard pointer 换个思路:读者在读之前,把「我正握着的指针」写进一个全局可见的 hazard 槽;回收者要释放某个节点前,先扫一遍所有 hazard 槽,只有没人声明的节点才 delete。读侧只需一次 store,回收的代价被摊到回收者身上。
下面是简化实现,hazard 槽用原子变量,退休列表用锁保护:
// hazptr_demo.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread hazptr_demo.cpp -o hazptr
#include <atomic>
#include <cstdio>
#include <mutex>
#include <vector>
// 简化 hazard pointer:hazard 槽是原子的,退休列表用锁保护
// (生产级如 folly::hazptr 两者都无锁;这里用锁只为把机制讲清楚)
class MiniHazptr {
public:
static constexpr int kReaders = 2;
std::atomic<int*> slots_[kReaders]{}; // 每个读者一个 hazard 槽
std::atomic<int> reclaimed_{0};
private:
std::mutex mu_;
std::vector<int*> retired_;
public:
void acquire(int reader, int* p) { slots_[reader].store(p); }
void release(int reader) { slots_[reader].store(nullptr); }
void retire(int* p) {
std::lock_guard<std::mutex> lock(mu_);
retired_.push_back(p);
}
bool is_protected(int* p) const {
for (int r = 0; r < kReaders; ++r) {
if (slots_[r].load() == p) return true;
}
return false;
}
void reclaim() {
std::lock_guard<std::mutex> lock(mu_);
for (auto it = retired_.begin(); it != retired_.end();) {
if (is_protected(*it)) {
++it; // 有人正在用,跳过
} else {
delete *it; // 演示内存回收机制,此处故意裸 delete
reclaimed_.fetch_add(1);
it = retired_.erase(it);
}
}
}
};
int main() {
MiniHazptr hp;
int* a = new int(1); // 演示内存回收机制,故意裸 new(真实代码交给现成库)
int* b = new int(2);
hp.acquire(0, a); // 读者 0 声明「正在用 a」
hp.retire(a); // 回收者退休 a
hp.retire(b); // 退休 b
hp.reclaim(); // a 被保护不删,只删 b
std::printf("第一次 reclaim:回收 %d 个(b),a 仍在用=%d\n",
hp.reclaimed_.load(), *a);
hp.release(0); // 读者 0 用完 a
hp.reclaim(); // 现在 a 才被删
std::printf("第二次 reclaim:累计回收 %d 个\n", hp.reclaimed_.load());
}
第一次 reclaim:回收 1 个(b),a 仍在用=1
第二次 reclaim:累计回收 2 个
两次 reclaim() 的结果正好说明机制:第一次 a 在 hazard 槽里、被跳过;等读者 release 之后,a 才被回收。时序是这样的:
hazard pointer 的「声明 → 退休 → 回收」时序
读者 0 acquire(a) ──────────── release(a) ─────────────────►
回收者 retire(a) ── reclaim() reclaim() ──►
│(扫 hazard: │(a 已不在
│ 槽里有 a,跳过) │ 任何槽里)
▼ ▼
a 保留不删 delete a
b 从未被声明 → 第一次 reclaim 就直接 delete b
hazard pointer 的局限也很明确:读者必须显式、成对地 acquire/release,漏写一次就是 use-after-free;而且一个读者同时最多能用多少个节点,就得准备多少个 hazard 槽。这些正是生产库要替你兜住的地方。
官方文档:std::atomic::store(hazard 槽的原子写) · C++ Core Guidelines CP(并发章节)
5. 方案三:epoch-based reclamation(读侧免锁)
hazard pointer 要求读者「精确声明每个指针」,epoch-based 更粗放:读者只登记「我正处于第几个 epoch」,用完清零;回收者想释放某批节点时,先把全局 epoch 推进一位,然后确认所有读者都已经走过旧的 epoch(静止点,quiescent state),这时退休于旧 epoch 的节点就能安全释放。读侧只是两次 store,几乎没有竞争 —— 这正是 Linux 内核 RCU(read-copy-update)读侧免锁的简化版思路。
// epoch_demo.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread epoch_demo.cpp -o epoch
#include <atomic>
#include <cstdio>
#include <mutex>
#include <vector>
// 简化 epoch-based reclamation(QSBR 风格):读侧只登记 epoch,免锁
class MiniEpoch {
public:
static constexpr int kReaders = 2;
std::atomic<unsigned long> global_epoch_{1};
std::atomic<unsigned long> reader_epoch_[kReaders]{}; // 0 = 空闲
std::atomic<int> reclaimed_{0};
private:
std::mutex mu_;
std::vector<int*> retired_;
public:
void enter(int reader) { reader_epoch_[reader].store(global_epoch_.load()); }
void exit(int reader) { reader_epoch_[reader].store(0); }
bool all_idle() const {
for (int r = 0; r < kReaders; ++r) {
if (reader_epoch_[r].load() != 0) return false;
}
return true;
}
void retire(int* p) {
std::lock_guard<std::mutex> lock(mu_);
retired_.push_back(p);
}
// 回收者:等所有读者穿过静止点,推进 epoch,再释放旧 epoch 退休的节点
void advance_and_reclaim() {
while (!all_idle()) { } // 简化:忙等静止点
const unsigned long old = global_epoch_.load();
global_epoch_.store(old + 1); // 推进 epoch
std::lock_guard<std::mutex> lock(mu_);
for (auto it = retired_.begin(); it != retired_.end();) {
delete *it; // 演示内存回收机制,此处故意裸 delete
reclaimed_.fetch_add(1);
it = retired_.erase(it);
}
}
};
int main() {
MiniEpoch e;
int* a = new int(1); // 演示内存回收机制,故意裸 new
e.enter(0); // 读者 0 进入读临界区
e.retire(a); // 退休 a
std::printf("读者在读时能回收吗 = %d\n", static_cast<int>(e.all_idle()));
e.exit(0); // 读者 0 退出,到达静止点
e.advance_and_reclaim(); // 推进 epoch 并回收
std::printf("读者退出后累计回收 = %d,新 epoch = %lu\n",
e.reclaimed_.load(), e.global_epoch_.load());
}
读者在读时能回收吗 = 0
读者退出后累计回收 = 1,新 epoch = 2
注意 enter/exit 只是两次对原子变量的 store,读者之间互不干扰 —— 这就是「读侧免锁」的含义。代价转移到回收者:它必须等所有读者都走到静止点,任何一个读者卡在临界区里,回收就整体被拖住。所以 epoch-based 适合「读极多、写极少、读者绝不在临界区里阻塞」的场景(比如路由表、配置的热更新)。
6. 完整示例:引用计数的多生产者多消费者无锁栈
把最实用的引用计数方案做成完整版:4 个生产者各 push 一批,4 个消费者并发 pop,用「总和守恒」验证每个值恰好被取走一次、没有丢也没有重复:
// rc_stack_full.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread rc_stack_full.cpp -o rcfull
#include <atomic>
#include <cstdio>
#include <memory>
#include <thread>
#include <vector>
template <typename T>
class RcStack {
struct Node {
T value;
std::shared_ptr<Node> next;
};
std::shared_ptr<Node> head_;
public:
void push(T v) {
auto n = std::make_shared<Node>();
n->value = v;
n->next = std::atomic_load(&head_);
while (!std::atomic_compare_exchange_weak(&head_, &n->next, n)) { }
}
bool pop(T& out) {
auto old = std::atomic_load(&head_);
while (old && !std::atomic_compare_exchange_weak(&head_, &old, old->next)) { }
if (!old) return false;
out = old->value;
return true;
}
};
int main() {
constexpr int kProducers = 4;
constexpr int kConsumers = 4;
constexpr int kPerProducer = 2500;
constexpr int kTotal = kProducers * kPerProducer;
RcStack<int> s;
std::vector<std::thread> producers;
for (int p = 0; p < kProducers; ++p) {
producers.emplace_back([&s] {
for (int k = 1; k <= kPerProducer; ++k) s.push(k); // 每个生产者 push 1..2500
});
}
for (auto& t : producers) t.join();
std::atomic<long long> sum{0};
std::atomic<int> count{0};
std::vector<std::thread> consumers;
for (int c = 0; c < kConsumers; ++c) {
consumers.emplace_back([&s, &sum, &count] {
int v;
while (s.pop(v)) {
sum.fetch_add(v, std::memory_order_relaxed);
count.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& t : consumers) t.join();
const long long expected = static_cast<long long>(kProducers) * kPerProducer * (kPerProducer + 1) / 2;
std::printf("pop 总数 = %d(期望 %d)\n", count.load(), kTotal);
std::printf("pop 总和 = %lld(期望 %lld)\n", sum.load(), expected);
}
pop 总数 = 10000(期望 10000)
pop 总和 = 12505000(期望 12505000)
两个数字都精确对得上,说明在引用计数保护下,多生产者多消费者各自并发地 CAS 头指针,没有丢节点、没有重复取、也没有一次 use-after-free。这正是引用计数作为「最省心的内存回收」的底气。工程上,std::atomic<std::shared_ptr<T>> 在 C++20 里把上面那三个自由函数收编成了成员函数,写起来更顺手;但在 C++17 环境下,自由函数版本就是标准写法。
7. 延伸阅读
- std::atomic_load / atomic_store / atomic_compare_exchange_weak(shared_ptr 特化)— cppreference —— 无锁结构里安全操作
shared_ptr的官方入口,这篇示例 1 和示例 6 就靠它 - std::shared_ptr — cppreference —— 引用计数与
make_shared控制块分配,理解「引用计数回收」的基础 - std::memory_order — cppreference —— 无锁代码里每个原子操作都要显式定序,这是内存序的权威参考
- C++ Core Guidelines · 并发章节 — isocpp —— 无锁/内存回收属于高风险代码,指南明确要求「先证明必要,再写」
- Compiler Explorer — godbolt.org —— 把上面的无锁栈贴进去开
-O2,看atomic_load/compare_exchange到底生成了什么指令
本知识库内的相关篇目:
- 《无锁栈:Treiber 栈的 CAS、ABA 问题与 tagged pointer》 —— 无锁栈(Treiber stack)是理解无锁编程的入门课
- 《无锁队列(MPSC/MPMC):Vyukov 环形队列与 slot 未就绪问题》 —— 队列比栈难,因为它有两个端点——头尾都要协调。
- 《锁进阶:atomic_flag 自旋锁、shared_mutex 读写锁与无锁的代价》 —— std::mutex 一竞争就把失败线程挂进内核(futex)
8. 一句话总结
无锁结构里「从结构移除节点」和「物理 delete 节点」之间隔着一段无人知道的引用窗口,所以不能直接 delete;三种回收思路本质都是「延迟释放 + 判定无人引用」。引用计数靠 shared_ptr 最省心、hazard pointer 靠读者声明「我在用」省读侧开销、epoch-based 靠读者登记 epoch 做到读侧免锁;这三段的代码都是讲原理的最小实现,生产级请直接用 folly::hazptr、libcds 或内核 RCU 这类经过验证的现成库,别自己重造。
429



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



