C++ `jemalloc` / `tcmalloc` 的线程局部缓存(Thread-Local Cache)原理

哈喽,各位好!今天咱们来聊聊 C++ 里那些内存分配器中的线程局部缓存(Thread-Local Cache,简称 TLC)。这玩意儿听起来玄乎,但实际上是内存分配器为了提升性能,耍的一个小聪明。咱们以 jemalloctcmalloc 为例,深入浅出地扒一扒它的原理。

一、内存分配器的前世今生:从mallocjemalloc/tcmalloc

话说当年,C 语言横行天下,mallocfree 这对好基友几乎成了动态内存分配的代名词。但随着应用越来越复杂,多线程编程越来越普及,malloc 的缺点也暴露出来了:

  • 锁的争用: malloc 内部通常使用全局锁来保护堆,多个线程同时申请内存时,必须排队等锁,效率低下。想象一下,只有一个厕所,大家都要上,那酸爽…
  • 内存碎片: 频繁的分配和释放会导致堆中出现很多小的、不连续的空闲块,导致大块内存无法分配,明明还有总容量,却不能分配,这是内存碎片化。

为了解决这些问题,各种高级内存分配器应运而生,比如 jemalloc (Facebook 出品),tcmalloc (Google 出品),还有 mimalloc (Microsoft 出品) 等等。它们的核心思想都是尽量减少锁的争用,提高内存分配的效率。而 TLC 就是它们提高效率的一个重要武器。

二、什么是线程局部缓存(TLC)?

简单来说,TLC 就是每个线程私有的、用于缓存小块内存的区域。 每个线程都有自己的一个“小金库”,当线程需要分配小块内存时,先从自己的“小金库”里找,如果够用就直接分配,不需要去全局堆里抢锁。 释放的时候也一样,先放回自己的“小金库”。

TLC 的好处:

  • 减少锁的争用: 线程从自己的 TLC 中分配和释放内存,不需要和其他线程竞争全局锁,大大提高了并发性能。
  • 提高缓存命中率: 线程频繁使用的内存块更有可能留在 TLC 中,下次使用时直接命中,速度更快。

TLC 的缺点:

  • 空间浪费: 每个线程都有自己的 TLC,即使线程使用的内存很少,也需要占用一定的空间。
  • 内存迁移: 如果一个线程释放的内存被另一个线程需要,需要进行内存迁移,会增加开销。

三、jemalloc 的 TLC 实现

jemalloc 的 TLC 实现比较复杂,它采用了多层级的缓存结构:

  1. Thread-Local Cache (TLC): 最底层的缓存,每个线程独占。
  2. Arena: 一组相关的内存页的集合,可以理解为一个小型的堆。
  3. Global Arena: 最大的堆,所有线程共享。

当一个线程需要分配内存时,jemalloc 会按以下步骤进行:

  1. TLC 查找: 首先在线程自己的 TLC 中查找是否有合适的空闲块。
  2. Arena 填充: 如果 TLC 中没有合适的空闲块,jemalloc 会尝试从与该线程关联的 Arena 中填充 TLC。
  3. Global Arena 分配: 如果 Arena 中也没有合适的空闲块,jemalloc 才会从 Global Arena 中分配新的内存块,并填充到 Arena 中。

释放内存的过程则相反:

  1. TLC 释放: 优先将释放的内存块放回线程自己的 TLC。
  2. Arena 回收: 如果 TLC 满了,会将一部分内存块返回给 Arena。
  3. Global Arena 释放: 如果 Arena 也满了,才会将内存块释放回 Global Arena。

jemalloc 使用 extent 来管理内存块。 extent 是连续的虚拟内存区域,可以被分割成更小的块来满足分配请求。

jemalloc 的 TLC 相关数据结构 (简化版):

// 线程局部缓存 (Thread-Local Cache)
typedef struct {
    malloc_bin_t *bins[MALLOCX_NCPUS][NBINS]; // 二级指针,bins是一个数组,数组的元素是指针,指向malloc_bin_t类型的结构体
    // ... 其他字段
} tcache_t;

// Arena,管理一组内存页的集合
typedef struct {
    // ... 其他字段
} arena_t;

// 内存块管理结构
typedef struct malloc_bin_s malloc_bin_t;
struct malloc_bin_s {
  size_t        size;
  uint64_t      n_allocated;
  size_t        n_cached;
  malloc_bin_t* next;
};

jemalloc 的 TLC 相关代码示例 (简化版):

// 从 TLC 中分配内存
void* tcache_alloc(tcache_t* tcache, size_t size) {
    // 根据 size 找到对应的 bin
    malloc_bin_t* bin = get_bin(size);

    // 从 bin 中取出一个空闲块
    if (bin->n_cached > 0) {
        void* ptr = bin->next; // 假设 bin 使用链表存储空闲块
        bin->next = ((malloc_bin_t*)ptr)->next;
        bin->n_cached--;
        bin->n_allocated++;
        return ptr;
    }

    // TLC 中没有空闲块,需要从 Arena 或 Global Arena 中分配
    return NULL; // 简化处理,实际实现更复杂
}

// 将内存块释放到 TLC
void tcache_dealloc(tcache_t* tcache, void* ptr, size_t size) {
    // 根据 size 找到对应的 bin
    malloc_bin_t* bin = get_bin(size);

    // 将内存块添加到 bin 的链表头
    ((malloc_bin_t*)ptr)->next = bin->next;
    bin->next = (malloc_bin_t*)ptr;
    bin->n_cached++;
    bin->n_allocated--;

    // 如果 TLC 满了,需要将一部分内存块返回给 Arena
    // ...
}

