Malloc技术原理解析以及在转转搜索业务上的实践

1 导读

内存管理在三个不同的层面上发挥作用:用户程序层、C运行时库层以及内核层。其中,内存分配器allocator是C运行时库中的一个关键组件,其主要任务是响应用户程序的内存分配请求。分配器负责向操作系统内核请求适当大小的内存块,并将这些内存块分配给用户程序。

为了提高内存分配的效率,分配器通常会预先分配一块稍大于用户请求的内存空间,并使用特定的算法来管理这块内存,以满足用户的内存需求。不同之处在于,用户释放的内存并不会立即返回给操作系统,而是由分配器来管理这些空闲内存空间,以备将来用户的内存分配请求。简而言之,分配器的任务不仅仅是管理已分配的内存块,还包括有效地管理可用的空闲内存块。当需要响应用户的内存分配请求时,分配器会首先在已有的空闲内存中查找合适大小的内存块,只有在空闲内存不足时才会申请新的内存。系统的物理内存是有限的,而对内存的需求是变化的, 程序的动态性越强,内存管理就越重要,选择合适的内存分配库会带来明显的性能提升。

在转转的服务中,许多服务存在较高的堆外内存使用问题,例如转转搜索业务的排序服务,堆外内存的使用超出了预期,这已经导致了许多物理机内存的不足。初步分析表明,这些内存占用较高的转转服务内部使用TensorFlow进行推断,Linux默认使用的glibc的malloc实现在内存池资源回收方面存在缺陷,导致已分配的内存无法有效地返还给操作系统,根据此现状,需要对现有的可选malloc进行原理分析和合理选择,优化服务的内存占用表现,缓解服务器内存不足的现状。常见的内存分配库包括ptmalloc (作为glibc标准库的一部分)、tcmalloc (由Google开发)、jemalloc(由Facebook开发),由于篇幅较长,下文将对ptmalloc和tcmalloc的基本原理和相关参数进行介绍,并只介绍jemalloc的参数部分。

2 内存管理与系统调用

在介绍ptmalloc、tcmalloc等内存分配库之前,让我们先了解一下内存布局:

上图描述了x86_64架构下的Linux进程默认地址空间,栈从顶向下扩展,堆从底向上扩展,而mmap映射区域也是从顶向下扩展。mmap映射区域与堆相对扩展,直到耗尽虚拟地址空间,操作系统提供了brk()系统调用,用于设置堆的上边界。其次,针对mmap映射区域的操作,可以使用mmap()和munmap()函数。由于系统调用的开销较高,因此不太适合在每次需要内存分配时都从内核申请空间,特别是对于小内存分配来说更是如此。另外,mmap的映射区域可能会因为munmap()的释放而容易被回收。因此,一般的做法是对于大内存分配,使用mmap()来申请内存,而对于小内存分配,则采用brk()方式。这其中也包含了linux内存管理的基本思想:内存延迟分配。即只有在真正访问一个地址的时候才建立这个地址的物理映射。linux内核在用户申请内存的时候,只是给它分配了一个虚拟地址,并没有分配实际的物理地址,只有当用户使用这块内存的时候,内核才会分配具体的物理地址给用户使用。

2.1 brk() 和 sbrk()

arduino 复制代码
#include <unistd.h>

int brk(void *addr);

brk()是一个系统调用,其实现定义在mmap.c中。它的主要作用是调整堆顶的位置,使堆内存可以从低地址向高地址增长。在分配内存时,brk()会将堆段的最高地址指针mm->brk向高地址扩展,然后调用do_brk_flags来分配新的虚拟内存区域(Virtual Memory Area,VMA),并将这个VMA插入到内核的链表和红黑树中。

需要注意的是,虽然使用brk()分配了一段新的虚拟内存区域,但这并不会立即分配物理内存。实际的物理内存分配通常是在访问新分配的虚拟内存区域时,如果发生了缺页异常(Page Fault),操作系统才会开始分配并映射相应的物理内存页面,内存收缩时,调用__do_munmap对heap进行收缩。

  • Brk()的参数设置为新的brk上界地址,成功返回1,失败返回0;
  • Sbrk()的参数为申请内存的大小,返回heap新的上界brk的地址。

2.2 mmap()

mmap()系统调用用于在进程的虚拟地址空间中创建新的内存映射。内存分配器通常使用这个系统调用来创建私有匿名映射,以分配内存。内核会按照页面大小的倍数(通常为4096字节)来分配内存,函数原型如下:

arduino 复制代码
#include <unistd.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

