CATEGORY / Development

游戏引擎设计 之 内存管理框架

Permanent Link: http://wutiam.net/notes/14

内存管理对于大型游戏来说是至关重要的一环,这里我们所说的“内存”指的是动态分配的内存。一个好的内存管理框架,能显著提升程序执行效率,也能大大提高内存问题的调试效率。所以,我们设计内存管理框架时必须能够满足以下两大需求:

  1. 自定义内存分配器
  2. malloc/free(包括间接由new/delete等调过来的)等函数本身的开销其实并不大,但由于其功能太过基础了,没有任何策略可言,从而导致反复分配释放带来的开销、大量内存碎片以及多线程内存分配引起的效率问题。所以自定义一个高效的内存分配器就显得必不可少了,对于一般情况,我们可以使用 nedmallocjemalloc 等第三方多线程内存分配器,也可以根据具体需求自己实现一套(例如 Nebula3 中的内存池以及 Heap 对象机制),甚至是基于栈的动态内存分配。而具体的内存分配/释放策略是另一个话题了,以后新开一篇讨论。

  3. 内存统计及内存泄漏跟踪
  4. 虽然 CRT 有 dump 内存泄漏的功能,但最大的问题就在于——仅限于 DEBUG 环境下,而游戏由于其实时计算的复杂性,在后期很多情况下开 DEBUG 模式已经无法满足正常调试的需求了,所以 Release with debug info 模式其实才是我们更常用的,这样我们就必须自己实现内存统计及泄漏跟踪的功能,甚至提供对动态内存泄漏(运行时大量已无用的内存未释放,直到游戏退出时才一并释放,最常见的就是由智能指针引起的动态内存泄漏)的检查。

先说说第一点,我们需要把 malloc/free 和 new/delete 的分配/释放部分重定向到我们自己的分配/释放函数(handler)里,然后在我们的函数里调用指定的内存分配器的分配/释放函数(allocator),这就完成了。

第二点,正好可以利用第一点定义出来的我们自己的分配/释放函数,除了调用分配器之外,再通知内存分配统计模块更新统计信息(记录新分配的内存的相关信息或删掉相关的信息),这样内存分配统计模块就完成了统计和泄漏报告两大功能了。

来看看具体的伪码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Handle allocation/deallocation
class AllocationHandler
{
public:
#ifdef MEMORY_STATS
    static void* Allocate(unsigned int size, const char* fileName, int lineNum, const char* funcName)
    {
        AllocStats::Alloc(size, fileName, lineNum, funcName);
        return Allocator::Allocate(size);
    }
#else
    static void* Allocate(unsigned int size)
    {
        return Allocator::Allocate(size);
    }
#endif // MEMORY_STATS
 
    static void Deallocate(void* ptr)
    {
        Allocator::Deallocate(ptr);
    }
 
private:
    // re-define to your own allocator
    typedef DefaultAllocator Allocator;
};
 
// Allocator using default CRT malloc
class MM_DefaultAllocator
{
public:
    static void* Allocate(unsigned int size)
    {
        return malloc(size);
    }
    static void Deallocate(void* ptr)
    {
        free(ptr);
    }
};

基本原理非常简单,但“现实总是残酷的”!为了方便的传入调试信息(__FILE__、__LINE__、 __FUNCTION__ 等),我们使用宏来定义我们自己的分配函数:

1
2
3
4
5
6
7
8
9
#ifdef MEMORY_STATS
#define MALLOC(size)        AllocationHandler::Allocate(size, __FILE__, __LINE__, __FUNCTION__)
#define ALLOC(T, count)     static_cast<T*>(AllocationHandler::Allocate(sizeof(T)*(count), __FILE__, __LINE__, __FUNCTION__))
#define T_FREE(ptr)         AllocationHandler::Deallocate(ptr)
#else
#define MALLOC(size)        AllocationHandler::Allocate(size)
#define ALLOC(T, count)     (T*)AllocationHandler::Allocate(sizeof(T)*(count))
#define FREE(ptr)           AllocationHandler::Deallocate(ptr)
#endif // MEMORY_STATS

很好,可是 new/delete 呢?我也很想一起把它们定义出来,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
#define GE_NEW(type)                new(__FILE__, __LINE__, __FUNCTION__) type
#define GE_DELETE(ptr)              delete ptr
#define GE_NEW_ARRAY(type, count)   new(__FILE__, __LINE__, __FUNCTION__) type[count]
#define GE_DELETE_ARRAY(ptr)        delete[] ptr
 
