广告位 · 页头(后台-模块管理-新建模块后替换此区域)

怎样设计线程安全的内存管理器 锁粒度与性能平衡?

设计线程安全内存管理器面临一个本质矛盾:既要保护共享元数据不被并发破坏,又要尽量缩短持有锁的时间。如果简单引入全局互斥锁,每次 malloc 和 free 都串行化,随着核心数增加,分配器本身会成为比业务逻辑更严重的瓶颈。理解锁粒度与性能之间的关系,是实现高并发内存管理的基础。

怎样设计线程安全的内存管理器 锁粒度与性能平衡?

一、全局锁方案为什么很快失效

最简单的线程安全实现是使用一把全局互斥锁保护整个分配器。每个 malloc 入口加锁,每个 free 入口加锁,保证同一时刻只有一个线程访问空闲链表。这种做法的正确性没有问题,代码量也很少,适合线程数不多、分配频率较低的场景。下面的代码展示了一个基于全局锁和单向空闲链表的简化分配器。

#include <pthread.h>
#include <stddef.h>
#include <stdlib.h>
typedef struct block {
    size_t size;
    struct block* next;
} block_t;
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static block_t* free_list = NULL;
void* my_malloc(size_t size) {
    pthread_mutex_lock(&g_lock);
    block_t* prev = NULL;
    block_t* curr = free_list;
    while (curr) {
        if (curr->size >= size) {
            if (prev) {
                prev->next = curr->next;
            } else {
                free_list = curr->next;
            }
            pthread_mutex_unlock(&g_lock);
            return (void*)(curr + 1);
        }
        prev = curr;
        curr = curr->next;
    }
    pthread_mutex_unlock(&g_lock);
    return malloc(size);
}

这段代码把查找、拆分、更新头指针全部放在临界区内。所有线程在进入 my_malloc 时都会竞争同一个 g_lock。当并发线程数达到 8、16 甚至更多时,锁的缓存行会在多个 CPU 核心之间来回跳跃,每一次加锁和解锁都会触发缓存一致性协议的大量通信。即使业务线程只是频繁分配小块内存,也会出现明显的吞吐下降与延迟抖动。

全局锁的性能问题并不是锁本身太慢,而是临界区太粗。线程在等待锁时无法做任何有用工作,锁持有者还要完成链表遍历等耗时操作。如果一次分配需要 200 纳秒,其中 150 纳秒都花在锁竞争与等待上,那么实际有效工作占比很低。优化方向不是去掉锁,而是缩短临界区,并让不同线程尽量操作不同的数据区域,这就是锁粒度设计的起点。

二、细粒度方案:分区锁与线程局部缓存

分区锁是降低竞争最直接的手段。把堆划分成多个独立的分配区域,通常称为 arena 或 zone。每个区域有自己的空闲链表和互斥锁,线程根据 ID 或某个哈希值选择区域。只要两个线程落在不同区域,它们就可以并行执行分配和释放。即使发生哈希冲突,也只需要竞争单个分区的锁,而不是全局锁。下面的示例定义 64 个 zone,并按线程 ID 取模选择。

#define ZONE_COUNT 64
typedef struct zone {
    pthread_mutex_t mtx;
    block_t* free_list;
} zone_t;
static zone_t zones[ZONE_COUNT];
void zone_init(void) {
    for (int i = 0; i < ZONE_COUNT; i++) {
        pthread_mutex_init(&zones[i].mtx, NULL);
        zones[i].free_list = NULL;
    }
}
void* zone_malloc(size_t size, int thread_id) {
    int idx = thread_id & (ZONE_COUNT - 1);
    zone_t* z = &zones[idx];
    pthread_mutex_lock(&z->mtx);
    block_t* blk = z->free_list;
    if (blk) {
        z->free_list = blk->next;
        pthread_mutex_unlock(&z->mtx);
        return (void*)(blk + 1);
    }
    pthread_mutex_unlock(&z->mtx);
    block_t* new_blk = (block_t*)malloc(sizeof(block_t) + size);
    new_blk->size = size;
    new_blk->next = NULL;
    return (void*)(new_blk + 1);
}

分区锁的粒度比全局锁细得多,但它仍然存在两个问题。第一,如果线程数量远大于分区数量,多个线程仍然可能频繁落在同一个 zone 上;第二,分配和释放可能发生在不同线程,这会导致一个线程释放的内存进入另一个线程常用的区域,引入跨核心访问。为了解决这两个问题,更进一步的方案是引入线程局部缓存,也就是每个线程拥有自己的空闲链表。

线程局部缓存通常使用 __thread 或 thread_local 存储的 freelist。当线程需要分配内存时,先检查本地 freelist。如果命中,整个过程不需要任何锁;如果未命中,再从全局或分区层批量获取一批内存块,填充本地缓存。释放时也优先放回本地缓存,只有当本地缓存超过阈值时,才批量归还给全局层。这样绝大多数操作都在线程本地完成,锁只用于批量补充和回收,锁的调用频率可以降低几个数量级。

static __thread block_t* local_free = NULL;
static __thread size_t local_count = 0;
#define LOCAL_MAX 32
void* tls_malloc(size_t size, int thread_id) {
    if (local_free != NULL) {
        block_t* blk = local_free;
        local_free = blk->next;
        local_count--;
        return (void*)(blk + 1);
    }
    local_free = fetch_batch_from_zone(size, thread_id);
    if (local_free == NULL) {
        return malloc(size);
    }
    local_count = LOCAL_MAX;
    block_t* blk = local_free;
    local_free = blk->next;
    local_count--;
    return (void*)(blk + 1);
}