一旦建立了这种映射关系,进程就可以通过指针的方式来读写这段内存,而系统会自动将脏页(被修改的页)回写到相应的磁盘文件上。这意味着进程可以通过直接访问内存来完成对文件的操作,而无需再调用read、write等系统调用函数。

除了减少read、write等系统调用外,mmap()还可以减少内存拷贝的次数。例如,在使用read调用时,典型的流程是操作系统首先将磁盘文件内容读入页缓存,然后再将数据从页缓存复制到read传递的缓冲区中。然而,使用mmap()后,操作系统只需将磁盘数据读入页缓存,然后进程可以直接通过指针方式操作mmap映射的内存,从而减少了从内核态到用户态的数据拷贝。

3 ptmalloc

目前大部分服务端程序使用GNU Libc的内存分配器ptmalloc,起源于Doug Lea的malloc,进而由Wolfram Gloger改进得到可以支持多线程。ptmalloc的设计是在性能和内存放大之间做了权衡,为了降低锁冲突,设置了多arena机制,却间接增加了内存碎片,从而导致物理内存的消耗增加。下面将对服务器常用的centos7上使用的ptmalloc2的技术细节进行介绍和分析。

3.1 整体架构

主分配区 (main_area)和非主分配区(no_main_area):当多个线程同时使用malloc分配内存时,内存分配器会采取一些策略来解决线程之间的锁竞争问题。分配器将内存分配区分为两种:主分配区和非主分配区。这两种分配区被组织成一个环形链表,以便进行有效管理。每个分配区都使用互斥锁来确保线程对其的访问是互斥的。每个进程只有一个主分配区,但可以有多个非主分配区。ptmalloc根据系统对分配区的竞争情况动态调整分配区的大小,一旦增加了分配区的数量,就不会再减少。主分配区可以使用brk和mmap来分配内存,而非主分配区只能使用mmap来映射内存块。在分配小内存时,可能会导致内存碎片问题,因此ptmalloc在整理内存时需要对分配区进行加锁操作。当线程需要使用malloc分配内存时,它首先检查自己的私有变量中是否已经有一个分配区。如果有,它会尝试对其进行加锁操作,如果成功,就使用该分配区分配内存。如果失败,它会遍历循环链表,寻找一个未加锁的分配区。如果整个链表中都没有未加锁的分配区,那么malloc将创建一个新的分配区,将其加入全局循环链表并加锁,然后使用该分配区进行内存分配。

在释放内存时,线程会先获取待释放内存块所在分配区的锁。如果其他线程正在使用该分配区,线程必须等待其他线程释放该分配区的互斥锁,然后才能进行释放内存的操作。这些策略有助于确保内存分配的线程安全性和高效性。

ptmalloc使用chunk结构体来描述内存块,其中包含了大小、前后chunk指针、前一个chunk是否在使用中以及前一个chunk的大小等成员信息。这些成员在内存块的管理和合并操作中起着关键作用。

其中,p字段主要用于内存块的合并操作,具体表示如下:

  • 当p=0时,表示前一个chunk为空闲,此时prev_size字段有效;
  • 当p=1时,表示前一个chunk正在使用,此时prev_size字段无效。

值得注意的是,ptmalloc分配的第一个块总是将p设为1,以防止程序引用到不存在的区域。此外,还有M和A字段,其中M=1表示该内存块来自mmap映射区域,而M=0表示来自heap区域;A=0表示主分配区分配,A=1表示非主分配区分配。

在内存中,空闲的chunk具有如下结构:

  • fp和bp分别指向前一个和后一个空闲链表上的chunk;
  • fp_nextsize和bp_nextsize分别指向前一个空闲chunk和后一个空闲chunk的大小,用于在空闲链表上快速查找合适大小的chunk。

这些指针的值都存储在原始用户区域中,这样就不需要为每个chunk准备单独的内存存储指针。

ptmalloc还维护了多个bin,用于存储相似大小的chunk,这些bin以双向链表的形式链接在一起。总共有128个bin,根据chunk的大小可以分为四种类型:

  • Fast bin(小于64B)
  • Unsorted bin
  • Small bin
  • Large bin

这些bin被保存在数组fastbinsY(fast bin)和bins(其他bin)中。当用户调用malloc时,系统可以快速查找适合用户需求大小的内存块是否在这些bin中,并通过双向链表查找合适的chunk内存块提供给用户使用。

Fast bins记录着大小以8字节递增的Fast bin链表,主要用于存储较小的内存块(小于默认的max_fast大小64B),这些chunk一般不会被合并,因此分配速度很快。