inline void* operator new(size_t size, const char* fileName, int lineNum, const char* funcName)
{
    return AllocationHandler::Allocate(size, fileName, lineNum, funcName);
}
inline void operator delete(void* ptr)
{
    return AllocationHandler::Deallocate(ptr);
}
// only called if there is an exception in corresponding 'new'
inline void operator delete(void* ptr, const char*, int, const char*)
{
    return AllocationHandler::Deallocate(ptr);
}
inline void* operator new[](size_t size, const char* fileName, int lineNum, const char* funcName)
{
    return AllocationHandler::Allocate(size, fileName, lineNum, funcName);
}
inline void operator delete[](void* ptr)
{
    return AllocationHandler::Deallocate(ptr);
}
// only called if there is an exception in corresponding 'new'
inline void operator delete[](void* ptr, const char*, int, const char*)
{
    return AllocationHandler::Deallocate(ptr);
}
*/

通过重定义全局的 new/delete operators 来重定向,看起来很统一、很完美。可是,除了程序自己的所有代码的 new/delete 被重定向了以外,连第三方库里的所有 new/delete 操作也都被重定向到我们自己的分配器来了(这里所说的第三方库指的是以源码方式发布并合入我们的工程的),而这些第三方库里的内存分配被我们接管后,可能会导致一些意想不到的异常结果。此路不通。

既然不能替换全局的 new/delete 操作符,但至少可以替换掉一部分 class 的 new/delete 操作符吧,即,凡是从 MemoryObject 继承的子类,将其 new/delete 都接管到我们的分配器上(这也是 ORGEGamebryo 所采用的方法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#ifdef MEMORY_STATS
#define NEW(T)          new(__FILE__, __LINE__, __FUNCTION__) T
#else
#define NEW(T)          new T
#endif // MEMORY_STATS
#define T_DELETE(ptr)   delete ptr
 
// Override MemoryObject-derived classes' new/delete operators to redirect to AllocationHandler
#ifdef MEMORY_STATS
class MemoryObject
{
public:
    void* operator new(size_t size, const char* fileName, int lineNum, const char* funcName);
    void operator delete(void* ptr);
    // only called if there is an exception in corresponding 'new'
    void operator delete(void* ptr, const char*, int, const char*);
};
 
inline void* MemoryObject::operator new(size_t size, const char* fileName, int lineNum, const char* funcName)
{
    return AllocationHandler::Allocate(size, fileName, lineNum, funcName);
}
inline void MemoryObject::operator delete(void* ptr)
{
    AllocationHandler::Deallocate(ptr);
}
inline void MemoryObject::operator delete(void* ptr, const char*, int, const char*)
{
    AllocationHandler::Deallocate(ptr);
}
#else
class MemoryObject
{
public:
    void* operator new(size_t size);
    void operator delete(void* ptr);
};
 
inline void* MemoryObject::operator new(size_t size)
{
    return AllocationHandler::Allocate(size);
}
inline void MemoryObject::operator delete(void* ptr)
{
    AllocationHandler::Deallocate(ptr);
}
#endif // MEMORY_STATS

然而,毕竟还有那么多 POD 类型、自定义的简单结构体的数据,都用 MemoryObject 包起来既不现实又没效率,那只能为它们再单独制定一套 new/delete 策略了,类似 OGRE 的实现:

1
2
3
4
5
6
#ifdef MEMORY_STATS
#define EXTERNAL_NEW(T)     new(AllocationHandler::Allocate(sizeof(T), __FILE__, __LINE__, __FUNCTION__)) T
#else
#define EXTERNAL_NEW(T)     new(AllocationHandler::Allocate(sizeof(T))) T
#endif // MEMORY_STATS
#define EXTERNAL_DELETE(ptr, T) if (ptr != NULL_PTR) { (ptr)->~T(); AllocationHandler::Deallocate(ptr); }

但这里我们去掉了对数组的支持,原因是一方面自定义数组的 new/delete 处理代码比较丑陋,另一方面也不支持多维数组的创建,而且我们对引擎设计的其中一条底线就是禁止上层应用直接使用原生数组(越界引起的各种崩溃,太让人崩溃了),要玩数组,用我们自己封的数组对象吧。顺便说一句,说实话我个人觉得 OGRE 的内存分配 category 和 policy 有过度设计的嫌疑,而且其分类和策略的设计并不能为多分配器提供支持,所以我直接让分配请求和分配器对接了。未来如果需要对不同的对象采用不同的分配器,可以考虑 Helium 的设计思路,通过模板将分配器直接传入类定义。

好了,内存管理的框架设计就是这样,看起来很简单,但为什么设计成了这样,也许只有等你也走过一遍才会有深刻的理解,也许你的需求,可以产生更优秀的框架。

1 Comments / Trackbacks / Pingbacks

Leave a Reply

:) :wink: 8-O :lol: :-D 8) :-| :mrgreen: :oops: :-o :-? :( :twisted: :cry: more »