有时候系统自带的内存分配器性能较低,原因是分配器本身按照通用目的设置,而程序通常有些特定的分配模式,不能对这些分配模式进行特定优化,此时程序可能就有必要开发自定义的分配器来针对这些模式进行优化。
内存分配和释放
假设一开始我们有一块大的内存,那么为了满足不同大小的分配,我们会将这块内存进行切分,在释放时,我们会对相邻的内存进行合并,以减少外部碎片。为了知道分配的内存有多大,我们一般需要记录一些元数据。下面用userP
表示返回给用户的内存,sysP
表示从别的分配器获得内存。
一种标准技巧
一般元数据记录在分配给用户的内存起始地址之前,这样可以很方便地确定数据内容,所以释放和分配的代码可以如下,
void* unalignedAlooc(size_t size) {
// for simplicity, assume that sysP is 4-byte aligned
uint32_t* sysP = /* allocated memory */;
*sysP = size;
return sysP + 1;
}
void unalignedDealloc(void* userP) {
uint32_t* sysP = (uint32_t*)userP - 1;
uint32_t size = *sysP;
// return sysP to the system
}
内存对齐
通常每种类型的数据都有一种对齐要求,如果不满足这种要求,要么存取变慢,要么访问出错。假设系统返回的内存是未对齐的指针,我们如何满足用户的对齐需求?常见的做法是额外分配n=对齐
个字节(见Game Engine Architecure),这样既可以满足对齐,又可以保证正确释放。
void* align(void* p, size_t align) {
return ((uintptr_t)p + align - 1) & ~(align - 1);
}
void* alignedAlloc(size_t size, size_t align) {
auto sysP = (unsigned char*)unalignedAlloc(size + align);
auto userP = (unsigned char*)align(unaligned + 1, align);
// save the offset from unaligned so that we can know the start address
// 256 will overflow, 0 will be stored
userP[-1] = userP - unaligned;
return userP;
}
void alignedDealloc(void* userP) {
auto aligned = (unsigned char*)p;
auto sysP = aligned - p[-1] ? p[-1] : 256;
unalignedDealloc(sysP);
}
其他元数据分配
有时我们还想记录一些别的元数据,比如需要用来检查是否有越界写,一般的做法在用户内存之后存放一个tag
,在分配时设置为一个特殊值,释放时如果发现被修改,那么就警告有越界写。
为了能够写入tag
,我们需要知道
- 用户请求分配的大小
- 满足对齐要求所需的偏移
我们使用如下结构来存储
struct Header {
uint32_t userSize;
uint32_t offset;
};
我们依然采用标准做法,把元数据存在用户指针之前,为了满足对齐,我们需要考虑
-
用户内存对齐 >= 元数据对齐
显然偏移userP
总是可以保证元数据对齐要求,因为较低对齐要求的数据总是可以存放在满足较高对齐要求的地址。 -
用户内存对齐 < 元数据对齐
假设userP
以用户内存对齐,此时如果直接偏移userP - sizeof(Header)
,可能不满足元数据对齐,我们可以使得返回给用户的内存也以元数据的对齐进行对齐即可。
所以综上,我们需要保证用户内存以较严格的对齐要求对齐
void* boundedAlloc(size_t size, size_t align) {
auto maxAlign = max(align, alignof(Header));
// header + padding + user size + tag
auto totalSize = sizeof(Header) + maxAlign - 1 + size + sizeof(uint32_t);
char* sysP = (char*)unalignedAlloc(totalSize);
char* userP = (char*)align(sysP + sizeof(Header), maxAlign);
((Header*)userP)[-1] = {
size,
userP - sysP
};
const auto tag = 0xDEADBEAFu;
// userP + size may not be 4-byte aligned, so use memcpy
memcpy(userP + size, &tag, sizeof(tag));
return userP;
}
void boundedDealloc(void* p) {
char* userP = (char*)p;
const auto header = (Header*)p - 1;
const auto tag = 0xDEADBEAFu;
assert(memcmp(userP + header->userSize, &tag, sizeof(tag)) == 0);
unalignedDealloc(userP - header->offset);
}
注意如果用alignedAlloc
来分配上述内存,那么我们可以
auto totalSize = sizeof(Header) + maxAlign - alignof(Header) + size + sizeof(uint32_t);
alignedAlloc(totalSize, alignof(Header));
// alignedAlloc(totalSize, maxAlign);
以alignof(Header)
分配的额外开销为maxAlign
,而unalignedAlloc
的开销为maxAlign - 1
,差不多。若以maxAlign
分配,则为2*maxAlign - alignof(Header)
,相对大一点。
小内存分配
因为分配的内存小,所以,采用通用内存分配器,可能额外的开销过大,所以使用小内存分配器可以使得这种开销降到最低。
一般对于一个用于分配一种特定大小的小内存分配器,我们使用一个free list的单链表来管理,可以实现O(1)的快速分配和释放。分配即弹出表头,释放即压入表头。使用指针的free list实现,一般最小的分配大小为sizeof(void*)
。
释放
对于一个通用的小内存分配器,即可以分配不同大小的小内存,在不保存额外的元数据情况下(比如大小,用于确定释放到哪个free list),如何进行O(1)的释放呢?显然我们需要知道对应的free list才能实现O(1)的复杂度,否则我们需要查找所有的free list。
有一种方法是每次从系统中分配页面大小的内存,一般是4K,之后在这段内存开头或者结尾存放必要的free list。由于页面是以4K对齐,我们可以将从该页面分配的小内存地址向下取整或者向上取整得到页面的起始地址或结束地址,这样就很容易得到free list,之后就可以直接释放,而无需查找所有free list。
另一种方法依赖于编译器在释放时传递正确的大小,我们稍后会介绍。
如果从一个只支持一种大小的小内存分配器中分配,释放时直接调用该分配器释放内存,也就不需要上述那种实现。
Scoped Allocator
有时我们想一次性地释放所有的分配的对象,并正确调用其析构函数,那么在每次分配时,我们可以额外分配数据存储一个函数指针,指向能够析构对应对象的函数。
struct Destructor {
using destructor_t = void (*)(void* p);
destructor_t dtor;
Destructor* next;
template<typename T>
static void destroy(void* p) {
(T*)((char*)p + sizeof(Destructor))->~T();
}
};
template<typename T, typename... Args>
T* make(Args&&... args) {
if constexpr (!is_trivially_destructible_v<T>) {
// need to call the destructor
auto maxAlign = max(alignof(T), alignof(Destructor));
auto size = sizeof(Destructor) + maxAlign - 1 + sizeof(T);
char* p = unalignedAlloc(size);
auto obj = (Destructor*)align(p + sizeof(Destructor), maxAlign);
obj->dtor = Destructor::destroy<T>;
obj->next = m_dtorHead;
m_dtorHead = obj;
return new (obj) T(forward<Args>(args)...);
} else {
// no need for Destructor
}
}
// in destructor
~ScopedAllocator() {
for (; m_dtorHead; m_dtorHead = m_dtorHead->next) {
m_dtorHead->dtor(m_dtorHead);
}
}
上述代码参考filament中的内存分配器。
c++ new/delete
在c++14之前,要想在delete
时自动获取对象的大小,可以通过在每个类中增加operator delete
的方式得到,但是对于全局的operator delete
却没有这种便利。c++14中增加了带size参数的全局operator delete
。这样对于我们释放小内存是有所帮助的,在得到size后,可以直接计算对应的free list,而无需采用上面提到的技巧也可以很容易实现。
一些参考
Composing High-Performance Memory Allocators
作者提出了使用分层的概念来实现内存分配器,通过将不同的职责实现到不同的内存分配器,增加了代码可读性,可维护性,并且性能损失也不大。
Memory System和Memory Management作者对实现进行了一定的探讨,有很多示例代码可供参考。
《Game Programming Gems 7》中作者详细描述了一个分配器的实现,该分配器使用一个小内存分配器和一个大内存分配器,大内存分配器通过RB tree来加速内存的分配和释放。
示例代码
这里有一份简单的实现代码可供参考。