一、背景
这篇博客,我们继续之前的 由jemalloc 5.3.0初始化时的内存分配的分析引入jemalloc的三个关键概念及可借鉴的高性能编码技巧-CSDN博客 博客里对初始化分配逻辑进行分析,已经涉及到了jemalloc 5.3.0里的非常重要的base模块的一部分逻辑,在这篇博客里,我们进一步展开分析base模块,针对的场景也依然是初始化分配逻辑这块来作为切入口。
在第二章里,我们先顺着之前的博客 跟踪jemalloc 5.3.0的第一次malloc的源头原因及jemalloc相关初始化细节拓展-CSDN博客 里 3.2.4 里重点提到的malloc_init_hard函数,继续展开分析,在 跟踪jemalloc 5.3.0的第一次malloc的源头原因及jemalloc相关初始化细节拓展-CSDN博客 博客里,我们分析了初始化逻辑里的tsd模块的初始化逻辑,在 由jemalloc 5.3.0初始化时的内存分配的分析引入jemalloc的三个关键概念及可借鉴的高性能编码技巧-CSDN博客 博客里,我们讲到了几个关键概念group、delta、class还有sz.h里几个常用的psz,size,index之间的转换函数及相关含义,有了这两篇的基础,base模块的详细分析相对容易一些。
我们在第二章,我们依然以malloc_init_hard里内存分配逻辑作为切入口,来展开详细分析base模块,对于相关联的概念也会一一进行介绍,在第三章里,我们会用思维导图来汇总一下并附上总结说明。
二、依然以malloc_init_hard里的内存分配逻辑作为切入口,展开详细分析base模块
2.1 base_boot里的2M的分配用的是base_block_alloc函数,是base模块的分配函数
jemalloc 5.3.0的第一次内存分配的地方就是这个malloc_init_hard_a0_locked里的base_boot函数最终调用的base_map进行的2M的分配。
这块我们在之前的博客 由jemalloc 5.3.0初始化时的内存分配的分析引入jemalloc的三个关键概念及可借鉴的高性能编码技巧-CSDN博客 里已经做了一些介绍,这篇博客里需要再展开分析一下。
base_boot有关的调用链是:
malloc_init_hard->malloc_init_hard_a0_locked->base_boot->base_new->base_block_alloc->base_map
我们先说一下base模块是什么?用来干什么?
2.2 base模块是一个metadata数据的一个分配器
base模块的文件就两个base.h和base.c。
base模块是一个metadata数据的一个分配器。那么什么是metadata数据呢?metadata就是元数据,也就是数据的数据。
base模块只分配元数据的内存,而并不分配malloc的客户所要的内存,也就是说,malloc的使用者所要的内存是由jemalloc里的base分配器以外的其他的底层内存分配器来分配的,这一点我们下面会用堆栈截图来证明。
2.2.1 三个用于分配的base接口都可能会调用到base_block_alloc函数
base模块用于分配的主要接口是三个,base_new函数和base_alloc和base_alloc_edata,其中base_alloc和base_alloc_edata函数,其中base_new用于base的初始化(base本身也是一个元数据),另外两个,则是在指定的base里分配元数据,当然,虽然说在指定的base里分配,但是还是可能按需扩容的,并不是说base_new分配出内存了以后,base_alloc和base_alloc_edata就不会分配新的内存了(关于非base分配器来分配出来的调用链例子在下面 2.4 一节里会讲到)。这三个函数在需要分配新的内存时都会调用base_block_alloc函数,这个函数我们会在后面详细展开描述。
下图是base_new函数调用base_block_alloc函数的截图:
base_alloc和base_alloc_edata,这两个函数都会调用base_alloc_impl:
而base_alloc_impl会在判断出当前空间不够时调用base_extent_alloc进行更多内存的分配:
而base_extent_alloc就会调用base_block_alloc进行内存分配:
2.2.2 分配元数据的base_alloc_edata接口相比base_alloc接口需要在base_alloc_impl时取回sn号,设到base_alloc_edata接口返回的是edata_t指针里去
base_alloc和base_alloc_edata两个函数的主要区别在于base_alloc_edata需要在base_alloc_impl时取回sn号,也就是序列号,并设到新创建的edata_t这个元数据里。
这个sn号是base实例管理的,在base结构初始化时设置成0:
在base_new里创建完元数据后,把上图里的extent_sn_next记录到了base实例里:
base_new里调用base_block_alloc创建元数据时会把传入的表示sn号的指针指向的值加1:
base_block_alloc里调用了base_edata_init函数:
在base_edata_init函数里进行了+1:
你可能会问,这个多出来的sn号有什么用,在下面的 2.7.1 里会讲到。
2.3 base模块分配了哪些元数据?
我们列一下base模块分配的元数据的种类,列出的都是相对重要的元数据,用一次调用栈例子来说明(当然分配的同一种元数据对应的调用栈也可能是不一样的,我们只是举其中的一次调用栈来说明)。
2.3.1 base模块首先会分配base_t实例,也就是base自己这个元数据
上图的堆栈是main之前的第一次malloc的调用,这次malloc的调用我们在之前的博客 跟踪jemalloc 5.3.0的第一次malloc的源头原因及jemalloc相关初始化细节拓展-CSDN博客 里也说明了是因为preload的jemalloc库时由于jemalloc的实现里包含了C++的内容,所以需要在main之前的初始化流程里做相关的C++的异常处理用的pool的分配。而上图中的堆栈,是由这一次分配触发,判断出整个jemalloc还未进行初始化,所以调用了malloc_init_hard接口(关于malloc_init_hard接口我们之前的博客里 跟踪jemalloc 5.3.0的第一次malloc的源头原因及jemalloc相关初始化细节拓展-CSDN博客 的 3.2.4 分析过一部分),这个函数会间接调用base_boot来初始化第一个base实例,base_boot继而调用了base_new,base_new继而调用了base_block_alloc进行了分配,base_block_alloc使用base_map,base_map调用pages_map,注意,pages_map是base分配器以外的其他jemalloc内存分配器都会用到的一个接口,定义在src/pages.c里,它并不属于base模块。
看base_boot的返回类型就可以体现它分配的就是base_t这个元数据实例:
0是第一个base,第一个base由一个src/base.c里的上截图的static base_t *b0变量来保存的。
2.3.2 base模块会分配提供静态的tcache的tbins信息的cache_bin_info_t数组
cache_bin_info_t数组的首地址定义如下:
它需要动态分配因为其大小不可静态确定。
相关的核心调用代码截图:
可以看到上图里的这次分配大小不大,就82字节,n_reserved_bins是41,41来自于nhbins,因为nhbins比SC_NBINS的值大(SC_NBINS是36):
相关的上下文调用链如下:
2.3.3 base模块会分配管理arena的arena_t的内存
arena是一个内存分配区,不同的线程一般属于不同的arena,默认情况下arena的个数是cpu核心数*4。
base模块分配arena实例的代码逻辑如下:
如上图可以看到一个关于arena实例分配的细节:
arena_t的大小是一个动态值,因为arena_t用了柔性数组:
相关base分配arena的调用链截图:
要注意,这次main之前的这个arena的分配由于分配的空间不大,用之前base分配出来的内存池子里的剩余空间就足够了,所以这次分配并没有触发base_map及底下分配接口。关于从base里挑选可用的空间来进行分配的逻辑,见 2.7 一节。
2.3.4 base模块会分配用来管理实际使用数据内存块的edata_t管理结构
先说明一下edata,它是管理的实际使用的数据内存块,这么说,edata还是一个元数据的数据结构,它管理对应的内存分为两种,一种是用户使用malloc接口触发进行分配的内存,另一种是jemalloc实现的内部逻辑调用泛iallocztm或泛ipallocztm的接口进行内部使用的内存分配。
jemalloc对于不同大小的内存会有不同的策略,对于小size的class,jemalloc会预分配更多块这样的小块,从而组成一个大块的内存块,这个大块的内存块是page size的整数倍。当然对于大size的class,jemalloc就不会预分配这样的内存块了。而edata_t就是管理这样的整块内存块的数据结构。
下图的场景仍然是main之前的在做初始化时的调用栈,下图是在初始化tsd的tcache data的过程中分配各个size class的tcache_bin用的stack_head指针数组的内存时,需要在具体分配该size的内存对应的内存块的分配前(2.4.1 一节的截图流程),先分配该size class的内存块对应的元数据edata数据结构。
上图里的base_alloc_edata所调用base_alloc_impl分配的大小是很小的,如下图:
就128字节:
和 2.3.2 同样的,由于分配的大小很小,所以直接从base里的当前的block块里剩下的内存里扣出一块出来就可以了,它并不会触发base_map及其他底层分配动作。
在启动过程中,关于edata_t管理结构分配的另外一个场景是在tsd_tcache_data_init时在进行分配各个size class的tcache_bin用的stack_head指针数组的内存对应的内存块时,发现通过ehooks_alloc分配出来的一大块extent内存块后有不用的多出来的部分,这部分剩下的内存也需要创建对应的edata管理结构,在创建该剩下的内存的edata管理结构时走到的edata_cache_get再到base_alloc_edata再到base_alloc_impl里,而这个最后三级函数的调用是和刚才说的场景是一致的,在extent_grow_retained函数内到最后三级函数edata_cache_get->base_alloc_edata->base_alloc_impl中间还有两级调用,为extent_split_interior->extent_split_impl,有关完整调用链可见第三章的调用链图。
2.3.5 base模块会分配用来管理关联每个edata信息所需要的radix tree所用到的叶子结构的内存
jemalloc里有个全局唯一的管理关联每个edata信息的radix tree,即jemalloc里的rtree,这个rtree需要用到rtree_leaf_elm_t结构,所需要的该结构体的数量即radix tree的一层一层的数量,这个数量是按照索引项的bit来决定的,一层是18bit,如下图:
所以一层用到了1<<18=26144个,如下图看到rtree_levels里就两层:
这种情况所触发的函数,如下图,是在rtree_leaf_init函数里:
调用链(也是在初始化时场景):
由于这次分配的大小较大:
所以需要通过pages_map来向os要内存:
上图里的pages_map实际做分配的size是在这次分配的调用链里是由base模块里的pind_last变量来管理维护的,每次分配都要+1,因为+1后,得是HUGEPAGE_CEILING进行2M的对齐,所以就变成了分配4M了,有关pind_last的逻辑具体细节可参考之前的博客 由jemalloc 5.3.0初始化时的内存分配的分析引入jemalloc的三个关键概念及可借鉴的高性能编码技巧-CSDN博客 里 2.2.5 一节。
2.3.6 base模块会分配background线程所用到的background_thread_info_t数据结构
相关的调用截图和调用链截图如下:
2.4 非base分配器分配出内存的调用栈例子
这一节并不是本文的重点,我们并不过多展开,只是把相关调用栈贴出并描述相关调用场景。
这里说的其他分配器,是指离调用os的mmap接口(jemalloc默认只用mmap进行内存分配)进行的内存分配非常近的调用层次的分配函数。jemalloc里pages_map接口封装了这样的os的mmap接口的内存分配,作为一个接口给jemalloc里较底层的分配器所使用。所以,无论是base分配器还是其他的下面会讲到的分配器,最终还是会调用到pages_map接口。
2.4.1 使用extent_alloc_core分配函数
下图调用链是最终用到了extent_alloc_core函数,而extent_alloc_core函数和base_map一样也是调用的extent_alloc_mmap函数,下图调用链所在的场景是在初始化tsd的tcache data的过程中分配各个size class的tcache_bin用的stack_head指针数组的内存对应的内存块,大小在对齐PAGE_SIZE后是32768字节:
上截图也是在初始化流程中,是在 2.3.4 截图流程之后,在 2.3.5 截图的流程之前。
2.4.2 其他调用pages_map的分配器
调用extent_alloc_mmap进行分配的函数就base_map和 2.4.1 说的extent_alloc_core两个。
但是使用pages_map进行分配的函数还有一些,如hpa模块:
hpa_hooks模块:
这里先不展开了。
2.5 base_block_alloc函数有点像分配一个内存池子,每个base都至少有一个内存池子
我们回头来说base_block_alloc函数,上面讲到,base模块里的最终向操作系统去做分配的最终都汇总到了调用这个base_block_alloc函数里。
这个base_block_alloc函数有点像是预分配一个内存池。后面的大大小小的分配都需要在这个池子里进行再分配(只是说这个内存池的管理是按照之前博客说的group delta这样管理的),当然这个池子是可以变大的,但是每一次变大的最小颗粒度很大,之前的博客也说了是至少分配2M,且一次分配比一次分配大,参考之前的 由jemalloc 5.3.0初始化时的内存分配的分析引入jemalloc的三个关键概念及可借鉴的高性能编码技巧-CSDN博客 博客里的 2.2.5 一节。
刚才说的这个内存池子的分配就是下图里的通过base_block_alloc函数得到的base_block_t:
可以从下图中看到,分配出来的block塞到了base_t结构体里的block链表里(下图是因为是第一个block,所以直接赋值就行了):
如果是新增block,则是下图里的红色框逻辑的逻辑(目前新增block只会被base_extent_alloc函数用到):
因为同一个base实例而言,可以新关联base_block_t,所以就有这一节标题里说的每个base都至少有一个内存池子,意思就是可以有大于一个内存池子。
2.6 base_block_alloc函数分配出来的池子是对应base编号的,且base编号是和arena的编号是一一对应的
1)所谓的base_block_alloc函数分配出来的池子是对应base编号的,意思就是base0至少有一个内存池子,如果有base1的话,那么base1也至少有一个内存池子,如果一个base里发现内存不够,可以新申请一个内存池子再与这个base关联。每笔使用base_block_alloc函数进行的内存分配,都需要明确一个所属的base。
如下图,传入给base_block_alloc函数的unsigned ind就是这个base编号:
0是第一个base,第一个base由一个base.c里的static base_t变量来保存是:
2)刚才说的base编号是和arena的编号一一对应的。下图是代码里能体现逻辑上arena的index对应于base的index的代码细节:
3)顺便提及一个arena的细节,对于大size的内存分配,jemalloc把它归到了最后一个index的arena里,即,如果cpu的数量是20,大内存分配用的是最后一个arena即arena80(假设arena0是第一个)。
这块细节我们在以后的博客里会讲到。
2.7 base里挑选可用的空间来进行分配的逻辑
base里的这个挑选的逻辑就一处,在base_alloc_impl里,这个base_alloc_impl就是上面讲过的两个分配元数据的接口base_alloc和base_alloc_edata必经调用到的。
2.7.1 “挑选”逻辑用到了配对堆 Pairing Heap
在base_alloc_impl里,有下面这段逻辑做所谓的“挑选”:
上图里的红色框出的逻辑里,核心函数edata_heap_remove_first是使用了配对堆的数据结构,如下图看到base的avail数组就是配对堆的数据结构:
base_alloc_impl的实现就是从base里的最多SC_NSIZES个配对堆里:
从最小的可能满足size大小的配对堆往size更大的堆去找,直到遇到非空的配对堆就返回堆里最小的元素。至于如何判断堆里元素谁大谁小,见 2.7.3 一节。
我们先看一下配对堆的宏定义和使用。
2.7.2 配对堆的宏定义和使用
jemalloc里除了base模块里的内存管理以外,数据内存块的管理者edata_cache模块和huge page的管理模块hpdata也使用到了配对堆,所以,配对堆的声明总共有3处,两处在edata.h里,另外一处在hpdata.h里:
其中,base模块用的是edata_heap。
配对堆的定义在各自的.c里,如base模块用到的edata_heap是定义在edata.c里,如下图显示了base模块用到的配对堆关联的配对函数edata_snad_comp:
配对堆的一系列函数的函数名是由大量的宏拼接形成的,如下:
2.7.3 base用到的配对堆如何比较谁大谁小?
我们看一下决定base模块用的配对堆里的元素,决定谁大谁小的逻辑,上一节也说到了,该函数是edata_snad_comp:
可以看到它是先比较edata的sn号,关于edata的sn号,我们在上面的 2.2.2 一节里说到了是如何生成的。
比较完sn号,如果是一样的话,再去比较地址大小。
这么比较是为了让早分配的内存更早被释放,可以减少内存碎片。
2.7.4 base分配时选到的配对堆元素可能大于实际需要的大小,相关的塞回配对堆的逻辑介绍
从上面的 2.7.1 一节里就可以看到,base分配时选到的配对堆元素可能大于实际需要的大小。
我们看一下相关的塞回配对堆的逻辑在哪里,base模块用的是base_extent_bump_alloc_post进行的塞回动作。
下图是base_extent_bump_alloc_post的选择塞哪里的逻辑:
可以看到是用的floor,也就是在塞的时候,会塞到它实际所属的index的下一个,这样在分配时拿到的元素肯定都能满足当前这个delta组里的所有size的情况。关于delta以及group等概念见之前的博客 由jemalloc 5.3.0初始化时的内存分配的分析引入jemalloc的三个关键概念及可借鉴的高性能编码技巧-CSDN博客 里的 2.2.4 一节。
三、malloc_init_hard时的所有内存分配动作的调用流程图
细心的同学会发现,上面第二章里的所有截图出来的堆栈调用链都是在malloc_init_hard下的函数调用的。
我们把这个期间的所有的内存分配的逻辑调用都在上面一一列出来了,进行了分析。
下面是一张整图,如果需要再看得清楚一些可以参考我发布的资源里的图,资源链接 https://download.csdn.net/download/weixin_42766184/90385037。