29、高级内存管理:内存池的线程安全实现、Hazard Pointer详解、RCU机制、Epoch-Based Reclamation
各位同学,今天我们来聊聊并发编程里最头疼的问题之一——内存回收。
你想想看,在多线程环境下,一个线程正在读某个对象,另一个线程却把它删了。程序直接崩溃。这问题我早年做游戏服务器时遇到过不下十次。嗯,今天要讲的这四种技术,就是专门解决这类问题的。
一、内存池的线程安全实现
先说说内存池。说白了,就是预先分配一大块内存,然后自己管理分配和释放。为什么需要它?因为频繁调用 malloc/free 太慢了,而且容易产生内存碎片。
线程安全的内存池,核心思路是「无锁化」。我个人的习惯是使用 TLS(线程本地存储) 加 全局缓存 的组合方案。
核心设计:
- 每个线程有自己的小缓存(TLS),分配时优先从本地拿
- 本地不够了,从全局大池子里批量取
- 释放时,先放回本地,本地满了再还给全局池
// 简化的线程安全内存池
class ThreadSafePool {
static constexpr size_t BLOCK_SIZE = 64; // 每个块64字节
static constexpr size_t LOCAL_CACHE = 32; // 本地缓存32个块
struct alignas(64) ThreadCache {
void* blocks[LOCAL_CACHE];
size_t count = 0;
};
std::atomic<void*> global_head_{nullptr};
// 使用TLS,每个线程独立
static thread_local ThreadCache t_cache_;
public:
void* allocate() {
if (t_cache_.count > 0) {
return t_cache_.blocks[--t_cache_.count];
}
// 从全局池批量取
return refill_from_global();
}
void deallocate(void* ptr) {
if (t_cache_.count < LOCAL_CACHE) {
t_cache_.blocks[t_cache_.count++] = ptr;
} else {
return_to_global(ptr);
}
}
};
避坑指南: 我曾经在项目中把 BLOCK_SIZE 设成 32 字节,结果发现大量对象需要 40 字节。每个对象都跨了两个块,性能直接腰斩。记住:块大小一定要对齐到 cache line(通常 64 字节)。
二、Hazard Pointer 详解
Hazard Pointer 是什么?我打个比方:你正在读一本书,这时候有人想把它拿走。你举着「危险!正在使用」的牌子。别人看到这个牌子,就不敢动这本书。
在 C++ 里,每个线程维护一个 Hazard Pointer 列表,记录当前正在访问的共享对象地址。回收线程在删除对象前,必须检查所有 Hazard Pointer,确认没人用才能删。
// Hazard Pointer 核心逻辑
class HazardPointer {
std::atomic<void*> hp_{nullptr}; // 当前保护的指针
public:
void protect(void* ptr) {
hp_.store(ptr, std::memory_order_release);
}
void clear() {
hp_.store(nullptr, std::memory_order_relaxed);
}
bool is_protected(void* ptr) const {
return hp_.load(std::memory_order_acquire) == ptr;
}
};
// 回收线程检查所有Hazard Pointer
void retire_node(Node* node) {
// 检查所有线程的Hazard Pointer
for (auto& hp : all_hazard_pointers) {
if (hp.is_protected(node)) {
// 还有人用,先留着
node->retired_list.push(node);
return;
}
}
// 没人用,直接删
delete node;
}
注意: Hazard Pointer 有个明显的缺点——如果线程崩溃了,它持有的 Hazard Pointer 永远不会被清除。我见过线上事故,就是因为一个线程异常退出,导致大量对象无法回收,内存暴涨。解决方案是加超时机制,或者用 RAII 包装器自动清理。
三、RCU(Read-Copy-Update)机制
RCU 是 Linux 内核里用的技术,现在 C++ 里也能用了。它的核心思想是:读操作不阻塞,写操作先复制再更新。
举个例子:你有一个全局链表,多个线程在读。现在你想删除一个节点。怎么做?
- 先把要删除的节点从链表里摘下来(用原子操作更新指针)
- 等待所有正在读的线程完成(等待宽限期结束)
- 真正删除节点
这里的关键是「宽限期」(Grace Period)。怎么知道所有读线程都结束了?
// 简化的RCU实现
class RCU {
std::atomic<int> reader_count_{0};
public:
void read_lock() {
reader_count_.fetch_add(1, std::memory_order_acquire);
// 内存屏障,确保后续读操作在计数器增加之后
std::atomic_thread_fence(std::memory_order_seq_cst);
}
void read_unlock() {
std::atomic_thread_fence(std::memory_order_seq_cst);
reader_count_.fetch_sub(1, std::memory_order_release);
}
void synchronize() {
// 等待所有读者完成
while (reader_count_.load(std::memory_order_acquire) > 0) {
std::this_thread::yield(); // 让出CPU
}
}
};
RCU 的适用场景:
- 读多写少(比如路由表、配置信息)
- 读操作非常频繁,写操作很少
- 对延迟敏感,不能容忍锁竞争
我记得有一次做网络框架,路由表更新频率很低(几分钟一次),但查询频率极高(每秒百万次)。用 RCU 后,读操作几乎零开销,写操作虽然慢一点(需要等待宽限期),但完全能接受。
四、Epoch-Based Reclamation(EBR)
EBR 是另一种延迟回收机制。它把时间分成一个个「纪元」(Epoch)。每个线程记录自己当前所在的纪元。回收对象时,只回收那些「所有线程都已经离开的纪元」里的对象。
说白了,就是:等所有人都过了那个时间点,再清理那个时间点之前的东西。
// EBR 核心逻辑
class EBR {
static constexpr int MAX_THREADS = 64;
std::atomic<int> global_epoch_{0};
std::array<std::atomic<int>, MAX_THREADS> thread_epochs_;
public:
void enter_critical_section(int thread_id) {
thread_epochs_[thread_id].store(
global_epoch_.load(std::memory_order_relaxed),
std::memory_order_release
);
}
void leave_critical_section(int thread_id) {
thread_epochs_[thread_id].store(-1, std::memory_order_release);
}
void retire_object(void* obj, int epoch) {
// 检查是否所有线程都离开了这个epoch
for (auto& te : thread_epochs_) {
int e = te.load(std::memory_order_acquire);
if (e <= epoch && e != -1) {
// 还有线程在这个epoch里,先留着
pending_list_[epoch].push_back(obj);
return;
}
}
// 可以安全回收
delete obj;
}
};
个人经验: EBR 比 Hazard Pointer 性能更好,因为不需要逐个检查指针。但它的缺点是:如果某个线程卡住了(比如死循环),所有回收都会被阻塞。我曾经在调试时遇到一个线程在 sleep,导致其他线程的内存回收全部暂停。解决方案是加超时检测,或者用混合方案(EBR + 备用回收机制)。
五、四种技术对比
| 技术 | 读开销 | 写开销 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| 内存池(TLS) | 极低 | 低 | 中 | 通用内存分配 |
| Hazard Pointer | 低 | 高(需扫描) | 低 | 读多写少,对象数量少 |
| RCU | 极低 | 中(需等待) | 低 | 读极多写极少 |
| EBR | 低 | 中 | 中 | 读多写少,线程稳定 |
六、知识体系图
下面这张图展示了这四种技术的关系和适用场景:
七、实战建议
最后,我给大家几个实战中的建议:
- 不要盲目追求无锁:如果锁的竞争不激烈,用
std::mutex就够了。无锁代码的调试成本很高。 - 先测量,再优化:用 perf 或者 TSan 看看瓶颈到底在哪。我见过有人花两周优化一个根本不热的内存分配路径。
- 考虑混合方案:比如用内存池管理小对象,用 RCU 管理大对象。没有银弹。
- 注意内存序:无锁编程里,
memory_order用错了就是 bug。我建议先用seq_cst保证正确,再逐步优化。
最后提醒: 这些高级内存管理技术,说白了都是在「正确性」和「性能」之间做 trade-off。如果你不确定某个场景该用哪种,先从最简单的 std::shared_ptr 开始。性能不够了再上这些黑科技。我曾经在项目里一上来就用 Hazard Pointer,结果代码复杂到没人敢改。后来换成 RCU,反而更稳定。
好了,这一章的内容就到这里。记住:工具是死的,场景是活的。多实践,多踩坑,你才能真正掌握这些技术。
公众号:蓝海资料掘金营,微信deep3321