这里的 fetch_batch_from_zone 会一次性向分区请求多个内存块,而不是一个。批量补充后,后续 31 次分配都可以在本地完成。释放路径同理,当本地缓存未满时直接插入本地链表,只有在 local_count 超过阈值时才调用批量归还函数。这种设计将锁竞争从每次分配分摊到每几十次分配,显著提升多核扩展性。

三、进一步降低竞争:无锁结构、批量分配与缓存行对齐

当分区锁和线程本地缓存已经部署后,全局层的锁仍然可能成为瓶颈,尤其是不同线程同时归还批量内存时。此时可以考虑把全局空闲链表改造成无锁结构。最常见的做法是使用 Treiber stack:通过 __atomic_compare_exchange_n 或 C11 的 atomic_compare_exchange_weak 完成头指针的 CAS 更新。无锁栈避免了互斥锁的睡眠与唤醒开销,在临界区极短时表现更好。

#include <stdatomic.h>
static _Atomic(block_t*) atomic_free_list = NULL;
void lockfree_push(block_t* blk) {
    block_t* head = atomic_load(&atomic_free_list);
    do {
        blk->next = head;
    } while (!atomic_compare_exchange_weak(&atomic_free_list, &head, blk));
}
block_t* lockfree_pop(void) {
    block_t* head = atomic_load(&atomic_free_list);
    while (head != NULL) {
        block_t* next = head->next;
        if (atomic_compare_exchange_weak(&atomic_free_list, &head, next)) {
            return head;
        }
    }
    return NULL;
}

无锁结构虽然避免了锁等待,但并非银弹。CAS 失败时会导致自旋重试,如果竞争非常激烈,CPU 会反复执行无效指令。而且无锁栈通常需要解决 ABA 问题,尤其在使用头指针和复用节点时。实际分配器中可以结合引用计数、标签指针或者风险指针来降低 ABA 风险,也可以把无锁结构限制在低竞争路径上。

另一个容易被忽略的细节是伪共享。假设每个线程的本地缓存头部放在一个连续数组中,而每个线程只写自己的缓存头。如果这些缓存头位于同一个缓存行内,一个线程修改自己的数据会导致其他核心的缓存行失效,造成和锁类似的开销。解决方式是对每个线程的缓存头做缓存行对齐,典型做法是使用 64 字节或 128 字节对齐,并在结构体中增加填充字段。

#define CACHE_LINE_SIZE 64
typedef struct tls_cache {
    _Alignas(CACHE_LINE_SIZE) block_t* free_list;
    _Alignas(CACHE_LINE_SIZE) size_t count;
    unsigned char padding[CACHE_LINE_SIZE - sizeof(block_t*) - sizeof(size_t)];
} tls_cache_t;
static tls_cache_t thread_caches[MAX_THREADS];

批量分配和批量归还本质上也是在降低全局元数据的访问频率。假设每次批量申请 64 个块,那么全局锁或 CAS 的压力就降低为原来的六十四分之一。批量大小的选择需要在内存利用率和锁竞争之间折中:批量过大,线程会囤积过多内存,导致其他线程可用内存不足;批量过小,又无法有效分摊全局同步开销。通常可以参考线程数量和分配大小动态调整,例如小对象设置较大批量,大对象逐个分配。

四、锁粒度选择的工程建议

没有一种锁粒度可以适配所有场景。线程数在 4 以内、分配频率不高时,全局锁的简单实现已经足够,维护成本最低。线程数在 8 到 32 之间、分配和释放频繁时,建议使用分区锁加线程本地缓存,并保证分区数量不为 2 的幂次与线程数强关联,避免哈希聚集。线程数超过 32 或者全局层出现明显 CAS 失败时,再考虑无锁栈和批量归还机制。

除了锁粒度,还需要关注分配大小分布。小对象分配最频繁,应优先命中线程本地缓存;中等对象可能跨线程存活,适合分区锁;大对象通常直接调用操作系统接口,锁的开销占比较低,不应强行加入细粒度分配器。对于释放操作,如果释放线程与分配线程不同,可以引入延迟队列或锁保护的回送队列,把归还操作合并成批处理,减少跨区域锁竞争。

性能验证时不能只看单线程吞吐。单线程下,无锁结构和复杂缓存可能比一把大锁更慢,因为它们增加了分支和代码路径。多线程扩展性才是衡量锁粒度设计的关键指标。可以编写基准测试,在线程数从 1 增加到 32 的过程中记录每秒分配次数、尾延迟和 CPU 自旋耗时。合理的锁粒度方案应当在线程数增加时保持吞吐近似线性增长,同时延迟曲线不会出现大幅抖动。

线程安全内存管理器的设计可以概括成一句话:把共享状态压缩到最小,把临界区缩短到最短,让大多数分配在无锁或线程本地路径上完成。全局锁是起点,分区锁是过渡,线程本地缓存是主力,无锁结构是补充。只有结合具体线程模型、分配特征和生命周期管理策略,才能在正确性与性能之间找到合适的平衡点。

广告位 · 文章底部(后台-模块管理替换此区域)