Unsorted bin的队列使用bins数组的第一个bin,相当于small bins和large bins的一个缓冲区,存放最近释放的大小大于max_fast 的chunk、合并后的chunk以及切割剩余的chunk等。这个bin没有尺寸上限,可以快速找到最近释放的chunk。如果找不到合适大小的chunk,ptmalloc会清空unsorted bin,将其中的chunks按照大小分类放入其他适当的bin中。

Small bin保存大小小于512字节的chunk,从2开始编号,一共有63个,相邻的small bin之间相差8字节。同一个small bin中的chunk具有相同的大小。在释放一个chunk时,会检查其前后的chunk是否为空闲,如果是,则将它们合并成一个新的chunk,然后将新chunk添加到unsorted bin的前端。

Large bin保存大小大于等于512字节的chunk,位于small bin之后。每个bin包含了一个给定范围内的chunk,这些chunk按大小递减排序。在分配chunk时,会寻找大小最合适的chunk,并进行切割,剩余部分放入unsorted bin。释放操作类似于small bin,如果条件满足,将会进行合并。

除了以上类型的chunk,还有mmaped chunk和top chunk:

mmaped chunk用于分配非常大的内存块(大于默认的分配阈值,通常为128K)。在释放mmaped chunk上的内存时,会直接返回给操作系统。

top chunk相当于分配区的顶部空闲内存,当bins上无法满足内存分配要求且小于mmap分配阈值时,就会使用top chunk来分配。如果top chunk的大小比用户请求的大小大,会将其切割为两部分:User chunk(用户请求大小)和Remainder chunk(剩余大小)。其中Remainder chunk成为新的top chunk。当top chunk大小小于用户所请求的大小时,top chunk就通过sbrk(主分配区)或mmap(非主分配区)系统调用来扩容。

last remainder是一种特殊的chunk,类似于top chunk和mmaped chunk,它不会出现在任何bins中。当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chuk。

3.2 内存分配

ptmalloc的内存申请过程其实是:获取arena并加锁--> fast bin --> unsorted bin --> small bin --> large bin --> top chunk --> 扩展堆的过程,详细流程如下:

  1. 获取分配区(arena)的锁,以确保多线程环境下的内存分配操作不会发生冲突;
  2. 计算出需要分配的chunk的实际大小;
  3. 首先检查chunk的大小是否小于max_fast(默认64字节),如果是,则尝试从fast bins中获取适合的chunk,如果找到则分配结束。否则,继续下一步;
  4. 如果chunk的大小小于512字节,尝试从small bins中获取合适的chunk,如果找到则分配结束。否则,继续下一步;
  5. 如果以上步骤都未成功,会首先遍历fast bins中的chunk,将相邻的chunk合并,并链接到unsorted bin中,然后遍历unsorted bins。如果unsorted bins中只有一个chunk且大小大于待分配的chunk,则将其切分,并将剩余的部分继续放入unsorted bins;如果unsorted bins中有大小适合的chunk,则分配结束。否则,继续下一步;
  6. 如果以上步骤都未成功,在large bins中查找合适的chunk,进行切割,并将剩余部分放回unsorted bin。如果fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了。如果top chunk的大小比用户所请求的大小还大,则将top chunk切分为两部分:User chunk(用户请求大小)和Remainder chunk(剩余大小),并将剩余块作为新的top chunk。如果top chunk的大小小于用户所请求的大小,根据分配区的类型(主分配区或非主分配区),通过sbrk(主分配区)或mmap(非主分配区)系统调用来扩展top chunk的大小;
  7. 如果以上步骤都未成功,根据需要的chunk大小,选择调用sbrk(主分配区)或mmap(非主分配区)系统调用来分配内存块;
  8. 使用mmap系统调用为程序的内存空间映射一块大小为chunk_size、对齐到4KB的内存空间,并将内存指针返回给用户;
  9. 判断是否为第一次调用malloc,如果是主分配区,则需要进行初始化工作,分配一块初始大小为(chunk_size + 128KB)、对齐到4KB的空间作为初始的heap。如果已经初始化过了,主分配区则调用sbrk(主分配区)来增加heap的大小,非主分配区则在top chunk中切割出一个chunk以满足分配需求。

3.3 回收过程

