6、线程安全内存池:互斥锁(Mutex)保护、读写锁优化、无锁(Lock-Free)队列实现

多线程环境下,内存池的线程安全是个绕不开的坎儿。

我刚开始做高并发服务时,觉得内存池嘛,加个锁不就完了?结果线上压测,性能直接腰斩。嗯,这里面的门道,比想象中要多。

6.1 为什么需要线程安全?

单线程内存池,说白了就是一块私有的自留地。但多线程一来,多个线程同时申请释放内存,就会出大问题——数据竞争、内存损坏、甚至直接崩溃。

你想想看,两个线程同时从空闲链表里取节点,如果没有保护,链表指针会被改得乱七八糟。我曾在项目中见过这种bug,排查了整整两天,最后发现是内存池没加锁。

核心矛盾: 性能 vs 安全。锁加多了,性能下降;锁加少了,数据不安全。

6.2 互斥锁(Mutex)保护——最直接的方式

最简单的做法,就是在内存池的分配和释放函数里,加上一把大锁。

// 互斥锁保护的内存池
typedef struct {
    void *pool_start;
    size_t block_size;
    int total_blocks;
    // 空闲链表头
    void *free_list;
    // 互斥锁
    pthread_mutex_t mutex;
} MutexPool;

void *mutex_pool_alloc(MutexPool *pool) {
    pthread_mutex_lock(&pool->mutex);
    if (pool->free_list == NULL) {
        pthread_mutex_unlock(&pool->mutex);
        return NULL;
    }
    void *ptr = pool->free_list;
    pool->free_list = *(void **)ptr;
    pthread_mutex_unlock(&pool->mutex);
    return ptr;
}

void mutex_pool_free(MutexPool *pool, void *ptr) {
    pthread_mutex_lock(&pool->mutex);
    *(void **)ptr = pool->free_list;
    pool->free_list = ptr;
    pthread_mutex_unlock(&pool->mutex);
}

这段代码逻辑很简单,但问题也很明显:锁的粒度太粗了。每次分配释放都要加锁解锁,线程多了以后,锁竞争会非常激烈。

我的经验: 如果线程数不超过4个,且分配频率不高,互斥锁方案完全够用。别过度设计。

6.3 读写锁优化——读多写少的场景

有些场景下,分配操作远多于释放操作。比如网络服务器,大量分配小包,但释放是异步的。这时候,读写锁就派上用场了。

// 读写锁优化的内存池
typedef struct {
    // ... 其他成员
    pthread_rwlock_t rwlock;
} RWLockPool;

void *rwlock_pool_alloc(RWLockPool *pool) {
    pthread_rwlock_rdlock(&pool->rwlock);
    // 分配逻辑(只读操作)
    void *ptr = pool->free_list;
    if (ptr) {
        pool->free_list = *(void **)ptr;
    }
    pthread_rwlock_unlock(&pool->rwlock);
    return ptr;
}

void rwlock_pool_free(RWLockPool *pool, void *ptr) {
    pthread_rwlock_wrlock(&pool->rwlock);
    // 释放逻辑(写操作)
    *(void **)ptr = pool->free_list;
    pool->free_list = ptr;
    pthread_rwlock_unlock(&pool->rwlock);
}

这里有个坑:读写锁不是银弹。如果写操作频繁,读写锁的性能甚至不如互斥锁。因为写锁会阻塞所有读锁,而且读写锁本身的实现比互斥锁更重。

避坑指南: 我曾经在一个日志系统中使用读写锁,结果写日志线程一多,读线程全被阻塞了。后来改成无锁队列,问题才解决。

6.4 无锁(Lock-Free)队列——高性能的终极方案

无锁编程,说白了就是利用CPU的原子指令(CAS、FAA等)来实现线程安全,不加锁。这玩意儿性能极高,但实现起来也最复杂。

我常用的无锁内存池结构是这样的:

// 无锁内存池(基于CAS)
typedef struct {
    void *pool_start;
    size_t block_size;
    int total_blocks;
    // 无锁空闲栈顶指针
    void *top;
} LockFreePool;

void *lockfree_alloc(LockFreePool *pool) {
    void *old_top, *new_top;
    do {
        old_top = pool->top;
        if (old_top == NULL) return NULL;
        // 新栈顶 = 当前节点的next指针
        new_top = *(void **)old_top;
    } while (!__sync_bool_compare_and_swap(
        &pool->top, old_top, new_top));
    return old_top;
}

void lockfree_free(LockFreePool *pool, void *ptr) {
    void *old_top;
    do {
        old_top = pool->top;
        *(void **)ptr = old_top;
    } while (!__sync_bool_compare_and_swap(
        &pool->top, old_top, ptr));
}

这段代码的核心是CAS循环。多个线程同时修改top指针时,只有一个能成功,其他的会重试。重试次数通常很少,所以性能很高。

无锁的优势:
  • 没有上下文切换开销
  • 不会发生死锁
  • 高并发下性能线性增长

6.5 三种方案的对比

方案 实现难度 性能 适用场景
互斥锁 一般 线程少、分配频率低
读写锁 较好 读多写少
无锁队列 优秀 高并发、低延迟

我个人建议:先从互斥锁开始,性能不够再优化。别一上来就搞无锁,调试起来真的很痛苦。

6.6 无锁实现中的ABA问题

说到无锁,就不得不提ABA问题。简单来说,就是CAS操作时,值从A变成B又变回A,CAS会认为没变过,但实际上链表结构已经变了。

解决ABA问题的常见方法:

  • 带标签的指针: 在指针的高位存一个版本号,每次修改都递增
  • 双指针CAS: 同时比较指针和计数器
  • 垃圾回收机制: 延迟释放节点,确保不会立即重用
我的经验: 在x86_64架构下,指针的高16位通常用不到,可以用来存版本号。但要注意内存对齐问题。

6.7 线程本地缓存(Thread Local Cache)的补充

还有一种常见的优化思路:每个线程维护自己的小缓存。分配时先从本地缓存取,不够了再从全局池拿。释放时也先放回本地缓存,满了再还给全局池。

这种方案结合了互斥锁和无锁的优点:

  • 本地操作不需要加锁
  • 全局池的访问频率大大降低
  • 实现相对简单

嗯,这个方案我在一个游戏服务器里用过,效果很不错。全局池用互斥锁保护,每个线程的本地缓存用无锁栈,整体性能提升了3倍多。

注意: 线程本地缓存会导致内存碎片。每个线程都缓存一些内存,但可能其他线程更需要。需要设置合理的缓存上限。

6.8 本章小结

线程安全内存池的设计,说白了就是在性能安全性之间找平衡。

互斥锁简单可靠,适合低并发场景;读写锁在特定场景下能提升性能;无锁队列性能最高,但实现复杂,调试困难。

我个人更推荐分层设计:线程本地缓存 + 全局无锁池。这样既保证了性能,又降低了实现难度。

线程安全内存池方案对比 互斥锁保护 pthread_mutex_t 读写锁优化 pthread_rwlock_t 无锁队列 CAS原子操作 特性 实现简单 性能一般 适合低并发 特性 读多写少优化 写锁会阻塞读 实现中等 特性 高性能 无死锁风险 实现复杂 推荐:线程本地缓存 + 全局无锁池 根据实际场景选择合适方案,不要过度设计 从简单方案开始,性能不够再优化
公众号:蓝海资料掘金营,微信deep3321