C++内存管理变革(1): GC Allocator

引言

C/C++语言的内存管理经历了几次变革,但至今仍未能趋于成熟。这几次变革主要包括:

  1. 从malloc/free到new/delete。这场变革是OOP技术兴起的产物。C++是强类型语言,new/delete的主要成果也就是加强了类型观念,减少了强制类型转换的需求。但是从内存管理角度看,这个变革并没有多少的突破性。
  2. 从new/delete 到内存配置器(allocator)。自从STL被纳入C++标准库后,C++世界产生了巨大的变化。而从内存管理角度来看,allocator的引入也是C++内存管理一个突破。留意一下你就可以发现,整个STL所有组件的内存均从allocator分配。也就是说,STL并不推荐使用 new/delete 进行内存管理,而是推荐使用allocator。

然而,STL的allocator并没有导致C++语言在内存管理上发生巨大的变化。除了STL本身外,并没有多少人使用allocator,甚至是意识到allocator的重要性。所以C++程序员在使用STL的同时,依旧在使用new/delete进行烦琐的内存分配/释放过程。

究其原因,主要有二。一是allocator的引入,STL设计者主要可能还是出于将内存管理从容器的实现独立出来的设计理念作用,让STL使用者在内存管理算法上有选择的余地。设计者本身都可能也没有意识到allocator的重要性。二是allocator本身也只是侧重于关注效率上,而没有侧重于C++语言使用者对内存管理观念的变革上。

总之,在我看来,STL的引入allocator,是一件了不起的事情。但是这场变革被忽视了,没有得到贯彻。当然,这也与STL的allocator本身的缺陷有关。

本文要讨论的,正是如何贯彻STL的allocator思想,对其进行适当的改进,以期在C++内存管理观念上产生变革性的突破,彻底淘汰传统的new/delete内存管理方法1

垃圾回收器(GC)

几乎所有目前流行的垃圾回收器,均倾向于将使用者当作一个傻瓜,期望能够让使用者在完全不理解内存管理的情况下,可以很好的使用它。应该说这它们基本上都也做到了(虽然使用者有时也有这样那样的烦恼,但总体来说情况确实得到了很大程度的改善)。然而这一设计理念我并不十分认同。

首先,可以在一个提供垃圾回收器的语言中自如的工作,没有被垃圾回收器所困扰,本身已经是很了不起的事情,他们绝对是非常聪明的人,而不是傻瓜。他们理解垃圾回收器的工作原理,选择它并且让它为他们工作,只是因为还有更重要的事情等着他们去做。必要的时候,他们需要有办法控制垃圾回收器,使它按照他们的意愿工作。因此,垃圾回收器的设计要点在于把使用者从烦琐的内存管理中解脱出来,使得他们可以将全部精力投入到本身的业务逻辑上,而不是让垃圾回收器看起来更傻瓜式。

其次,使用一个全自动的垃圾回收器,在内存回收的时机不明确的情况下,垃圾回收器的工作过程有很大的不确定性,这给使用者带来烦恼。例如C#在调用非管制代码(如调用Win32 API)时,这些问题变得突出。一个不小心,就有可能出现Win32 API还在使用一块内存,而垃圾回收器已经把它回收了的情形。在小心翼翼的避开这些陷阱时,这种感觉其实与C/C++程序员遗憾语言没有垃圾回收器的感觉有点类似。

因此,最理想的情况,是内存管理器提供垃圾回收的能力,但是它也只是提供这个能力而已,至于什么时候进行垃圾回收,完全可以由用户自己控制。另外,用户也可以强制释放一块内存,而不是完全被动的等待垃圾回收过程决策何时回收该内存。对于客户来说,他有权掌控一切,只是如果万一他确实疏忽了,垃圾回收器能够为他护航。

将垃圾回收器引入C++,有没有这种可能呢?我认为,如果我们试图提供一个全自动的垃圾回收器,这相当困难。我们看到以Microsoft之能,仍然无法把这件事做好2。或许,我们需要改变一下观念:一个半自动的垃圾回收器,也许就可能可以和C++融洽相处了呢?