内存回收流程概述如下:

  1. 首先,获取分配区的锁,以确保线程安全性;
  2. 如果要释放的是空指针,则无需执行任何操作,直接返回;
  3. 检查当前的内存块(chunk)是否是通过mmap映射的内存区域。如果是的话,直接通过munmap()函数将该chunk释放。我们可以通过已使用chunk的数据结构中的"M"标志来判断是否是mmap映射的内存;
  4. 判断chunk是否与顶部内存块(top chunk)相邻。如果相邻,将它们合并在一起(相邻意味着与分配区中的空闲chunk相邻)。然后,继续下一步骤;
  5. 如果chunk的大小大于max_fast(64字节),将其放入未排序的bin中,并检查是否需要合并。如果需要合并且与top chunk相邻,进入下一步骤;
  6. 如果内存块的大小小于max_fast(64字节),直接放入快速bin中,而不改变chunk的状态。然后,检查是否需要合并,如果需要合并,继续下一步骤;
  7. 在快速bin中,如果当前chunk的下一个chunk也是空闲的,将它们合并,并将合并后的chunk放回未排序的bin中。如果合并后的chunk大小大于64字节,将触发快速bin的合并操作,将与相邻的空闲chunk合并后,放回未排序的bin中。如果合并后的chunk与top chunk相邻,将它们合并到top chunk中;
  8. 最后,检查top chunk的大小是否大于mmap的收缩阈值(默认为128KB)。如果是,尝试将部分top chunk归还给操作系统,然后结束内存释放操作。

3.4 部分参数解析

MALLOC_ARENA_MAX(线程内存池的最大数量):通过将MALLOC_ARENA_MAX设置为较小的值,可以减少arena的数量,从而降低内存碎片的可能性。然而,这可能会导致高并发环境中的锁争用问题,从而对性能产生负面影响。因此,需要在性能和内存利用之间找到一个平衡点。

此外,ptmalloc2默认会根据内存需求的动态情况来调整mmap分配的阈值,以便更有效地利用内存缓冲池设置,但是设置M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个就可以固定分配阈值为128K,这样超过128K的内存分配请求都不会进入ptmalloc的buffer池而是直接走mmap分配和munmap回收(性能上会有损耗)。

3.5 特性分析

  • ptmalloc2使用了多arena 来分配内存,虽然增加了内存碎片,但却提升了内存分配效率;
  • 后分配的内存先释放,因为 ptmalloc 收缩内存是从 top chunk 开始,如果与 top chunk 相邻的 chunk 不能释放, top chunk 以下的 chunk 都无法释放;
  • 多线程锁开销大,需要避免多线程频繁分配释放;
  • 内存从thread的areana中分配, 内存不能从一个arena移动到另一个arena, 就是说如果多线程使用内存不均衡,容易导致内存的浪费。比如说线程1使用了300M内存,完成任务后glibc没有释放给操作系统,线程2开始创建了一个新的arena, 但是线程1的300M却不能用了;
  • 每个chunk至少8字节的开销很大;
  • 不定期分配长生命周期的内存容易造成内存碎片,不利于回收。64位系统最好分配32M以上内存,这是使用mmap的阈值。

4 tcmalloc

TCMalloc 全称 Thread-Caching Malloc,即线程缓存的 malloc,是 Google 开发的内存分配器,在不少项目中都有使用,目前已经在chrome、safari等知名软件中运用。

4.1 整体架构

tcmalloc的架构大体分为三个部分:

  • Front-end用来为应用提供快速分配以及释放内存的需求。可以根据参数的配置调整为Per-cpu mode 和 per-thread mode两种模式,后面会细讲;
  • Middle-end 由 CentralFreeList 和 TransferCache 两部分组成,负责重新填充Front-end的Cache,并将空闲内存返回给 back-end;
  • back-end 负责管理从操作系统获取的内存,支持大内存和小内存的pageheap 管理。

下面将对内存组织形式和三个核心组成部分进行详细讲解,并说明在内存的分配和释放过程中各个部分的机制。

4.1.1 Pagemap 和 Spans

tcmalloc 管理的堆内存会在编译期间确定一个page-size,并将这么多内存映射为对应size的一个个page。一系列正在被使用的pages 可以被一个span 对象描述,一个span 对象可以管理一个大的内存对象,也可以按照size-class 管理多个小对象。

pagemap 则是用来查找一个内存对象属于哪一个span的,或者申请一个指定size-class 的内存对象,pagemap 根据32位还是64位是一个 2层或者3层的 radix-tree。下图展示了一个两层的 page-map 如何管理 span的,其中 span A 管理了2个page,span B 管理了三个page。

Span 这个数据结构在middle-end中用来管理回收的内存对象,在back-end 用来管理 对应大小的pages,负责给central-list 提供对应大小的span。

4.1.2 Front-end

Front-end 提供了Cache,能够缓存一部分内存用来分配给应用 ,也能够持有应用释放的内存。其同一时刻只能由一个线程访问,所以本身不需要任何的锁,这也是多线程下内存分配释放高效的原因。如果Front-end 持有的内存大小足够,其能够满足应用线程任何内存需求。如果持有的内存为空了,那它会从 middle-end 组件请求一批内存页进行填充。如果用户请求的内存大小超过了front-end 本身能缓存的大小(大内存需求),或者middle-end 缓存的内存页也被用尽了,那这个时候会直接让从back-end分配内存给用户。

