# WHY
最近在研究U3d webGL ,很多人在吐槽WebGL support启动时需设定TOTAL_MEMORY. 其实在游戏引擎设计中,提前申请大块内存并自己管理有诸多的好处。PhantomEngine在一开始的时候也准备设计自己的MemoryManager,并且也参考借鉴了不少的资料,虽然最后没有实际应用(主要是没人喜欢关注这个)。Unity在Unite2019上公布了对现有GC方案的改进:增量式GC,也表明了内存管理永远是引擎需要持续关注的事情。
下面这个系列的文章实现了内存管理器的雏形,我做了翻译留在了印象笔记里,现在分享出来。
文章转自:
Memory Management part 1 of 3: The Allocator | Ming-Lun "Allen" Chou | 周明倫allenchou.net
Memory Management part 1 of 3: The Allocator
为什么要自找麻烦去构建你自己的内存管理系统?
内存管理对于游戏开发是至关重要的,因为内存总是有限的,即便是主机游戏。有很多游戏开发者依赖一些编程语言本身的垃圾收集器。垃圾收集器有着易用性的同时,它的机制也有无法始终一致、并不总是可靠的缺点。可能在一百多次垃圾回收循环后,它需要做一次比较大型的clean up/GC,所以会导致游戏在某几帧里帧频下降到10Fps。大多数时候,你无法预测这个性能尖刺。你的游戏大多数时间运行的非常流畅,但是不时地它就会卡那么一下。游戏能玩,玩家也许对这个时不时的卡顿并不那么在意; 但是为了让每个玩家的体验都达到完美极致,你应该致力于消灭每一个可能的性能尖刺。
构建你自己的内存管理系统是你可以做的一个非常重要的改进。
为了构建一个自定义的内存管理系统,编程语言必须允许你直接操作内存地址。对于那些有些内建垃圾收集器、不允许操作底层内存地址的语言就没办法了。很多PC游戏和主机游戏都用C++语言开发,我们这里也从C++展开。
这个系列的第一部分准备搞定固定大小的内存分配器,本系列的其余部分都是建立在此基础上的。在第二部分,我会展示怎么用这个内存分配器去实现一个C语言风格的、支持动态大小的内存分配过程,也会尝试用C++的模板函数语法糖实现。 最终,第三部分的内存分配器的实现会和STL容器兼容。重点是: 使用new/delete 不够好。
如果你用C++写你的游戏,你已经使用内置的new和delete操作符去管理你的内存了。但是,这个方案不够好。每一次分配(new)和再分配(delete)都有它的额外开销,因为默认的内存管理器(基于编译器实现)需要去扫描可用的内存,去寻找一块合适的内存。我们将要构建的固定大小内存分配器分配一块内存会花费固定的时间;它的每块内存大小都是固定的所以它不需要扫描查询。第二部分我们会展示,可以通过预算计算的查找表来实现固定时间获取变长内存分配。
为了加速分配和再分配的过程,我们会使用new操作符去预先申请一大块内存(big char arrays),自己管理这些内存块。这种方法有以下几个有点:
- 对于new和delete的使用大幅减少了,减少了系统调用开销。
- 我们自己管理所有的内存块,就可以很轻松的插入额外的调试数据了,比如为了检查缓冲区溢出。
- 我们可以更好地控制我们想对内存做什么,比如决定某一帧里是否有足够的时间来执行一个快速的内存碎片整理。
Pages & Blocks
我们申请的每个内存块叫做Page,在每个Page中,我们用来存储一段完整数据的内存叫做Block。
本篇中的例子里,内存分配器只能创建固定大小的Pages,分配固定大小的blocks 。每个内存分配器都有一个追踪由自己已分配的Page的列表,也有一个free list去追踪管理所有能被分配的blocks。
Lists都被设计成单向链表,所以每个Page只有一个额外的指针的内存开销。每个Block也有一个指针。但是一旦这个Block被分配了,内存空间就可以跟用户数据共享了(因为当block被分配时,链表指针就不需要了。)。
下面是Page和Block结构的头文件:
struct BlockHeader
{
// union-ed with data
BlockHeader *Next;
};
struct PageHeader
{
// followed by blocks in this page
PageHeader *Next;
// helper function that gives the first block
BlockHeader *Blocks(void)
{ return reinterpret_cast<BlockHeader *>(this + 1); }
};
The Allocator 头文件
首先展示一下Allocator头文件:
class Allocator
{
public:
// debug patterns
static const unsigned char PATTERN_ALIGN = 0xFC;
static const unsigned char PATTERN_ALLOC = 0xFD;
static const unsigned char PATTERN_FREE = 0xFE;
// constructor
Allocator
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
);
// destructor
~Allocator(void);
// resets the allocator to a new configuration
void Reset
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
);
// allocates a block of memory
void *Allocate(void);
// deallocates a block of memory
void Free(void *p);
// deallocates all memory
void FreeAll(void);
private:
// fill a free page with debug patterns
void FillFreePage(PageHeader *p);
// fill a free block with debug patterns
void FillFreeBlock(BlockHeader *p);
// fill an allocated block with debug patterns
void FillAllocatedBlock(BlockHeader *p);
// gets the next block
BlockHeader *NextBlock(BlockHeader *p);
// the page list
PageHeader *m_pageList;
// the free list
BlockHeader *m_freeList;
// size-related data
unsigned m_dataSize ;
unsigned m_pageSize ;
unsigned m_alignmentSize;
unsigned m_blockSize ;
unsigned m_blocksPerPage;
// statistics
unsigned m_numPages ;
unsigned m_numBlocks ;
unsigned m_numFreeBlocks;
// disable copy & assignment
Allocator(const Allocator &clone);
Allocator &operator=(const Allocator &rhs);
};
Debug样式,顾名思义,在测试时用它们把内存区域都填充起来便于观察。Allocator的构造函数和Reset函数决定了Page和Block的大小,还有对齐量(稍后再说)。Allocate方法负责分配,返回被分配的block内存的地址。Free方法的目的与Allocate相反。把一个block内存返回free-list以便重用。 FreeAll方法会释放所有由这个Allocator申请的Pages。
Sample Client Code | 示例代码
You would create an allocator and use it to allocate and free blocks in client code like this:
// create allocator
Allocator alloc(sizeof(unsigned), 1024, 4);
// allocate memory
unsigned *i = reinterpret_cast<unsigned *>(alloc.Allocate());
// manipulate memory
*i = 0u;
// free memory
alloc.Free(i);
The Constructor & Destructor
构造函数和Reset方法基本差不多,所以就不用惊讶构造函数中直接调用了Reset方法了。析构函数和FreeAll方法同理。
Allocator::Allocator
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
)
: m_pageList(nullptr)
, m_freeList(nullptr)
{
Reset(dataSize, pageSize, alignment);
}
Allocator::~Allocator(void)
{
FreeAll();
}
The Reset Method
Reset方法首先释放了所有的Pages,而且为分配器重新设定了新的data size,page size和alignment size。Data size是客户端代码申请的单个Block的内存大小;实际上的Block物理内存占用会因为需要去对齐而可能大于/等于Data size。
> 译者注: 内存对齐是空间换时间,Cpu是按字读取内存,对齐的好处是不会出现某个类型的数据需要两次读取内存。Alignment size 是额外添加到Block的字节数,来保证Block size是某个指定的alignment的整数倍。这样做是为了提升内存读取效率。如果你的机器每次读取4个字节的物理内存,那么最好不要让你的数据从非4的边界开始。否则,为了处理一次运算,Cpu可能需要额外的内存读取才能把数据载入到寄存器中。
客户端可能申请比一个Block Header还小的内存块。为了保证我们的Block总是能放得下Block Header,我们使用maxHeaderData和m_alignmentSize来确定实际Block Size尺寸。
void Allocator::Reset
(
unsigned dataSize,
unsigned pageSize,
unsigned alignment
)
{
FreeAll();
m_dataSize = dataSize;
m_pageSize = pageSize;
unsigned maxHeaderData =
Max(sizeof(BlockHeader), m_dataSize);
m_alignmentSize =
(maxHeaderData % alignment)
? (alignment - maxHeaderData % alignment)
: (0);
m_blockSize =
maxHeaderData + m_alignmentSize;
m_blocksPerPage =
(m_pageSize - sizeof(PageHeader)) / m_blockSize;
}
The Allocate Method
Allocate方法是Allocator中最重要的部分。它创建Pages,使用Debug数据填充Pages,把新的Page填充到Pages List中,组织链接所有的新的未被使用的Block,把它们放入到未被使用的Block List中。
void *Allocator::Allocate(void)
{
// free list empty, create new page
if (!m_freeList)
{
// allocate new page
PageHeader *newPage =
reinterpret_cast<PageHeader *>
(new char[m_pageSize]);
++m_numPages;
m_numBlocks += m_blocksPerPage;
m_numFreeBlocks += m_blocksPerPage;
FillFreePage(newPage);
// page list not empty, link new page
if (m_pageList)
{
newPage->Next = m_pageList;
}
// push new page
m_pageList = newPage;
// link new free blocks
BlockHeader *currBlock = newPage->Blocks();
for (unsigned i = 0; i < m_blocksPerPage - 1; ++i)
{
currBlock->Next = NextBlock(currBlock);
currBlock = NextBlock(currBlock);
}
currBlock->Next = nullptr; // last block
// push new blocks
m_freeList = newPage->Blocks();
}
// pop free block
BlockHeader *freeBlock = m_freeList;
m_freeList = m_freeList->Next;
--m_numFreeBlocks;
FillAllocatedBlock(freeBlock);
return freeBlock;
}
The Free & FreeAll Methods
The Free method simply puts a block back into the free list.
void Allocator::Free(void *p)
{
// retrieve block header
BlockHeader *block =
reinterpret_cast<BlockHeader *>(p);
FillFreeBlock(block);
// push block
block->Next = m_freeList;
m_freeList = block;
++m_numFreeBlocks;
}
And the FreeAll method frees all pages created by the allocator.
void Allocator::FreeAll(void)
{
// free all pages
PageHeader *pageWalker = m_pageList;
while (pageWalker)
{
PageHeader *currPage = pageWalker;
pageWalker = pageWalker->Next;
delete [] reinterpret_cast<char *>(currPage);
}
// release pointers
m_pageList = nullptr;
m_freeList = nullptr;
// re-init stats
m_numPages = 0;
m_numBlocks = 0;
m_numFreeBlocks = 0;
}
Helper Methods
Finally, the helper methods. I’m just going to post the implementation, because the code is quite self-explanatory.
self-explanatory : 不需要加以说明的
void Allocator::FillFreePage(PageHeader *p)
{
// page header
p->Next = nullptr;
// blocks
BlockHeader *currBlock = p->Blocks();
for (unsigned i = 0; i < m_blocksPerPage; ++i)
{
FillFreeBlock(currBlock);
currBlock = NextBlock(currBlock);
}
}
void Allocator::FillFreeBlock(BlockHeader *p)
{
// block header + data
std::memset
(
p,
PATTERN_FREE,
m_blockSize - m_alignmentSize
);
// alignment
std::memset
(
reinterpret_cast<char *>(p)
+ m_blockSize - m_alignmentSize,
PATTERN_ALIGNMENT,
m_alignmentSize
);
}
void Allocator::FillAllocatedBlock(BlockHeader *p)
{
// block header + data
std::memset
(
p,
PATTERN_ALLOCATED,
m_blockSize - m_alignmentSize
);
// alignment
std::memset
(
reinterpret_cast<char *>(p)
+ m_blockSize - m_alignmentSize,
PATTERN_ALIGNMENT,
m_alignmentSize
);
}
Allocator::BlockHeader *
Allocator::NextBlock
(BlockHeader *p)
{
return
reinterpret_cast<BlockHeader *>
(reinterpret_cast<char *>(p) + m_blockSize);
}
Done!
This concludes the first part of this series. I hope you find this post useful
我写了一个Test,整个Demo工程就3个文件:

下面的github上的commit
https://github.com/mh29110/PhantomEngine/commit/33796be696ae4890b6e2f149077b6d1332d3e919