内存分配器之 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这类比较大的项目,无法下手的话,可以先从单元测试着手。
下一期,讲解下多线程内存分配器的设计,会更加硬核,敬请期待。