针对小内存对象的分配,Front-end的cache 会按照其大小将其映射到 60-80个size-classes(size-class是front-end 分配内存的粒度)中的一个,实际的内存分配会按照该大小对应的size-class 的大小进行分配。比如12B 的对象会best-fit到16B 的size-class中。设置这么多的size-class 是为了尽可能得降低内存的浪费,比如原本的内存分配粒度都是2的n次幂,那对于23字节的内存需求就需要分配一个32字节的内存区域,而在tcmalloc的size-class的配置中只需要分配24字节即可。

对于大内存需求的对象来说,内存大小的需求超过256K,则会直接从back-end 分配。因此,这一部分的内存需求不会缓存再 front-end 以及 middle-end 中,由back-end的page-heap 进行管理。对于大内存对象的实际内存分配会按照tcmalloc page size 进行对齐。

如果要释放一个对象,编译期间如果能够知道这个对象的大小,编译器会直接告诉分配器这个对象的大小。大多数的时候,编译期间并不清楚对象的大小,会从pagemap中查找这个对象。如果这个对象是一个小内存对象,释放的时候会告诉front-end cache进行管理,如果是一个超过kMaxSize 的对象,则会直接释放给back-end 的 pageheap。

4.1.3 Middle-end

Middle-end 的主要作用为 font-end 提供内存申请需求,并将空闲内存返回给 back-end。Middle-end 对于每一个size-class,都会有有一个各自的 transfer cache 和 central free list。这一些caches 会有自己的互斥锁,所以对于这一些cache的访问, 因为锁粒度较低,则不会有过多的冲突,保证了访问的性能。

当 front-end 请求内存或者释放内存的时候,会先到达transfer cache。Transfer cache 会持有 一个数组指针进行内存的释放或者将新的内存对象填充进来并返回给font-end。Transfer cache会将一个cpu/thread 释放的内存分配给另一个cpu/thread 对内存的需求,这个类似于内存池的内存对象流动在两个不同的cpu/threads 之间可以非常迅速。

central free list通过 span 数据结构来管理内存,一个span可以管理一个或者多个tcmalloc page。Font-end如果从central free list请求一个或者多个内存对象的时候,central free list会从span中提取对应大小的对象,如果此时span 没有足够的pages返回,则会从back-end 请求更多的span。

当内存对象返回给central free list,则这一些对象会通过 pagemap 被映射到对应的span中进行管理,如果所有的对象都返回给span,这个span就可以被释放给back-end。

4.1.4 Back-end

tcmalloc的back-end 主要有三个工作线程,分别负责管理未使用的大块内存区域,负责从 os 中获取内存,来满足其他组件的内存需求以及负责将其他组件不需要返回的内存,还给os。还有两个后端组件:Legacy page heap和Huge page heap,分别负责管理tcmalloc 的pages和管理大页内存的 pageheap。Huge page heap可以用来提升应用程序大页内存的申请需求,提升页表缓存的命中率。

legacy pageheap是一个数组的freelist,统一管理可用内存页。数组的每一个节点是一个free list,也就是单链表。一般这个数组的大小 k < 256,对于第k 个数组元素来说,它的链表中的每一个节点都管理了 k 个pages。

如果想要申请 k 个pages,则直接查找这个数组的第k 个元素,从free list中取一个节点即可。如果这个free list是空的,那就查找下一个数组元素的free list,直到找到一个非空的free list。如果还是失败,则直接mmap 获取内存。

当一段连续的pages 被返回给了pageheap,则会根据当前pages 是否能够形成一个连续的pages区域,然后串联这一些pages 并根据page数量 添加到对应的free list中。

针对大页场景,Huge page heap能够有效持有 hugepage 大小的内存块,需要的时候能够快速分配,不需要的时候也能在合理的内存占用情况下释放给操作系统。

tcmalloc的back-end 拥有三个不同的cache 来管理大页内存的分配:

  • filler cache:能够持有hugepage ,并提供一些大页内存的申请需求。类似于legacy pageheap,通过一些free lists 管理pages 那样管理huge page,主要用于处理小于hugepage 大小的内存申请;
  • region cache:用于大于hugepage 大小的内存申请,这个cache 允许分配多个连续的hugepage;
  • hugepage cache:和region cache的功能有点重复,也是用于分配大于hugepage 的内存申请。

