垃圾回收器
2008-03-31 23:36
246 查看
引言
在前文,我们引入了GC Allocator(具备垃圾回收能力的Allocator),并提供了一个实作:AutoFreeAlloc(详细内容参见《C++内存管理变革(2):最袖珍的垃圾回收器 - AutoFreeAlloc》)。但是,如前所述,AutoFreeAlloc是有其特定的适用环境的(它对内存管理的环境进行了简化,这种简化环境是常见的。详细参阅《C++内存管理变革(3):另类内存管理 - AutoFreeAlloc典型应用》)。那么,在AutoFreeAlloc不能适用的情形下,我们可以有什么选择?
本文要讨论的,正是这样一个GC Allocator实作。它所抽象的内存管理的环境比之AutoFreeAlloc复杂许多,适用范围也广泛很多。这个GC Allocator我们称之为ScopeAlloc。
思路
在AutoFreeAlloc假象的模型里,一个算法的所有步骤都统一使用同一个GC Allocator,最后的内存由该Allocator统一回收。这个模型很简洁,很容易理解和掌握。理解ScopeAlloc的关键,在于理解我们对AutoFreeAlloc的模型所作的修正。我们设想一个算法的第i步骤比较复杂,其内存开销也 颇为可观,希望为步骤i引入一个私有存储(Private GC Allocator),以便哪些步骤i内部计算用的临时内存在该步骤结束时释放。示意图如下:
图1
由于引入私有存储(Private GC Allocator),模型看起来就变得很复杂。上面这个图也许让你看晕了。不过没有关系,我们把上图中与步骤i相关的内容独立出来看,得到下图:
图2
如图2显示,一个算法会有自己的私有存储(Private GC Allocator),也会使用外部公有的存储(Share GC Allocator)。之所以是这样,是因为算法的结果集(Result DOM)不能在算法结束时销毁,而应该返回出去。这我们大致可以用以下伪代码表示:
ResultDOM* algorithm(InputArgs args, ScopeAlloc& shareAlloc) { ScopeAlloc privateAlloc(shareAlloc); ... ResultDOM* result = STD_NEW(shareAlloc, ResultDOM); ResultNode* node = STD_NEW(shareAlloc, ResultNode); result->addNode(node); ... TempVariable* temp = STD_NEW(privateAlloc, TempVariable); ... return result; }
在这段伪代码中,ScopeAlloc是今天的主角。STD_NEW是StdExt库中用于生成对象实例的宏,STD_NEW(alloc, Type)其功用等价于《C++内存管理变革(1): GC Allocator》中的New<Type>(alloc)。只是New<Type>模板函数比较“C++”,比较正统,也比较偏于理论1。而STD_NEW则是实际工程中的使用方式。
挑战
你可能说,要引入私有存储(Private GC Allocator),为什么非要提供一个新的Allocator?为什么不能是AutoFreeAlloc?为什么不能像下面这样:ResultDOM* algorithm(InputArgs args, AutoFreeAlloc& shareAlloc) { AutoFreeAlloc privateAlloc; ... ResultDOM* result = STD_NEW(shareAlloc, ResultDOM); ResultNode* node = STD_NEW(shareAlloc, ResultNode); result->addNode(node); ... TempVariable* temp = STD_NEW(privateAlloc, TempVariable); ... return result; }
答案是,性能问题。我们这里对AutoFreeAlloc和ScopeAlloc这两个GC Allocator的性能进行了对比,结论如下:
生成一个新的AutoFreeAlloc实例是一个比较费时的操作,其用户应注意做好内存管理的规划。而生成一个ScopeAlloc实例的开销很小,你甚至可以哪怕为生成每一个对象都去生产一个ScopeAlloc都没有关系(当然我们并不建议你这样做)。
对于多数的算法而言,我们不能确定它所需要的私有存储(Private GC Allocator)的内存空间是多大。或者说,通常它们也许并不大。而在仅仅申请少量内存的情形下,使用AutoFreeAlloc是不太经济的做法。 而相对的,无论算法所需的内存多少,使用ScopeAlloc都可以获得非常平稳的性能。
故此,我们的第二个结论是:
AutoFreeAlloc有较强的局限性,仅仅适用于有限的场合(局部的复杂算法);而ScopeAlloc是通用型的Allocator,基本在任何情况下,你都可通过使用ScopeAlloc来进行内存管理,以获得良好的性能回报。
实现
看到这里,你的兴趣相信来了,很想知道ScopeAlloc是长什么样。其实,ScopeAlloc只是另一个“AutoFreeAlloc”。我们来看看它的定义:typedef AutoFreeAllocT<ProxyBlockPool> ScopeAlloc;
而我们的AutoFreeAlloc它的定义是:
typedef AutoFreeAllocT<DefaultStaticAlloc> AutoFreeAlloc;
详细的代码,参考以下链接:
ScopeAlloc.h
AutoFreeAlloc.h
可以看出,ScopeAlloc和AutoFreeAlloc唯一的区别,在于AutoFreeAlloc向系统申请内存(调用的是 malloc/free),而ScopeAlloc向一个内存池(即BlockPool,调用的是BlockPool:: allocate/deallocate)。
BlockPool
BlockPool 就是通常我们所说的内存池(Memory Pool)。但是它比一般的内存池要简单很多,因为它只是管理MemBlock,而不负责对MemBlock进行结点(Node)2的划分(这个工作实际上由AutoFreeAllocT完成了)。BlockPool的规格如下:
class BlockPool { BlockPool(int cbFreeLimit, int cbBlock); void* allocate(size_t cb); // 申请一个MemBlock void deallocate(void* p); // 释放一个MemBlock void clear(); // 清空所有申请的内存 };
关于该类的实现细节,我并不多解释,大家可以参考内存池(MemPool)技术详解。我解释下构造函数的两个参数:cbFreeLimit、cbBlock是什么。
cbBlock
这个量比较好解释,是指单个MemBlock的字节数。cbFreeLimit
大家都知道,内存池技术在释放内存时,它并不是将内存真的释放(还给系统),而是记录到一个FreeList中,以加快内存申请的速度。但是这带来 的一个问题是,内存池随着时间的推移,其占有的内存会不断 地增长,从而不断地吃掉系统的内存。cbFreeLimit的引入是为了限制了FreeList中的内存总量,从而抑制这种情况的发生。在 BlockPool中的FreeList内存达到cbFreeLimit时,deallocate操作直接释放MemBlock。代码如下:class BlockPool { public: void deallocate(void* p) // 提醒:m_nFreeLimit = cbFreeLimit / cbBlock + 1 { if (m_nFree >= m_nFreeLimit) { free(p); } else { _Block* blk = (_Block*)p; blk->next = m_freeList; m_freeList = blk; ++m_nFree; } } }[/code]
ProxyBlockPool
它只是BlockPool的代理。定义如下:typedef ProxyAlloc<BlockPool> ProxyBlockPool;
而Proxy是什么?简单地不能再简单:
template <class AllocT> class ProxyAlloc { private: AllocT* m_alloc; public: ProxyAlloc(AllocT& alloc) : m_alloc(&alloc) {} public: void* allocate(size_t cb) { return m_alloc->allocate(cb); } void deallocate(void* p) { m_alloc->deallocate(p); } void swap(ProxyAlloc& o) { std::swap(m_alloc, o.m_alloc); } };
ScopeAlloc
如上所述,ScopeAlloc只是一个typedef:typedef AutoFreeAllocT<ProxyBlockPool> ScopeAlloc;
而关于AutoFreeAlloc的细节,前面《C++内存管理变革(2):最袖珍的垃圾回收器 - AutoFreeAlloc》中我们已经做了详细介绍。
ThreadModel
关于线程模型(ThreadModel),从上面给出的代码(ScopeAlloc.h)中你可以看到相关的代码。但是详细的解释超出了本文的范畴,我们会另外一篇专门解释GC Allocator与线程模型(ThreadModel)之间的关系3。时间性能分析
关于性能问题,我们前面已经作了AutoFreeAlloc和ScopeAlloc的性能对比。这里简单再做一下分析。内存申请/释放过程
这两个过程ScopeAlloc与AutoFreeAlloc基本差不多。考虑到ScopeAlloc使用了MemPool技术,从统计意义上来讲,如果系统存在频繁的内存申请和释放,则ScopeAlloc性能略好于AutoFreeAlloc。构造过程
基本上都只是指针赋值,可忽略不计。析构过程
由于ScopeAlloc析构时将内存归还给内存池,而不是还给系统,ScopeAlloc的时间性能要好过AutoFreeAlloc许多。更确 切地讲,两者的时间复杂度都是O(N),其中N为MemBlock的个数(也就是Allocator所占的内存总量),但是由于释放MemBlock操作 的单位时间不同(BlockPool::deallocate比free快许多),导致两者的性能有异。使用样例
AutoFreeAlloc和ScopeAlloc的性能对比中当然不是ScopeAlloc的典型用例。这里我们举一个:class Obj { private: int m_val; public: Obj(int arg = 0) { m_val = arg; printf("construct Obj: %d/n", m_val); } ~Obj() { printf("destruct Obj: %d/n", m_val); } }; void testScope() { std::BlockPool recycle; std::ScopeAlloc alloc(recycle); printf("/n------------------- global: have 3 objs ----------------/n"); { Obj* a1 = STD_NEW(alloc, Obj)(0); Obj* a2 = STD_NEW_ARRAY(alloc, Obj, 2); printf("------------------- child 1: have 4 objs ----------------/n"); { std::ScopeAlloc child1(alloc); Obj* o1 = STD_NEW(child1, Obj)(1); Obj* o2 = STD_NEW_ARRAY(child1, Obj, 3); printf("------------------- child 11: have 3 objs ----------------/n"); { std::ScopeAlloc* child11 = STD_NEW(child1, std::ScopeAlloc)(child1); Obj* o11 = STD_NEW(*child11, Obj)(11); Obj* o12 = STD_NEW_ARRAY(*child11, Obj, 2); } printf("------------------- leave child 11 ----------------/n"); printf("------------------- child 12: have 3 objs ----------------/n"); { std::ScopeAlloc child12(child1); Obj* o11 = STD_NEW(child12, Obj)(12); Obj* o12 = STD_NEW_ARRAY(child12, Obj, 2); } printf("------------------- leave child 12 ----------------/n"); } printf("------------------- leave child 1 ----------------/n"); printf("------------------- child 2: have 4 objs ----------------/n"); { std::ScopeAlloc child2(alloc); Obj* o1 = STD_NEW(child2, Obj)(2); Obj* o2 = STD_NEW_ARRAY(child2, Obj, 3); } printf("------------------- leave child 2 ----------------/n"); } }
这个样例中,child11是特别要注意的。请注意child11它是new出来的,我们忘记释放它4。但是不要紧,在child1析构时,child11将会被删除。
我们看到,有了ScopeAlloc,内存管理就可以层层规划,成为一个内存管理树(逻辑ScopeAlloc树5)。你可以忘记释放内存(事实上你不能释放,只能clear),ScopeAlloc会记得为你做这样的琐事。这正是GC Allocator的精髓。
ScopeAlloc的名字来由,看这个样例就可以体会一二了。在《C++内存管理变革(1): GC Allocator》我们特别提到,内存管理有很强的区域性。在不同的区域(Scope),由于算法不同,而导致对Allocator需求亦不同。从总体上来讲,ScopeAlloc有更好的适应性,适合更为广泛的问题域。
Footnotes
1. StdExt库最初提供了New<Type>函数函数,但是后来最终还是决定废弃之。
2. 在 boost 的 pool/object_pool 类中,结点(Node)被称为块(Chunk)。
3. 你已经看到,AutoFreeAlloc并不考虑ThreadModel,而ScopeAlloc也并不完全支持。
4. 实际上也没有办法释放,因为根本没有STD_DELETE这样的东西,不过可以调用child11->clear()来释放由child11负责的内存。
5. ScopeAlloc的层次是逻辑上的。实际上ScopeAlloc并无层次。如下:
std::BlockPool recycle; std::ScopeAlloc alloc(recycle); { std::ScopeAlloc child(alloc); ... }[/code]
并不是说child就是alloc的子存储(Allocator)。只是通常child生命周期比alloc短,从而有逻辑上的父子关系。
以上代码等价于:
std::BlockPool recycle; std::ScopeAlloc alloc(recycle); { std::ScopeAlloc child(recycle); ... }[/code]
这里就很容易看出,其实alloc、child是平等的
C++内存管理变革(7):基于ScopeAlloc的STL容器
许式伟2008-2-4
来由
在前文(请参阅《C++内存管理变革(6):通用型垃圾回收器 - ScopeAlloc》),我们介绍了ScopeAlloc。既然我们称之为一个通用型的GC Allocator,那么这里我们就谈谈如何用ScopeAlloc来改造STL的容器,它们包括:std::list, std::map, std::set, std::multimap, std::multiset。改造方法
不改变std::list, std::map, std::set, std::multimap, std::multiset等STL容器的用法,仅仅将其Allocator换成ScopeAlloc。做法不难想到,这需要我们提供一个GC Allocator到STL的Allocator的Wrapper类。它就是我们的StlAlloc类。StlAlloc
代码参见:<stdext/Memory.h>)
template <class _Ty, class _Alloc = ScopeAlloc> class StlAlloc { private: _Alloc* m_alloc; public: typedef size_t size_type; typedef ptrdiff_t difference_type; typedef _Ty* pointer; typedef const _Ty* const_pointer; typedef _Ty& reference; typedef const _Ty& const_reference; typedef _Ty value_type; public: pointer address(reference val) const { return &val; } const_pointer address(const_reference val) const { return &val; } size_type max_size() const { size_type count = (size_type)(-1) / sizeof (_Ty); return (0 < count ? count : 1); } public: StlAlloc(_Alloc& alloc) : m_alloc(&alloc) {} pointer allocate(size_type count, const void*) { return m_alloc->allocate(count * sizeof(_Ty)); } void deallocate(void* p, size_type cb) { m_alloc->deallocate(p, cb); } void construct(pointer p, const _Ty& val) { new(p) _Ty(val); } void destroy(pointer p) { p->~_Ty(); } public: char* _Charalloc(size_type cb) { return (char*)m_alloc->allocate(cb); } };
关于StlAlloc,并没有特别重要的细节需要交代。只有一个细节,是非常有意思的,就是Visual C++ 6.0为allocator引入了一个特殊的函数:_Charalloc。这个函数的功能类似于malloc,用于分配一块不确定企图的普通内存块(请注意,实际上allocator的本意要避免使用这样的方法,allocator分配的内存应该是签名的,或者说带类型信息的)。
再谈STL的Allocator
实际上,STL的Allocator推荐使用rebind技巧解决“同一个STL容器需要分配多种类型的对象”问题。关于allocator详细规格和rebind技术的细节,请参阅《CUJ:标准库:Allocator能做什么?》。Visual C++ 6.0版本带的STL引入了特殊的_Charalloc函数,其目的是规避标准中要求STL allocator实现的rebind而已。不同版本的STL,其allocator规格存在不一致性。例如:
Visual C++ 6.0之STL库引入了特殊的_Charalloc。
SGI STL有自己的alloc组件,和我们这里的StlAlloc一样,SGI STL的std::allocator只是自己的alloc组件的一个Wrapper。并且默认情形下使用alloc组件,而不是std:: allocator。这说明SGI STL的设计者并不喜欢标准的allocator。
这种不一致性的本质原因是,STL的allocator存在一定的设计缺陷。为了更加“C++”,设计者试图去让allocator分配的内存是签 名的(或者说带类型信息的)。但是,签名意味着它不是通用的内存管理组件,无法取代new/delete, malloc/free的地位。为了避免暴露类的实现细节,_Charalloc这样的东西的存在就在所难免了(rebind技术是繁琐且丑陋的)。
改造后的STL容器
改造后的STL容器,我们命名为std::List, std::Map, std::Set, std::MultiMap, std::MultiSet。其源码请参考:<stdext/List.h>
<stdext/Map.h>
<stdext/Set.h>
实际上我们除了重写了构造函数,并没有改变任何东西。以Map类为例,改造的代码如下:
template < class KeyT, class DataT, class PredT = std::less<KeyT>, class AllocT = ScopeAlloc > class Map : public std::map< KeyT, DataT, PredT, StlAlloc<DataT, AllocT> > { private: typedef StlAlloc<DataT, AllocT> _Alloc; typedef std::map<KeyT, DataT, PredT, _Alloc> _Base; public: explicit Map(AllocT& alloc, const PredT& pred = PredT()) : _Base(pred, alloc) { } template <class Iterator> Map(AllocT& alloc, Iterator first, Iterator last, const PredT& pred = PredT()) : _Base(first, last, pred, alloc) { } };
使用改造后的STL容器
一个简单的样例如下:typedef std::Map<int, int> MapT; std::BlockPool recycle; std::ScopeAlloc alloc(recycle); MapT coll(alloc); coll.insert(MapT::value_type(1, 2)); coll.insert(MapT::value_type(1, 4)); coll.insert(MapT::value_type(2, 4)); coll.insert(MapT::value_type(4, 8));
实际上,也可以使用AutoFreeAlloc作为Allocator,如下:
typedef std::Map<int, int, std::less<int>, std::AutoFreeAlloc> MapT; std::AutoFreeAlloc alloc; MapT coll(alloc); coll.insert(MapT::value_type(1, 2)); coll.insert(MapT::value_type(1, 4)); coll.insert(MapT::value_type(2, 4)); coll.insert(MapT::value_type(4, 8));
特别提醒:
请注意:虽然支持AutoFreeAlloc,但是由于AutoFreeAlloc本身的局限性,使得这样的STL容器仅限于有限的场合。请注意其应用场景。
改造后的STL容器的性能评估
虽然结果是不言而喻的,但也许你仍然关心关于改造后的STL容器性能的具体数据。这一小结就进行简单对比测试。如下:《各种内存分配器在STL容器上的性能对比》
结论是:
使用ScopeAlloc或者AutoFreeAlloc后,STL容器的性能有显著提高。而ScopeAlloc与AutoFreeAlloc在该容器存在大量内存分配时区别不显著。另外,由于内存池技术的作用,ScopeAlloc在Share同一个BlockPool后,性能略有改善。
为什么没有std::vector等容器?
也许你很奇怪,既然ScopeAlloc可以改善STL容器的性能,为什么我们不将它应用到所有的STL容器。其实,问题在于其实那些容器它并不需要ScopeAlloc。这些容器包括:std::vector
std::basic_string, std::string, std::wstring
std::deque, std::stack, std::queue
无论是AutoFreeAlloc,还是ScopeAlloc,以及其他所有号称可以改善内存分配性能的Allocator, 仅仅适用于小内存管理。对于那些本身是线性内存结构的(如:std::vector, std::basic_string, std::string, std::wstring等),或者已经采用MemBlock(内存块)结构的(如:std::deque, std::stack, std::queue等),并不需要他们。这些容器并不需要一个特别的Allocator,普通的new/delete足矣。