一、并发内存管理的核心挑战

并发编程里,内存管理是最让人头疼的部分之一。我刚开始接触多线程项目时,就踩过一个大坑——线程A刚释放了一块内存,线程B还在读它,结果程序直接崩溃。这种问题,说白了就是「读写冲突」。

为什么会这样?因为多个线程共享同一块内存,而谁先谁后你根本控制不了。你想想看,一个线程在释放内存,另一个线程还在用这块内存的指针,这不就出事了?

解决这个问题,业界主要有四条路:

  • 线程局部存储(TLS)——每个线程管自己的,互不干扰
  • 无锁内存分配——用原子操作代替锁,减少等待
  • Hazard Pointer——标记正在使用的指针,延迟回收
  • Epoch Based Reclamation(EBR)——分代回收,批量处理

这四种方案各有适用场景。我个人习惯是:读多写少用Hazard Pointer,写多读少用EBR,追求极致性能用无锁分配器,线程私有数据用TLS

核心原则:并发内存管理的本质,就是「谁在用,谁负责」。要么让每个线程管好自己的内存,要么让使用中的内存不被误释放。

并发内存管理 线程局部存储 无锁内存分配 Hazard Pointer Epoch Reclamation 每个线程独立内存池 无锁、无竞争 CAS原子操作 避免锁等待 标记活跃指针 延迟回收 分代批量回收 全局同步点 选型建议 • 读多写少 → Hazard Pointer • 写多读少 → Epoch Based Reclamation • 极致性能 → 无锁分配器 + TLS

二、线程局部存储(TLS)

TLS的思路很简单:每个线程分配自己的内存池,互不干扰。这样就不需要加锁了,因为根本没有共享数据。

在C++11里,用thread_local关键字就能声明。我最早接触这个特性是在做游戏服务器时,每个连接线程需要维护自己的缓冲区,用TLS就特别合适。

// 线程局部存储示例
thread_local std::vector<char> tls_buffer(1024);

void worker_thread(int id) {
    // 每个线程都有自己的buffer,互不干扰
    tls_buffer[0] = 'A' + id;
    // 处理数据...
}

我的经验:TLS适合存储「每个线程独有」的数据,比如线程ID、临时缓冲区、日志上下文。但要注意,TLS变量不能跨线程访问,否则就失去了意义。

三、无锁内存分配

无锁分配的核心思想,是用原子操作(CAS)代替互斥锁。这样线程就不会因为等待锁而阻塞,吞吐量能提升不少。

我记得有一次优化一个高频交易系统,原来的锁分配器成了瓶颈。换成无锁分配器后,延迟从微秒级降到了纳秒级。嗯,效果确实明显。

// 简单的无锁内存池(Freelist)
struct Node {
    Node* next;
    // 实际数据...
};

std::atomic<Node*> head{nullptr};

Node* allocate() {
    Node* old_head = head.load();
    while (true) {
        Node* new_head = old_head ? old_head->next : nullptr;
        if (head.compare_exchange_weak(old_head, new_head)) {
            return old_head;
        }
        // CAS失败则重试
    }
}

注意:无锁编程最怕ABA问题。简单说就是:线程A读到指针P,然后P被释放又分配,地址还是P,但内容已经变了。解决方法是加版本号或使用Hazard Pointer。

四、Hazard Pointer

Hazard Pointer的思路是:每个线程声明自己「正在使用哪些指针」。回收内存时,先检查有没有线程在用,没人用了再释放。

我曾经在一个分布式存储项目中用过这个方案。当时需要频繁地读取共享数据结构,写操作很少。用Hazard Pointer后,读操作几乎零开销,写操作稍微慢一点,但整体性能提升了3倍。

// Hazard Pointer 核心逻辑(伪代码)
thread_local HazardPointer hp;

void read_data() {
    // 声明当前指针为危险指针
    hp.protect(shared_ptr);
    // 安全地读取数据
    Data* data = shared_ptr.load();
    // 使用data...
    // 解除保护
    hp.unprotect();
}

void write_data(Data* new_data) {
    Data* old = shared_ptr.exchange(new_data);
    // 等待所有线程解除对old的保护
    retire(old);  // 延迟回收
}

关键点:Hazard Pointer适合「读多写少」的场景。读操作只需要一个原子存储,写操作需要扫描所有线程的Hazard Pointer列表,开销较大。

五、Epoch Based Reclamation(EBR)

EBR是另一种思路:把时间分成一个个「纪元」(epoch)。所有线程都同意进入下一个纪元后,上一个纪元的内存就可以安全回收了。

我参与过一个内存数据库项目,用的就是EBR。它的好处是批量回收,效率高。但有个缺点:如果某个线程卡住了,整个回收过程都得等它。

// EBR 核心逻辑(伪代码)
std::atomic<int> global_epoch{0};
thread_local int local_epoch{0};

void enter_critical() {
    local_epoch = global_epoch.load();
    // 通知全局:我进入了当前纪元
}

void exit_critical() {
    local_epoch = 0;
    // 通知全局:我退出了
}

void reclaim() {
    // 等待所有线程离开旧纪元
    // 然后安全回收旧纪元的内存
}
方案 适用场景 优点 缺点
TLS 线程私有数据 零竞争,简单 不能跨线程
无锁分配 高频分配/释放 低延迟,高吞吐 ABA问题,实现复杂
Hazard Pointer 读多写少 读操作几乎无开销 写操作需扫描列表
EBR 写多读少 批量回收,效率高 依赖线程同步

六、避坑指南

我曾经在一个项目中混合使用Hazard Pointer和EBR,结果出了大问题。两个方案的内存回收策略冲突,导致内存泄漏。后来我学乖了:一个项目只选一种方案,不要混用

还有一次,我在无锁分配器里忘了处理ABA问题,结果线上服务时不时崩溃。排查了三天才找到原因。从那以后,我写无锁代码一定会加版本号。

我的建议:如果你刚开始接触并发内存管理,先从TLS入手。它最简单,也最不容易出错。等你对并发模型熟悉了,再尝试Hazard Pointer或EBR。

最后说一句:并发编程没有银弹。每种方案都有trade-off,选型时要结合你的实际场景。多测试,多压测,才能找到最适合的方案。


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