4.2 Per-thread mode

per-thread的cache模式是TCMalloc 名字 Thread-Cacheing malloc的由来。小内存的申请和释放会根据thread-cache的需求在middle-end 之间迁移。

每一个 thread-cache 内部不同size-class 对象会各自构造一个单链表(如果有 n 个size-classes,也就会有对应 n 个单链表),类似如下图:

分配某一个对应size-class 对象的时候,对应 size-class 链表对象会被从单链表移除(free-list),表示这个指针对应地址的内存可以被用户使用。释放对象的时候,则会将这个对象地址追加到thread-cache 管理的 size-class 的链表。在这个过程中,如果thread-cache 管理的内存不够,或者超限,则会从 middle-end 获取更多的内存对象或者将多余的内存对象释放给 middle-end。

对于per-thread caches来说,可以通过参数设置最大的可用内存大小。每一个线程有自己的最小的thread-cache大小 512K,如果当前线程内存申请需求较大,内存容量也会通过middle-end 将其他线程的可用内存迁移到当前线程。通过 middle-end 来协调当前的thread-cache 内存,通过ThreadCache::Scavenge()进行。如果当前线程退出,则会将自己的thread-cache 的内存返回给 middle-end。

Per-thread 模式下,cache 内部的最大存储对象容量 达到当前最大阈值时就会从middle-end 获取更多的对象,从而增大这个限制。降低最大限制的前提是发现了较多的未被使用的对象,则会将这一些对象根据需求还给middle-end。

4.3 Per-CPU mode

然而这种场景会随着用户线程的大量增加,出现了一些内存问题:每个线程只能有极小的thread-cache,需要消耗较多的CPU资源来聚合每个线程的内存资源。新的per-CPU 模式应运而生,这种模式下每一个逻辑CPU 会有自己的的thread-cache 用来为运行在这个cpu的现场分配内存。

Per-cpu mode和per-thread mode是tcmalloc的font-end 主体部分的两种模式。因为per-thread mode受到系统进程的线程数的影响,在大量线程的情况下会让每个thread-cache 只能够处理一小部分的内存申请释放需求,还会消耗大量的cpu 来 由middle-end 进行不同thread-cache 之间的内存迁移。

所以 tcmalloc 提供了优化版本的 per-cpu mode,即每一个逻辑核维护一个 cpu-cache,用来处理运行在当前核的线程的内存申请/释放需求,大体形态如下:

Per-cpu mode下会申请一个大内存块(也可以称为slab),这个slab 会被多个cpu共享,其中每一个cpu会持有slab 的一部分内存,并在其上存储一系列元数据管理对应线程的内存对象。

上图中的cpu1会管理 on slab 的绿色部分内存区域,在这一部分区域中会先存储一些元数据和指针来管理不同大小的 size-classes 的内存对象。其中元数据中包含一个header指针和每一个size-class 的索引block。

每一个size-class 的header 指针数据结构会有指向某一种实际存储内存对象的指针数组,即是一个数组,每一个元素是一个指针,总共是三个指针,分别指向这一种size-class 内存对象区域的起始地址块,当前地址块(后续分配这个size-class 大小对象的时候会从current 开始分配),最大地址。

每一个cpu 能够缓存的内存大小是通过SetMaxPerCpuCacheSize 配置的,也就是设置当前font-end 能够缓存的内存总大小取决去当前系统的cpu核心数,拥有更好核心数的机器使用tcmalloc 能够缓存更多的内存。当每一个cpu cache 的内存被分配耗尽,想要从 middle-end 获取内存来缓存更多的对象时,也需要考虑对size-class进行扩容。如果这个size-class 的内存分配需求还在持续增加,则这个size-class的容量会持续增加,直到达到这个size-class 容量的hard-code。

Per-cpu 模式下,增加cache 容量的前提是当前cache 是否在频繁的从 middle-end 获取内存 以及 释放内存交替,则需增加容量限制,有更多的空间来缓存这一些内存对象。降低容量限制的前提是发现有一些空闲容量长时间没有被使用。

4.4 部分参数解析

部分主流的tcmalloc配置参数有:

  • TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES: 限制每个线程本地缓存的最大总大小;
  • TCMALLOC_LARGE_ALLOC_REPORT_THRESHOLD: 内存最大分配阈值;
  • TCMALLOC_SAMPLE_PARAMETER : 采样时间间隔;
  • TCMALLOC_RELEASE_RATE:用于控制tcmalloc内部的内存回收速率。