初识Allocator

Allacator中文称为“内存配置器”,通常它是一个类,负责提供内存管理(可能包含内存分配、释放、自动回收等能力)相关的服务。例如,我们通过C提供的malloc/free即刻提供一个allocator实作出来:

class SimpleAlloc
{
public:
    //注意这里提供的参数fnDestroy,它是为那些具备垃圾回收能力的allocator需要提供。
    void* Alloc(size_t cb, FnDestructor fnDestroy = NULL)
    {
        return malloc(cb);
    }
 
    //注意这里有看似多余的参数cb,这完全是为了和后续提供的allocator规格一致的需要。
    void Free(void* data, size_t cb)
    {
        free(data);
    }
};

有了allocator,我们可以申请内存了,但是我们还不能用它创建一个C++对象。为了方便创建C++对象,我们提供了辅助的New操作,原型大体如下:

template <class Type, class AllocType>
Type* New(AllocType& alloc);                    // 类似于new Type
 
template <class Type, class ArgType1, class AllocType>
Type* New(ArgType1 arg1, AllocType& alloc); // 类似于new Type(arg1)
 
template <class Type, class AllocType>
Type* NewArray(size_t count, AllocType& alloc);// 类似于new Type[count]

有了这些辅助函数,我们就可以创建对象了。使用样例:

SimpleAlloc alloc;
 
int* intArray = NewArray<int>(count, alloc);
 
MyClass* obj = New<MyClass>(alloc);
MyClass* objWithArg = New<MyClass>(arg1, alloc);
 
MyClass* objArray = NewArray<MyClass>(count, alloc);

这里我们虽然使用SimpleAlloc创建对象,但是需要提醒的是,这些New操作对所有的allocator有效。如果你关心New函数的代码,先不急,下面我们马上就可以看到了。但是首先我们要继续讨论一下allocator。

Allocator引起的观念变化

接触allocator,你可以体会到了它与C++传统的new/delete观念的不同。这主要有以下几点:

其一,每个类(或者算法)本身,均有最合适它的内存管理机制,并不是向C++传统的做法那样,使用一个全局的new/delete。也许你会说,C++不也允许一个类定义自己的new和delete吗?是的,C++的确支持类定义自己的new/delete,但注意,它的理念和allocator完全不同。我不认为它是C++的一个优秀之作,相反,它起到了误导作用。

因为,决定一个类对象怎么去new出来,并不是取决于该类本身,而相反是取决于使用该类的人。一个类不需要关心自身被如何创造出来,更不能假定。它需要关心的是它自己的类成员如何被创建出来,它的算法(你可以把类看做一个算法集合)涉及到的所有组件如何被创建出来。而这,才是allocator带来的观念。

让各种各样的allocator创建同一个类的不同实例,这些实例甚至可能在一起工作,相互协作。从STL的角度讲,这完全是最正常不过的事情了。

其二,重要的是由allocator创建管理对象,避免在你的代码中使用new/delete。如果可能,你可以如STL那样,将allocator作为模板参数,不绑定具体的某个内存管理器。但是,如果你的算法依赖了某个allocator的实现特有的功能,这也并不要紧。你的目的不是要做到 allocator的可替换,不是吗?重要的是使用了这个allocator了,它给你在内存管理上带来了益处。

但是,应该看到,STL实作的各种allocator,目前来看除了最简单使用malloc/free实现的外,主要就是基于 mempool 技术。而该技术的目标,不是让内存使用者更加方便有效地进行内存管理,而更多的是关注于内存分配的时间性能。为了让C++程序员从内存管理中解脱出来,我们需要实作新的alloctor,需要新的突破!

新视角:GC Allocator(具垃圾回收能力的Allocator)

对,我设想的一个做法是,贯彻STL的allocator观念,并且提供具备特定的内存管理能力,可自动进行垃圾回收的增强型allocator(我们称之为GC Allocator),让C++社区广泛接受allocator观念,并且从中受益。C++程序员是时候抛弃传统的new/delete,让他们退出历史舞台了。

