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++ 里也能用了。它的核心思想是:读操作不阻塞,写操作先复制再更新

举个例子:你有一个全局链表,多个线程在读。现在你想删除一个节点。怎么做?

  1. 先把要删除的节点从链表里摘下来(用原子操作更新指针)
  2. 等待所有正在读的线程完成(等待宽限期结束)
  3. 真正删除节点

这里的关键是「宽限期」(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 读多写少,线程稳定

六、知识体系图

下面这张图展示了这四种技术的关系和适用场景:

高级内存管理技术体系 线程安全内存回收 内存池 (TLS) Hazard Pointer RCU 机制 Epoch-Based 特点 • 预分配,减少malloc • TLS减少锁竞争 • 适合高频分配/释放 特点 • 保护正在使用的指针 • 回收前需扫描 • 适合小对象集合 特点 • 读操作无锁 • 写操作复制更新 • 需等待宽限期 特点 • 按纪元分组回收 • 批量回收效率高 • 依赖线程活跃度 选择建议 读极多写极少 → RCU 读多写少,对象少 → Hazard Pointer 通用高频分配 → 内存池 读多写少,线程稳定 → EBR

七、实战建议

最后,我给大家几个实战中的建议:

  • 不要盲目追求无锁:如果锁的竞争不激烈,用 std::mutex 就够了。无锁代码的调试成本很高。
  • 先测量,再优化:用 perf 或者 TSan 看看瓶颈到底在哪。我见过有人花两周优化一个根本不热的内存分配路径。
  • 考虑混合方案:比如用内存池管理小对象,用 RCU 管理大对象。没有银弹。
  • 注意内存序:无锁编程里,memory_order 用错了就是 bug。我建议先用 seq_cst 保证正确,再逐步优化。

最后提醒: 这些高级内存管理技术,说白了都是在「正确性」和「性能」之间做 trade-off。如果你不确定某个场景该用哪种,先从最简单的 std::shared_ptr 开始。性能不够了再上这些黑科技。我曾经在项目里一上来就用 Hazard Pointer,结果代码复杂到没人敢改。后来换成 RCU,反而更稳定。

好了,这一章的内容就到这里。记住:工具是死的,场景是活的。多实践,多踩坑,你才能真正掌握这些技术。


公众号:蓝海资料掘金营,微信deep3321