4.5 特性分析

  • 高性能:TCMalloc在内存分配和释放过程中,大多数情况下都能够避免产生过多的竞争。这是因为它维护了线程本地缓存(thread-cache),以满足当前线程的内存分配需求。这意味着在许多情况下,应用程序的内存申请不会涉及到锁的竞争,尤其是在多核处理器的环境下,TCMalloc表现出色,并具有良好的可扩展性;
  • 智能内存资源管理:TCMalloc能够灵活地管理内存资源,当用户不再需要内存时,TCMalloc会智能地选择是重新利用这些内存还是将其归还给操作系统,以便更有效地利用系统资源;
  • 降低内存开销:通过以页面(page)为单位进行内存分配,TCMalloc降低了每个请求的内存开销。这种优化在处理小对象时特别有效,有效地减少了内存浪费;
  • 低开销的内部信息统计:TCMalloc具有低开销的内存使用信息统计功能,可以以细粒度的方式跟踪应用程序的内存占用情况。这有助于用户更详细地了解TCMalloc内部的内存使用情况,从而更好地进行性能调优和资源管理。

5 实践

5.1 背景

当前转转的服务中,很多都存在堆外内存较高的现象。以转转搜索排序服务为例,目前线上服务jvm堆内存上限为18G,服务内无堆外的特征缓存等数据,堆外内存的大小超出预期,很多物理机的内存已经开始告急,初步判断原因为相关的服务中用到了tensorflow进行推断,linux默认使用的glibc的malloc实现在内存池资源回收上存在缺陷,导致申请过的内存无法还给操作系统,上文的梳理和特性分析体现出主流的两种非原生malloc:tcmalloc和jemalloc都可能会改善这一状况,因此下面对三种不同的malloc进行实践分析。

5.2 准备工作

1.安装jemalloc和tcmalloc:

  • tcmalloc:需要提前安装libunwind和gperftools(自带tcmalloc)并配置环境变量生效,下文中的实验采用的是gperftools2.10和centos7;
  • jemalloc:下载最新版本的jemalloc,编译安装并配置环境变量即可,下文中的实验采用的是jemalloc5版本。

2.在服务启动时,终端设置export LD_PRELOAD={对应malloc.so文件的路径}和export MALLOC_CONF={指定的malloc参数}后,重新终端启动服务即可生效。其中jemalloc经过多次尝试,选择了利用MALLOC_CONF配置参数:background_thread:true,metadata_thp:auto,dirty_decay_ms:30000,muzzy_decay_ms:30000来获得最佳的表现(其含义见下表),而tcmalloc和ptmalloc经过测试,默认的参数表现最佳,3个malloc的最佳参数的测试过程同样是采用压测+线上对比来进行的,较为繁琐所以不再赘述。

参数 含义
narenas 默认为ncpus的四倍,用于设置线程独占的 arena数量。
dirty_decay_ms与muzzy_decay_ms 控制内存页的过期时间。jemalloc使用一种延迟回收策略,根据指定的时间段将内存页从"dirty"状态(已经写入)转换为"muzzy"状态(未写入),然后再回收
background_thread 启用后台线程。jemalloc支持后台线程来定期处理内存释放操作,这可以降低内存碎片并提高性能。启用此参数后,jemalloc将自动创建和管理后台线程。
tcache 禁用tcache(thread-local cache)。tcache是jemalloc的一项特性,用于线程本地的内存分配缓存。通过禁用它,您可以在一定程度上减少jemalloc的线程局部性,适用于高并发场景或者特定需求下。
percpu_arena 启用每个CPU核心的独立内存池。这可以提高多核系统中的内存分配性能,减少了多核之间的锁竞争。

5.3 压测表现

在隔离环境进行服务压测,分别对存在较高内存占用的转转搜索排序服务更换3种malloc进行10分钟的多线程压测,分析耗时和内存占用表现。下图从左至右分别是原生malloc tcmalloc jemalloc 压测过程中的内存表现:

下图从左至右分别是原生malloc tcmalloc jemalloc 压测过程中的耗时表现:

根据实践结果可以得出分别使用三种malloc在此服务上进行等条件压测,压测过后常驻内存增加了2.51g,而tcmalloc和jemalloc都只增加了0.5g左右,回顾ptmalloc2的内存分配和回收机制,有多种原因单一或者同时导致了这一现状:

  • 由于原生的ptmalloc2自身的内存释放机制的不足,如果多线程使用内存不均衡,容易导致内存的浪费。比如说线程1使用了300M内存,完成任务后glibc没有释放给操作系统,线程2开始创建了一个新的arena,但是线程1的300M却不能使用;
  • 与此同时,每个chunk至少需要8字节的开销;
  • top chunk的释放机制收缩内存是从 top chunk 开始,如果与 top chunk 相邻的 chunk 不能释放, top chunk 以下的 chunk 都无法释放。

