内存分配器之 arena
今天,就开始逐步更新剖析 RocksDB 源码的博客。思前想后,准备自下而上,因此还是从内存分配器开始叭。
在 RocksDB 中主要有两类内存分配器MemoryAllocator、Allocator。
MemoryAllocator
MemoryAllocator 是个基类,RocksDB 提供了两个子类:MemkindKmemAllocator、JemallocNodumpAllocator,而这两个子类,实际上分别是 memkind、jemalloc两个开源库的 wrapper,即利用两个开源库的函数来实现Allocate、Deallocate操作。jemalloc后者有时间再开专题专门细解,这里就不展开说了。
1 | class MemoryAllocator { |
Allocator
本节主要详细讲解Allocator及其子类Arena的实现。
类 Allocator 是个基类,主要有两个接口:
Allocate:分配无须对齐的内存;AllocateAligned:分配需要经过对齐的内存;
关于内存对齐,可以参考之前写过的一期博客 [内存对齐之 alignof、alignas 、aligned_storage、align 深度剖析],在这一期,通过讲解Allocator,会更加深刻的理解内存对齐。
1 | class Allocator { |
基类Allocator 有两个子类,Arena 和concurrent_arena,分别用于单线程和多线程内存分配,其中 concurrent_arena 也是个Arena的wrapper,外加了一些措施,保证Arnea在多线程下的安全。因此,本文先详细讲解下Arena,下一节再把注意力集中在concurrent_arena`的多线程设计上。
Arena
类Arena 在分配内存时,是以 block 为单位,即每次先分配一个block大小的内存,后续所需bytes大小的内存时,会先尝试从block 中获取,如果这个block中剩余的可用内存能满足bytes,则从block中划出一部分给上层使用,否则才从操作系统中获取。
一个block容纳的内存大小,由kBlockSize参数来指定。
现在Arena怎么实现基类中Allocate、AllocateAligned两个接口?
Arena 中有两个指针:aligned_alloc_ptr_、unaligned_alloc_ptr_,当一个block的内存创建完毕时:
aligned_alloc_ptr_:指向该block的首地址(低地址),后续用于分配需要对齐的内存;unaligned_alloc_ptr_:指向该block的末地址(高地址),后续用于分配不需要对齐的内存。
示意图如下:
1 | block : [-------------------------] |
为啥要这么做?
由于每次内存对齐操作可能存在一定的浪费,而同一个类中所需的内存对齐大小一般是固定的,因此从blokc的一端只分配需要对齐的内存,若内存对齐大小是固定的,那么每次分配的内存也都是连续的,如此就可以减少因为内存对齐带来的浪费。
从block的另一端分配无需对齐的内存,还能提高内存利用率。
比如,block的大小为7个字节,分配一块需要4字节对齐的内存,还剩下3字节的内存,可以继续用于无须对齐的内存。
下面先整体看看类Arean。
在下面的源码分析中,做了一些简化,去除了一些统计内存大小部分的代码,读者可自行追溯RocksDB中该部分的源码。
1 | class Arena : public Allocator { |
Arena::Arena
先来看看构造函数 Arena::Arena。这里,主要初始化一些类成员变量:
kBlockSize字段
kBlockSize,表达的是以后每次分配一个block时的内存大小。在初始化之前,使用OptimizeBlockSize函数对传入的参数block_size进行限制,使其满足后续的assert判断。通过
OptimizeBlockSize函数限制block_size的值,减少后续分配需要对齐的内存浪费。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const size_t Arena::kMinBlockSize = 4096; // 最小为 4k
const size_t Arena::kMaxBlockSize = 2u << 30; // 最大为 8k
static const int kAlignUnit = alignof(max_align_t); // 按照 8 byte 对齐
size_t OptimizeBlockSize(size_t block_size) {
// 确保满足: Arena::kMinBlockSize <= block_size <= Arena::kMaxBlockSize
block_size = std::max(Arena::kMinBlockSize, block_size);
block_size = std::min(Arena::kMaxBlockSize, block_size);
// 确保 block_size 是 8 的倍数
if (block_size % kAlignUnit != 0) {
block_size = (1 + block_size / kAlignUnit) * kAlignUnit;
}
return block_size;
}inline_block_字段
inline_block_,其中的inline语义是指这块内存分配在栈上,在地址上和Arena是连续的。这对于那些只需要分配小内存的操作具有优势,可以避免从堆上分配内存。当这块内存使用完毕,会再从堆上获取。hugetlb_size_字段
hugetlb_size_,是默认情况下使用mmap给block分配的内存大小。如果hugetlb_size_ == 0,则表示不使用mmap分配内存。当然,
hugetlb_size_在构造函数中也是经过向上取整操作,变为 8 的倍数。
现在,可以很好的阅读构造函数 Arena::Arena 了。
1 | Arena::Arena(size_t block_size, |
Arena::AllocateNewBlock
AllocateNewBlock 函数比较普通,本质上就是使用 new 操作从操作系统获取内存。
这里面稍微有个trick的操作,是blocks_.emplace_back(nullptr); 这行,作用是在blocks_中预留一个指针大小的空间,即 sizeof(Block*),出于内存泄露的考虑,使用了emplace_back,而不是reserve函数。
RocksDB 解释如下:
- 如果
emplace_back函数抛出异常,不会发生内存泄露,因为此时还没使用new分配内存; - 如果
new抛出异常(即std::bad_alloc异常),也不会发生内存泄露,因为在blocks_中预留的空间将会基于RAII语义被清除。
AllocateNewBlock 函数,直接使用new操作分配内存,不负责进行内存对齐操作。如果需要按照某个alignment大小进行对齐,得调用 AllocateAligned 函数,这个后面再说。
1 | ///@return 返回指针,指向当前分配的 block |
下面来解释下为何使用reserve可能会导致内存泄露,写法如下。
1 | char* Arena::AllocateNewBlock(size_t block_bytes) { |
理由很简单:上面这个写法,如果在emplace_back的过程中抛出异常,那么 block 指向的内存将会泄露。
尽管emplace_back抛异常这种bad case极少出现,但也应该为RocksDB的细致点赞。
Arena::AllocateFromHugePage
Arena 中有两种方式从操作系统获取内存:
new:即如Arena::AllocateNewBlock函中数的实现;mmap:即如Arena::AllocateFromHugePage函数中的实现。
mmap、munmap函数的原型如下。
1 |
|
使用mmap分配内存,需要linux内核支持 HUGE_PAGE,即当前linux内核具有 MAP_HUGETLB 标志位, 即可以如下操作。
1 | ///@return 返回指针,指向当前分配的 block |
Arena::AllocateFallback
好嘞,介绍完上面两种分配内存的措施,现在来看看统一上述两个操作的AllocateFallback函数。
当调用AllocateFallback函数时,是上层发现当前block中剩余的内存无法满足bytes个字节的需求,需要重新从操作系统获取内存。运行到AllocateFallback函数时,逻辑如下:
如果所需的内存大小
bytes超过了kBlockSize / 4,就直接从操作系统中获取bytes大小的内存,返回给AllocateFallback函数的调用方。那么就能避免浪费当前 block 中剩余的内存,这部分可以继续保留,供给下一次内存分配时使用。否则,当前block中剩余的内存就将会被抛弃,重新从操作系统获中分配一个block的内存,供给上层使用。
此时,由
bytes <= kBlockSize / 4,因此也降低了浪费,
现在,顺着代码注释往下看。
1 | char* Arena::AllocateFallback(size_t bytes, bool aligned) { |
Arena::AllocateAligned
最后,就是对外提供分配对齐内存的函数 AllocateAligned。
其输入参数huge_page_size,语义是在使用mmap分配内存时的自定义 alignment,如果 huge_page_size 为 0,只是表示此时不需要自定义的alignment,使用默认的kAlignUnit即可。
换句话说,当需要自定义alignment时,RcoskDb 是准备使用mmap来分配内存。
当使用默认的kAlignUnit,如果当前block中剩余的可用内存能满足need个字节(need个字节,是bytes字节按照kAlignUnit对齐的后的大小),则继续从当前block中划分出去need个字节,否则就使用上述的AllocateFallback函数,重新从操作系统分配内存。
现在,顺着代码注释向下看。
1 | char* Arena::AllocateAligned(size_t bytes, |
by the way
这里稍微总结下,如何向上调整、计算对齐后的地址。
实际上计算的方法有很多种,但是都符合上一期 [内存对齐之 alignof、alignas 、aligned_storage、align 深度剖析] 最后提到了一点:本质上就是以「alignment进制」向上(下)取为alignment的整倍数。
比如,在十进制下,12向上取整为10的倍数,即20。
理解了这一层之后,再来看看下面几个计算方式:
case 1:
size_t aligned = ((bytes - 1U) / alignment + 1U) * alignment单纯地将
bytes向上调整为alignment的整倍数,可以实现为(bytes / alignment + 1U) * alignment,那为啥要- 1呢?如果
bytes本身就已是alignment的整数,按照这 native 实现,会无端将bytes增加了alignment。为了应对这种情况,需要先减少1。此外,在
Arena::Arena中调整hugetlb_size_时,也是如此计算。而在OptimizeBlockSize函数中,调整block_size时,可以确定block_size不是kAlignUnit的整数时,就可将-1去掉,简化如下:1
2
3if (block_size % kAlignUnit != 0) {
block_size = (1 + block_size / kAlignUnit) * kAlignUnit;
}case 2:
size_t aligned = (bytes - 1u + alignment) & -alignment这种实现,利用位位运算将余数清除,效率较高,这也是
std::align函数的实现方式,这在 [内存对齐之 alignof、alignas 、aligned_storage、align 深度剖析] 分析过,这里顺带再解释下。其中
alignment是无符号数,-alignment的值实际上是::pow(2, n) - alignment。那么bytes - 1u + alignment对-alignment取&操作,就能保证bytes - 1u + alignment的高位不变,而小于alignment的余数全部清除。
万变不离其宗,理解了这个逻辑,无论是向上调整、亦或是向下调整,都能很好理解了。
Arena::Allocate
讲完了上面AllocateAligned函数之后,再看Allocate函数,就非常好理解了。
1 | inline char* Arena::Allocate(size_t bytes) { |
Arena::~Arena
释放所有已分配的内存。
1 | Arena::~Arena() { |
Say Something
一个优秀的开源项目,其单元测试(unitest)也是很好的学习资料。尤其对于RocksDB这类比较大的项目,无法下手的话,可以先从单元测试着手。
下一期,讲解下多线程内存分配器的设计,会更加硬核,敬请期待。