我接下来会实作两个具体的allocator(均属原创)。相信它们会让你耳目一新,让你不禁想到:哦,原来在C++中,我还可以这样进行内存管理。

当然,我最大的希望就是,这两个allocator能够起到抛砖引玉的作用,让大家也清楚地意识到allocator的重要性,可以出现更多的具备各种能力的allocator,解脱C++程序员一直以来的苦难(可能是最大苦难3)。

这两个allocator均具备一定程度的垃圾回收能力。只是观念上各有各的侧重。我们接下来会分为两个专题专门对它们进行阐述。

辅助的New过程

我们终于可以开始讨论前文提到的New函数的实现上了。以不带参数的New为例,它的代码如下,可能并没有你想象的那么复杂:

#include <new>
 
template <class Type, class AllocType>
inline Type* New(AllocType& alloc)
{
    void* obj = alloc.Alloc(sizeof(Type), DestructorTraits<Type>::Destruct);
    return new(obj) Type;
}

其中DestructorTraits是一个根据类型Type萃取4析构函数的萃取器。它看起来是这样的:

template <class Type>
struct DestructorTraits
{
    static void Destruct(void* pThis)
    {
        ((Type*)pThis)->~Type();
    }
};

这样,你就可以通过以下代码new出对象了:

MyClassA* obj = New<MyClassA>(alloc);
MyClassB* obj = New<MyClassB>(alloc);

特别提醒:这里New函数在VC++ 6.0下编译通过,但是产生的执行代码存在严重bug。如果你只New一类对象,没有问题,但在New了多种对象后,似乎VC++对MyClassA、 MyClassB 两者混淆起来了。为了支持VC++ 6.0,你需要对这里的New做出调整(关于这一点,详细请参考:VC++ 6.0小技巧)。

COM技术与内存管理

已经准备结束这篇短文的时候,忽然想到了长久以来使用COM技术5形成的一些感想,这些想法恰恰与内存管理紧密相关。故此想就这个问题陈述一下。

从COM的IUnknown接口看,它主要关注两个问题:一个是QueryInterface,一个是引用计数(AddRef/Release)。COM组件很讲究信息的屏蔽,使用者对组件的认识有限,这就给组件升级、扩充功能提供了可能。 QueryInterface是一个很好的概念,需要发扬光大。

COM的引用计数则关注的是组件的生命期维护问题。换句话说,就是组件如何销毁的问题。诚然,组件对象的销毁问题,是内存管理的关键。无论是COM的引用计数,还是垃圾回收技术,均是要解决对象的销毁问题。只是两者的侧重点不太一样,COM引用计数更关注“确保组件不会被提前销毁了,确保组件访问的安全性”,而垃圾回收器则关注“不管怎样确保组件最终被销毁,没有内存泄漏”。

在COM中,确保组件访问的安全性(避免非法访问),这个观点太重要了,以至于它甚至不惜加重程序员的内存管理负担。所以,在 COM程序中,出现内存泄漏太正常了,而且一旦泄漏通常就是大片大片内存的漏。更加要命的是,你甚至不能有一个很简单有效的方法确认这个泄漏是由于哪段代码引起。因为组件所有的客户都是平等的,任何一个客户代码存在问题均将导致内存的泄漏。

刚开始接触COM技术的时候,我对引用计数持的是比较正面的态度。但是随着部门逐步加大COM技术的使用力度后,四五年下来,我渐渐开始迷惑起来。一切并不如想象的那样。这个引用计数的背后,需要我们付出多少额外的代价!

而这个迷惑、思索,可能就是本文以及后续相关内容的成因吧。

关于本文

本文写于 2005-7-16,第一次公开发表是我的CSDN博客上:

本文提到了我创作的两个GC Allocator实作,是指:

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License

Subscription expired — please renew

Pro account upgrade has expired for this site and the site is now locked. If you are the master administrator for this site, please renew your subscription or delete your outstanding sites or stored files, so that your account fits in the free plan.