与此同时,在服务初始化完成后,tcmalloc也拥有更好的内存占用表现,可能的原因有:

  • 由于tcmalloc相比jemalloc还需要少一些额外的内存开销;
  • ThreadCache会阶段性的回收内存到CentralCache里来进行分发,在初始化阶段线程较少时占用的内存会更低。

而在耗时分析中,tcmalloc小幅优于ptmalloc和jemalloc但是差距不大,而tcmalloc表现最优可能和线程局部缓存在并发的环境下,因为每个线程可以独立地管理自己的内存分配,所以减少了线程之间的竞争,jemalloc在耗时方面表现不佳可能的原因是没有找到更合适的参数,还需要进一步的分析和实践。

5.4 线上表现

在线上环境进行对照实验,分别对两台内存和cpu占用率一致的物理机上的转转搜索排序服务同时进行重启,绿色的是更换tcmalloc的,而黄色的是原生的ptmalloc2,下图可以观察到服务启动后接入真实流量后内存的变化:

采用原生malloc的物理机上的服务,在初始化完成就占用了较多的内存,同时随着处理请求的过程,内存发生了较大的增长,而采用tcmalloc的物理机上的服务,则在初始化完成时就有更好的内存占用表现,在后续的处理请求的过程内存占用的增量较低,且出现了明显的内存释放过程。

5.5 实践结果

在上述的实践后,将tcmalloc部署至至搜索推荐的所有服务,平均每台机器得到了10g以上的内存减少量,经过上文压测和线上的实践得出,选择合理的malloc及其参数对服务的耗时和内存占用表现有着很高的影响,耗时情况也保持整体持平。

6 总结

本文主要是结合各类资料梳理了三种linux下常用的malloc:ptmalloc,tcmalloc的整体架构,以及对jemalloc的参数讲解,概念简介,内存分配和回收过程,并给出常用的参数的解析说明以及特性分析,限于篇幅,只对jemalloc进行了参数讲解,并分享了上述malloc在转转的搜索排序主服务上的实践经验,最终降低了大量的服务器内存占用,获得了不错的收益,这表明了我们应该根据服务的场景,不只考虑于jvm的参数调整,还应该选择适当的malloc并调整参数来达到性能和内存占用的平衡,不断在技术上精进自己,追求卓越。

7 参考文献

  1. zhuanlan.zhihu.com/p/613696274 Linux mmap内存映射;
  2. zhuanlan.zhihu.com/p/649511901 Linux内存分配之brk与mmap;
  3. zhuanlan.zhihu.com/p/645312749 Linux内存管理(三)--内存分配之malloc;
  4. zhuanlan.zhihu.com/p/448293503 malloc的底层实现(ptmalloc);
  5. zhuanlan.zhihu.com/p/537042335... 源码剖析2 -- 设计哲学;
  6. google.github.io/tcmalloc/de...
  7. zhuanlan.zhihu.com/p/653320304 内存分配器:TCMalloc 基本设计原理详解;
  8. developer.aliyun.com/article/604... tcmalloc浅析;
  9. zhuanlan.zhihu.com/p/642471269 内存管理特性分析(十五):内存分配器之jemalloc技术原理分析。

关于作者

刘子涵,转转搜索推荐基础建设研发工程师

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
yl--炼气6 分钟前
windows下wsl-ubuntu子系统的位置怎样从C盘转到其他盘
linux·运维·ubuntu
来自星星的坤13 分钟前
SpringBoot 与 Vue3 实现前后端互联全解析
后端·ajax·前端框架·vue·springboot
AUGENSTERN_dc19 分钟前
RaabitMQ 快速入门
java·后端·rabbitmq
博睿谷IT99_33 分钟前
红帽认证 Linux安全 级别
linux·运维·安全
烛阴1 小时前
零基础必看!Express 项目 .env 配置,开发、测试、生产环境轻松搞定!
javascript·后端·express
燃星cro1 小时前
参照Spring Boot后端框架实现序列化工具类
java·spring boot·后端
Cv打怪升级1 小时前
ubuntu 常用指令
linux·运维·ubuntu
用手码出世界1 小时前
【Linux】进程池bug、命名管道、systemV共享内存
linux·运维·bug
正点原子2 小时前
【正点原子STM32MP257连载】第二章 ATK-DLMP257B使用前准备 #串口软件 #MobaXterm
linux·stm32·单片机·嵌入式硬件
MobiCetus3 小时前
Linux Kernel 7
linux·运维·服务器·windows·ubuntu·centos·gnu