27、线程安全的内存分配:多线程环境下的malloc、线程局部存储

多线程编程里,内存分配是个大坑。我刚开始写多线程程序时,觉得malloc不就是分配个内存嘛,能有什么问题?结果程序跑着跑着就崩了,查了两天才发现是内存分配冲突。嗯,今天咱们就把这个坑填上。

为什么malloc在多线程下不安全?

标准C库的malloc,说白了不是线程安全的。为什么?你想想看,多个线程同时调用malloc,都要去修改堆内存的元数据——比如空闲链表、内存块状态这些。如果没加锁,两个线程同时修改同一块数据,那结果就是不可预知的。

我在项目中遇到过这样一个场景:一个8线程的服务,跑着跑着突然malloc返回NULL,但系统内存明明还有。查了半天,发现是多个线程同时操作空闲链表,把链表结构搞坏了。从那以后,我对多线程内存分配就格外小心。

核心问题: 标准malloc内部维护的全局数据结构(如空闲链表、内存池状态)在多线程并发访问时,会出现数据竞争(data race)。

线程安全的内存分配器

解决这个问题,有几种主流方案。我按推荐程度排个序:

方案 原理 性能 适用场景
加锁的malloc 全局互斥锁保护 低(高并发时锁竞争严重) 线程数少(<4)
线程缓存分配(tcache) 每个线程有自己的小对象缓存 大多数通用场景
线程局部存储(TLS) 每个线程独立的内存池 极高 固定大小对象、高频分配
无锁内存分配器 CAS原子操作 极高 实时系统、低延迟场景

我个人习惯用第二种——线程缓存分配。glibc的malloc从2.26版本开始就默认开启了tcache,说白了就是每个线程有个小缓存,分配小对象时直接从缓存拿,不用加锁。这招很聪明,大部分分配操作都避开了锁竞争。

线程局部存储(TLS)实战

线程局部存储,说白了就是每个线程独享一份全局变量。你声明一个变量,加上__thread关键字,每个线程看到的就是自己的副本。这玩意儿在多线程内存分配里特别好用。

来看个例子:

// 线程局部存储:每个线程独立的内存池
__thread char thread_pool[1024 * 1024];  // 1MB 每线程
__thread size_t pool_offset = 0;

void* thread_safe_alloc(size_t size) {
    // 对齐到8字节
    size = (size + 7) & ~7;
    
    if (pool_offset + size > sizeof(thread_pool)) {
        // 池子用完了,回退到系统malloc
        return malloc(size);
    }
    
    void* ptr = thread_pool + pool_offset;
    pool_offset += size;
    return ptr;
}

void thread_safe_free(void* ptr) {
    // TLS池内的内存不需要释放(线程退出时自动回收)
    // 只有回退到系统malloc的才需要free
    // 这里简化处理,实际需要判断指针来源
}
小技巧: 我写网络服务器时,经常给每个线程预分配一个固定大小的内存池。连接处理过程中分配的小对象,直接从池子里拿,线程退出时整个池子回收。这样既避免了锁竞争,又减少了内存碎片。

避坑指南:TLS的陷阱

我曾经在TLS上栽过跟头,分享几个血泪教训:

  • TLS变量不能跨线程访问——你在线程A里取线程B的TLS变量地址,拿到的是A自己的副本。这问题排查起来特别隐蔽。
  • TLS变量大小有限制——Linux默认TLS段大小是几MB,别想着放大数据结构。我一般控制在64KB以内。
  • 动态库里的TLS要小心——如果TLS变量定义在动态库里,不同加载方式可能导致变量初始化时机不同。我建议TLS变量尽量放在主程序里。
警告: 别在TLS内存池里分配大对象(超过几KB)。TLS空间有限,而且大对象容易导致池子快速耗尽,频繁回退到系统malloc反而降低性能。我一般只把TLS用于分配64字节以内的小对象。

知识体系:多线程内存分配全景

下面这张图,是我整理的多线程内存分配知识结构。你看一眼就能明白各个方案之间的关系:

多线程内存分配知识体系 多线程内存分配 加锁分配器 线程缓存(tcache) 线程局部存储(TLS) 全局互斥锁保护 高并发时性能差 每线程小对象缓存 glibc 2.26+ 默认开启 大部分分配无锁 __thread 关键字 每线程独立内存池 适合固定大小小对象 选择建议:小对象用TLS,通用场景用tcache

实际项目中的选择

说了这么多,到底该用哪种?我根据经验给个建议:

  • 通用场景——直接用glibc的malloc就行,tcache已经帮你处理了大部分并发问题。我写的大部分服务都这样用,没出过问题。
  • 高频小对象分配——比如网络协议解析、日志格式化这些场景,用TLS内存池。我有个消息中间件项目,每秒分配/释放几十万次小对象,改用TLS后性能提升了3倍。
  • 实时系统——考虑无锁内存分配器,比如tcmalloc或jemalloc。这些分配器用原子操作代替锁,延迟更可控。
我的习惯: 写多线程程序时,我会先用默认malloc跑一遍性能测试。如果发现malloc成了瓶颈(比如perf top里malloc占比超过10%),再考虑优化。过早优化是万恶之源,这个道理在多线程内存分配上同样适用。

嗯,多线程内存分配就聊到这儿。记住一点:没有银弹。不同的场景选不同的方案,关键是要理解每种方案的原理和局限。你写代码时多想想「这个分配会不会被多个线程同时调用」,就能避开大部分坑。


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