四、tcmalloc 的 TLC 实现

tcmalloc 的 TLC 实现相对简单一些,它也采用了线程局部缓存,但没有 jemalloc 那么多层级的结构。

tcmalloc 将内存分为两种类型:

  1. Small Object: 小于 32KB 的对象。
  2. Large Object: 大于等于 32KB 的对象。

tcmalloc 的 TLC 主要用于缓存 Small Object。 每个线程都有一个 ThreadCache 对象,其中包含多个 FreeList,每个 FreeList 对应一种大小的 Small Object。

当一个线程需要分配 Small Object 时,tcmalloc 会按以下步骤进行:

  1. ThreadCache 查找: 首先在线程自己的 ThreadCache 中查找是否有对应大小的空闲块。
  2. CentralCache 填充: 如果 ThreadCache 中没有空闲块,tcmalloc 会从 CentralCache 中填充 ThreadCacheCentralCache 是所有线程共享的缓存,但它使用了锁来保护。
  3. PageHeap 分配: 如果 CentralCache 中也没有空闲块,tcmalloc 才会从 PageHeap 中分配新的内存页,并填充到 CentralCache 中。PageHeaptcmalloc 的底层内存管理机制。

释放 Small Object 的过程则相反:

  1. ThreadCache 释放: 优先将释放的内存块放回线程自己的 ThreadCache
  2. CentralCache 回收: 如果 ThreadCache 满了,会将一部分内存块返回给 CentralCache
  3. PageHeap 释放: 如果 CentralCache 也满了,才会将内存页释放回 PageHeap

Large Object 直接从 PageHeap 分配和释放,不经过 TLC。

tcmalloc 的 TLC 相关数据结构 (简化版):

// 线程缓存 (ThreadCache)
class ThreadCache {
public:
    FreeList free_lists_[kNumClasses]; // FreeList 数组,每个 FreeList 对应一种大小的 Small Object
    // ... 其他字段
};

// 空闲列表 (FreeList)
class FreeList {
public:
    void* list_head_; // 空闲块链表的头指针
    size_t length_;    // 空闲块的数量
    // ... 其他字段
};

// CentralCache,所有线程共享的缓存
class CentralCache {
public:
    // ... 其他字段
};

// PageHeap,底层内存管理机制
class PageHeap {
public:
    // ... 其他字段
};

tcmalloc 的 TLC 相关代码示例 (简化版):

// 从 ThreadCache 中分配内存
void* ThreadCache::Allocate(size_t size) {
    int class_index = SizeMap::SizeToClass(size); // 根据 size 找到对应的 class index
    FreeList* free_list = &free_lists_[class_index];

    // 从 FreeList 中取出一个空闲块
    if (free_list->length_ > 0) {
        void* ptr = free_list->list_head_;
        free_list->list_head_ = ((void**)ptr)[0]; // 假设 FreeList 使用链表存储空闲块
        free_list->length_--;
        return ptr;
    }

    // ThreadCache 中没有空闲块,需要从 CentralCache 中填充
    return NULL; // 简化处理,实际实现更复杂
}

// 将内存块释放到 ThreadCache
void ThreadCache::Deallocate(void* ptr, size_t size) {
    int class_index = SizeMap::SizeToClass(size); // 根据 size 找到对应的 class index
    FreeList* free_list = &free_lists_[class_index];

    // 将内存块添加到 FreeList 的链表头
    ((void**)ptr)[0] = free_list->list_head_;
    free_list->list_head_ = ptr;
    free_list->length_++;

    // 如果 ThreadCache 满了,需要将一部分内存块返回给 CentralCache
    // ...
}

五、TLC 的配置和使用

jemalloctcmalloc 都提供了一些配置选项,可以用来调整 TLC 的行为。

  • jemalloc 可以通过环境变量 MALLOC_CONF 来配置 jemalloc。例如,可以设置 opt.tcache:false 来禁用 TLC。
  • tcmalloc 可以通过 MallocExtension 类来配置 tcmalloc。例如,可以使用 MallocExtension::SetMaxPerThreadCacheSize 来设置每个线程的 TLC 的最大大小。

代码示例:

// tcmalloc 的配置示例
#include <gperftools/malloc_extension.h>

int main() {
    // 设置每个线程的 TLC 的最大大小为 1MB
    MallocExtension::instance()->SetMaxPerThreadCacheSize(1024 * 1024);

    // ...
    return 0;
}

六、TLC 的优缺点分析

特性 优点 缺点
性能 减少锁的争用,提高并发性能 内存迁移会增加开销,特别是线程间频繁传递数据时
内存利用率 可以更好地利用缓存,提高缓存命中率 可能会导致内存碎片,特别是线程分配的内存大小差异较大时
空间占用 每个线程都需要分配 TLC,可能会占用较多的内存,特别是线程数量较多时
适用场景 多线程应用,频繁分配和释放小块内存的应用 单线程应用,内存分配量较小的应用
调试难度 TLC 的存在会增加内存泄漏和内存错误的调试难度,因为内存分配和释放发生在不同的线程中

七、总结

TLC 是高级内存分配器为了提高并发性能而采用的一种重要技术。它通过为每个线程提供私有的缓存,减少了锁的争用,提高了缓存命中率。但是,TLC 也有一些缺点,比如空间浪费和内存迁移开销。在实际应用中,需要根据具体的场景和需求来选择是否使用 TLC,以及如何配置 TLC。

希望今天的讲解能帮助大家更好地理解 C++ 内存分配器中的 TLC 技术。 记住,理解这些底层原理,能让你在遇到性能问题时,多一些排查和优化的思路。 祝大家编程愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注