ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SGI-STL空间配置器源码剖析:两级配置器与内存池设计

SGI-STL空间配置器源码剖析:两级配置器与内存池设计

第一次翻开SGI-STL源码时,大部分人都会在<stl_alloc.h>和<stl_uninitialized.h>面前停下来。这两个文件不像vector、list那样能让你立刻看到数据结构的样子,它们做的是更底层的事:在还没构造任何对象之前,先把裸内存准备好;在对象生命周期开始之后,再把构造动作逐个落到内存上。SGI-STL空间配置器就是把这两件事拆开处理的经典实现,而uninitialized_xx系列函数则负责"在裸内存上构造对象"这个容易被忽略的环节。这篇文章不打算复述教科书上的结论,而是带着你顺着源码逻辑走一遍:一级配置器怎么兜底、二级配置器的free-list和内存池怎么配合、uninitialized_copy/fill/fill_n为什么要区分POD类型,以及在实际工程里替换默认分配器时最容易踩的坑。

1. 从malloc/free到空间配置器:为什么需要两级设计

1.1 内存分配与对象构造被混在一起带来的问题

很多初学者会有个疑问:vector不就是一个封装了动态数组的类吗,底层直接用new[]不就行了?SGI-STL给出的答案是否定的。直接使用new[]看似省事,实际上把两件完全不同的事绑在了一起:operator new负责找一块足够大的内存,构造函数负责在这块内存上建立对象。当容器需要扩容时,它真正想要的是"一块和原来无关的全新内存,然后在上面逐个拷贝旧元素",而new[]的语义是"分配内存并默认构造所有元素",这个默认构造往往是白干的。

比如vector<int>扩容到新容量时,如果底层用new int[n],系统会先对n个int做默认初始化,然后你再用std::copy把旧元素拷过去。等于每个元素被初始化了两次,第一次的默认构造纯属浪费。更麻烦的是,容器需要的分配粒度是"元素大小",而malloc在返回的指针附近还要附带一些管理信息,频繁申请小块内存时,这些管理开销会非常可观,内存碎片也会越积越多。

SGI-STL空间配置器把这两个问题分开解决。allocate和deallocate只负责内存块的获取与回收,construct和destroy负责对象生命周期的开始与结束,uninitialized_xx系列则在两者之间搭桥。这套设计不只是SGI自己的偏好,后来C++标准库里的allocator和std::uninitialized_*自由函数,本质上走的还是这条路线。

1.2 两级配置器的分工逻辑

SGI-STL的配置器叫alloc,它内部实际是两个类模板的集合:一级配置器__malloc_alloc_template和二级配置器__default_alloc_template。判断逻辑非常朴素:申请内存大于128字节,走一级配置器,直接调用malloc;小于等于128字节,走二级配置器,从内存池中切割。

为什么要设128字节这个门槛?因为小内存块的分配频率远高于大内存块,而malloc对小块内存的管理开销占比最高。一个8字节的对象,在malloc内部可能要消耗16字节甚至更多的管理结构,而且频繁分配释放会造成碎片。二级配置器就是为了缓解这个问题:一次向系统要一大块,然后在用户层切成等尺寸的小块,用free-list管理,分配释放都在用户态完成,不惊动操作系统。

两级配置器不是二选一的关系,而是协作关系。二级配置器自己其实不直接调用malloc,它在内存池不够用或者系统拒绝分配时,会转而向一级配置器申请内存;一级配置器在malloc失败时会调用用户自定义的oom_handler,如果连handler都没有,就抛出bad_alloc。所以你可以把一级配置器理解成"最后一道防线",二级配置器是"前台缓冲"。

2. 一级空间配置器:以malloc为底层的兜底实现

2.1 __malloc_alloc_template的核心结构

一级配置器的代码非常短,本质上就是对malloc、realloc、free的一层薄封装。它的类模板长这样:

template <int inst> class __malloc_alloc_template { private: static void* _S_oom_malloc(size_t); static void* _S_oom_realloc(void*, size_t); static void (* __malloc_alloc_oom_handler)(); public: static void* allocate(size_t n) { void* result = malloc(n); if (0 == result) result = _S_oom_malloc(n); return result; } static void deallocate(void* p, size_t /* n */) { free(p); } static void* reallocate(void* p, size_t /* old_sz */, size_t new_sz) { void* result = realloc(p, new_sz); if (0 == result) result = _S_oom_realloc(p, new_sz); return result; } };

注意deallocate的第二个参数n在这里被丢弃了。一级配置器不需要知道原始块大小,因为free只认指针。这跟二级配置器的deallocate形成鲜明对比——二级配置器必须知道块大小才能找到对应的free-list。

类模板参数inst初看毫无用处,整个类里没有任何成员变量依赖它。它的作用仅仅是把模板实例化出多个互不相同的类型。比如__malloc_alloc_template<0>和__malloc_alloc_template<1>是两个完全不同的类,各自拥有独立的静态成员。后面你会看到,二级配置器也用了同样的技巧,目的是让用户可以在同一份代码里声明多个互相隔离的配置器实例。

2.2 oom处理机制与new_handler挂钩

malloc失败返回NULL时,一级配置器不会直接放弃,而是调用_S_oom_malloc进入一个循环:

template <int inst> void* __malloc_alloc_template<inst>::_S_oom_malloc(size_t n) { void (* my_malloc_handler)(); void* result; for (;;) { my_malloc_handler = __malloc_alloc_oom_handler; if (0 == my_malloc_handler) { throw bad_alloc(); } (*my_malloc_handler)(); result = malloc(n); if (result) return result; } }

这个机制和C++标准的set_new_handler如出一辙。用户可以注册一个回调函数set_malloc_handler,当内存耗尽时先执行回调,回调里可能释放了某些缓存,然后循环再次尝试malloc。如果回调函数本身不能释放出足够内存,这个循环就会一直转下去;如果用户压根没设置回调,那就直接抛异常。

我最早读这段代码的时候觉得奇怪:为什么不在malloc返回NULL时立刻抛异常,非要搞一个handler循环?后来才明白,SGI-STL要兼顾老式的C++运行时,有些环境的new_handler机制就是这么设计的——让用户有机会在抛异常前做最后的自救。这个设计理念后来也影响了现代C++的nothrow new和自定义operator new的行为。

3. 二级空间配置器核心:free-list与内存池的设计

3.1 union obj的结构设计

二级配置器的核心数据结构是一个union,而不是普通的struct:

union _Obj { union _Obj* _M_free_list_link; char _M_client_data[1]; };

这个union非常巧妙。当内存块处于空闲状态时,第一个字节被当作指向下一个空闲块的指针使用;当这个块被分配给客户端后,整个内存块就完全交给客户端了,_M_client_data只是一个占位符,保证union至少能容纳一个字节。

如果用struct实现,一个空闲节点至少要有"next指针+数据区",即使数据区只是占位,空闲块也要额外消耗一个指针大小的内存。union则完全消除了这个开销:同一个内存位置,空闲时存指针,分配后存用户数据。这也是为什么二级配置器能比直接malloc省内存的原因之一。

整个free-list是一张静态数组,包含16个元素,分别管理8、16、24、32……直到128字节的块。数组类型是_Obj*,每个元素指向一条单链表的头节点:

static _Obj* _S_free_list[__NFREELISTS];

__NFREELISTS等于128 / 8,也就是16。

3.2 16条free-list如何按8字节对齐管理

当客户端申请n字节时,如果n不是8的倍数,需要向上取整到8的倍数,否则无法确定该把它放进哪条链表。两个关键宏:

static size_t ROUND_UP(size_t bytes) { return (((bytes) + __ALIGN - 1) & ~(__ALIGN - 1)); } static size_t FREELIST_INDEX(size_t bytes) { return (((bytes) + __ALIGN - 1) / __ALIGN - 1); }

ROUND_UP(10)的结果是16,FREELIST_INDEX(10)的结果是1,也就是16字节档。ROUND_UP(8)的结果是8,FREELIST_INDEX(8)的结果是0。用位运算实现对齐,比if (bytes % 8 == 0)判断要快得多,因为这个函数在分配路径上处于最热点。

我见过有读者在这里纠结:如果申请的是7字节,ROUND_UP后变成8,用户实际上得到了一块8字节的内存。用户可能只用了7字节,剩下的1字节浪费了。这是空间换时间的典型取舍,SGI认为小对象申请中浪费一个字节远比频繁调用malloc划算。而且这个对齐粒度在现代CPU上也友好,8字节对齐基本能满足绝大多数标量类型的对齐要求。

4. allocate与deallocate的完整调用链剖析

4.1 refill的扩展流程

allocate的代码不长,但分支逻辑要理清:

static void* allocate(size_t n) { void* ret = 0; if (n > (size_t) __MAX_BYTES) { ret = malloc_alloc::allocate(n); } else { _Obj* my_free_list = _S_free_list + FREELIST_INDEX(n); _Obj* result = *my_free_list; if (result == 0) ret = _S_refill(ROUND_UP(n)); else { *my_free_list = result->_M_free_list_link; ret = result; } } return ret; }

注意,取链表头节点时直接把第二个节点设为新的头节点,这样allocate的时间复杂度是O(1)。第一次调用时链表为空,result == 0,于是调用_S_refill从内存池批量取块。

_S_refill默认想取20块,但最终能取多少取决于内存池剩余量。它调用_S_chunk_alloc时传入nobjs=20的引用,_S_chunk_alloc可能会把这个数字改小。拿到一整块内存后,_S_refill会把这块内存切分成多个小节点,用_M_free_list_link串成链表:

void* __default_alloc_template<threads, inst>::_S_refill(size_t n) { int nobjs = 20; char* chunk = _S_chunk_alloc(n, nobjs); if (1 == nobjs) return chunk; _Obj* result = (_Obj*) chunk; _Obj* current_obj = 0; _Obj* next_obj = 0; _Obj* my_free_list = _S_free_list + FREELIST_INDEX(n); *my_free_list = next_obj = (_Obj*) (chunk + n); for (int i = 1; ; ++i) { current_obj = next_obj; next_obj = (_Obj*) ((char*) next_obj + n); if (nobjs - 1 == i) { current_obj->_M_free_list_link = 0; break; } else { current_obj->_M_free_list_link = next_obj; } } return result; }

当内存池只够分出一块时,nobjs变成1,_S_refill直接把这块返回,不建链表。这在内存极度紧张时能保证分配不失败。

4.2 chunk_alloc的内存池回填机制

_S_chunk_alloc是二级配置器里最核心的函数,它负责决定从内存池拿多少、何时向系统要新内存。先看它的整体结构:

static char* _S_chunk_alloc(size_t size, int& nobjs) { char* result; size_t total_bytes = size * nobjs; size_t bytes_left = _S_end_free - _S_start_free; if (bytes_left >= total_bytes) { result = _S_start_free; _S_start_free += total_bytes; return result; } else if (bytes_left >= size) { nobjs = bytes_left / size; total_bytes = size * nobjs; result = _S_start_free; _S_start_free += total_bytes; return result; } else { // 内存池剩余不足一块,准备向系统要新内存 } }

第一分支:剩余内存够20块,直接切走。第二分支:剩余内存不够20块但至少够1块,就按最大整数块切走。这两个分支都不会向系统申请新内存。真正复杂的在else分支。

else分支需要做三件事:把内存池里剩余的碎片回收到free-list、尝试malloc新内存、如果malloc失败则从更大尺寸的free-list中"借"内存。这里有个细节值得注意:内存池剩余碎片可能不是8的倍数,没关系,FREELIST_INDEX会把它归到对应档位,碎片也能被后续分配复用。

if (bytes_left > 0) { _Obj* my_free_list = _S_free_list + FREELIST_INDEX(bytes_left); ((_Obj*) _S_start_free)->_M_free_list_link = *my_free_list; *my_free_list = (_Obj*) _S_start_free; }

紧接着,定义这次需要申请的系统内存大小:

size_t bytes_to_get = 2 * total_bytes + ROUND_UP(_S_heap_size >> 4);

第一次调用时_S_heap_size为0,所以bytes_to_get恰好是2 * total_bytes,也就是需求量的两倍。为什么要两倍?因为内存池不能每次都恰好用完然后马上再要,否则就退化成了逐次malloc。多申请的量被缓存在内存池里,下一次相同尺寸的refill可以直接命中。_S_heap_size >> 4是增量调整项,随着系统累计分配量增大,每次补充的量也会略微增加,这一方面减少了malloc调用次数,另一方面让内存池规模随使用量平滑增长。

如果malloc失败,代码不会立即抛异常,而是扫描从当前size到128字节范围内的所有free-list,看看有没有空闲块可以"接济":

for (size_t i = size; i <= __MAX_BYTES; i += __ALIGN) { _Obj* my_free_list = _S_free_list + FREELIST_INDEX(i); _Obj* p = *my_free_list; if (p != 0) { *my_free_list = p->_M_free_list_link; _S_start_free = (char*) p; _S_end_free = _S_start_free + i; return _S_chunk_alloc(size, nobjs); } }

这里找到的空闲块成为新的内存池,然后递归调用_S_chunk_alloc。递归回来后可能还是不够,那就只好调用一级配置器的allocate,让oom_handler上场。如果连一级配置器都失败,异常就向上抛了,整个分配链彻底失败。

5. 内存池的chunk分配算法:一次完整的计算过程

5.1 内存池剩余字节计算

光看代码容易晕,用一个具体例子把整个过程走一遍。假设程序刚启动,配置器还没有任何内存池,_S_start_free和_S_end_free都为0,_S_heap_size为0。

第一次调用allocate(8)。8 <= 128,走二级配置器,free-list为空,于是调用_S_refill(8)。_S_refill把nobjs设为20,调用_S_chunk_alloc(8, nobjs)。

进入_S_chunk_alloc后,total_bytes = 8 * 20 = 160,bytes_left = 0 - 0 = 0。第一分支不成立,第二分支bytes_left >= size也不成立,进入else。此时bytes_left > 0不成立,不回收碎片。bytes_to_get = 2 * 160 + ROUND_UP(0 >> 4) = 320。malloc(320)成功,得到新内存池,_S_heap_size变成320,_S_end_free指向池尾,然后递归调用_S_chunk_alloc(8, nobjs)。

第二次进入,bytes_left = 320,total_bytes仍然是160,第一分支成立,切走160字节返回给_S_refill。_S_refill拿到160字节,切成20个8字节节点,把后19个串成free-list,返回第一个节点给allocate。此时内存池还剩160字节。

5.2 内存池耗尽后的递归分配路径

继续分配8字节块。只要free-list里还有节点,allocate就直接取走,不碰内存池。等20块全部取完,再次allocate(8)时,free-list为空,又进入_S_refill(8),nobjs重新设为20,调用_S_chunk_alloc(8, nobjs)。

这次bytes_left = 160,恰好等于total_bytes,第一分支成立,160字节全部切走。内存池归零。后续再分配8字节块时,内存池为空,只能再次malloc。如果连续大量分配,这个过程会反复发生,但每次malloc的bytes_to_get里都带上了ROUND_UP(_S_heap_size >> 4),所以申请量会逐渐变大,malloc次数反而会减少。

再考虑一个稍微复杂的场景:申请allocate(10)。ROUND_UP(10) = 16,free-list索引1,第一次分配触发_S_refill(16),一次取20块,消耗内存池320字节。如果你已经先分配了一些8字节块,内存池剩余可能不是16的整数倍。比如内存池剩余100字节,_S_chunk_alloc进入第二分支,nobjs = 100 / 16 = 6,于是只取6块,内存池剩下4字节。这4字节会被回收到索引0对应的free-list,供未来8字节分配使用。这就能解释为什么SGI配置器能够把内存池碎片利用得这么彻底。

_S_chunk_alloc还有一个容易被忽略的细节:当它向free-list借用内存并递归时,nobjs是引用传递,在递归之后可能会被改小。如果最终只借到一块能用的内存,_S_refill会返回单独一块而不建链表,这样即使系统内存严重不足,分配也能以"每次只给一块"的方式继续工作下去。

6. 空间配置器的模板参数与无状态对象设计

6.1 template 的用意

__default_alloc_template有两个模板参数:bool threads和int inst。threads控制是否启用线程锁,inst和一级配置器一样,纯粹为了产生不同实例。

typedef __default_alloc_template<__NODE_ALLOCATOR_THREADS, 0> alloc;

如果用户希望在同一进程内维护两个互相独立的内存池,可以声明__default_alloc_template<false, 0>和__default_alloc_template<false, 1>。因为模板参数不同,两个类的静态成员不共享,free-list和内存池都是各自独立的。

这个设计在现代C++看来依然有启发意义。标准库的std::allocator被要求是stateless的,不同类型之间拷贝allocator不会带来状态问题,SGI早就用模板参数实现了类似隔离。不过在绝大多数工程里,单例配置器就够了,我几乎没见过有人真的使用多个inst实例。

6.2 static成员与多实例共存问题

配置器的所有状态都存于静态成员:_S_free_list、_S_start_free、_S_end_free、_S_heap_size。这意味着同一个类型的配置器实例之间完全共享同一个内存池。容器拷贝、赋值时,allocator被复制,复制后的allocator和原allocator操作的是同一套底层内存。

静态成员带来一个工程上必须注意的问题:如果启用了threads=true,所有allocate和deallocate都要加锁。SGI-STL在支持线程的构建下会使用原子操作或自旋锁保护free-list的摘取与挂回。但锁的粒度很大,多个线程频繁分配小对象时会形成竞争。现代高性能系统往往不愿意用这种全局锁,而是更倾向于每线程独立的内存池。这也是为什么尽管SGI配置器很经典,真正在高并发场景中直接复用它的人并不多。

另外,静态内存池不会在进程结束前主动归还系统。free-list里存着的大量空闲块,以及内存池中尚未切分的容量,都会一直占着虚拟内存。这在长期运行的服务器程序里会造成一种假象:内存占用只升不降。后面第12章会专门讲这个坑。

7. uninitialized_xx函数要解决的根本问题

7.1 placement new与分离的内存/对象生命周期

空间配置器把内存分配和对象构造拆开了,那么"在已分配内存上构造对象"就必须借助placement new:

template <class T1, class T2> inline void construct(T1* p, const T2& value) { new (p) T1(value); }

new (p) T1(value)不分配内存,只调用构造函数,把对象建立在这块裸内存上。destroy则反过来调用析构函数:

template <class T> inline void destroy(T* pointer) { pointer->~T(); }

但容器面对的不是单个对象,而是一段区间。vector扩容时需要把旧区间的每个元素在新内存上逐个构造出来,uninitialized_copy就是干这个的;把一段区间统一填充为某个值,uninitialized_fill负责;从某个位置开始连续构造n个值,uninitialized_fill_n负责。这些函数统一的特点是:目标区域必须是未构造的裸内存,而不是已存在的对象区间。

7.2 为什么不能在配置器内部直接构造对象

有人会问:既然allocator::allocate已经拿到了内存,为什么不在里面直接把对象构造好,让使用者一步到位?因为配置器一旦知道对象类型,就丧失了通用性。allocator<void>在标准里长期存在,配置器本身需要支持各种不同元素类型。容器在扩容时往往需要先分配新内存,再逐个拷贝旧元素,这个"拷贝"动作本身有自己的异常安全要求,必须由调用者在更高层次控制。

如果配置器里直接构造对象,那么当uninitialized_copy拷贝到第5个元素时抛了异常,前面的4个对象谁来析构?配置器并不知道你拷贝了多长区间。只有uninitialized_copy这类函数才掌握"已经构造了多少个对象"这个信息,它们才有能力在异常发生时做清理并重新抛出异常。所以构造逻辑必须放在uninitialized_xx这一层,而不是配置器里。

8. uninitialized_copy/fill/fill_n逐行解读

8.1 uninitialized_fill的实现

uninitialized_fill(first, last, x)在一个未构造区间内,用x逐个构造对象:

template <class ForwardIterator, class T> inline void uninitialized_fill(ForwardIterator first, ForwardIterator last, const T& x) { __uninitialized_fill(first, last, x, value_type(first)); } template <class ForwardIterator, class T, class U> inline void __uninitialized_fill(ForwardIterator first, ForwardIterator last, const T& x, U*) { typedef typename __type_traits<U>::is_POD_type is_POD; __uninitialized_fill_aux(first, last, x, is_POD()); }

value_type(first)用来提取迭代器所指元素的类型,然后通过__type_traits判断这个类型是不是POD。如果是POD类型,直接调用std::fill走普通赋值;如果不是POD,就逐个placement new:

template <class ForwardIterator, class T> void __uninitialized_fill_aux(ForwardIterator first, ForwardIterator last, const T& x, __false_type) { for (; first != last; ++first) construct(&*first, x); }

POD版本的代码更简单:

template <class ForwardIterator, class T> inline void __uninitialized_fill_aux(ForwardIterator first, ForwardIterator last, const T& x, __true_type) { fill(first, last, x); }

注意两者语义不完全等价。非POD版本调用构造函数,POD版本使用赋值。对int、float这类内置类型,构造和赋值的底层都是写内存,结果一致;对包含虚函数、引用成员、自定义析构函数的类类型,就必须严格区分。

8.2 uninitialized_fill_n的实现

uninitialized_fill_n(first, n, x)构造从first开始的n个对象,返回指向最后一个构造对象之后的位置:

template <class ForwardIterator, class Size, class T> inline ForwardIterator uninitialized_fill_n(ForwardIterator first, Size n, const T& x) { return __uninitialized_fill_n(first, n, x, value_type(first)); }

非POD版本如下,注意异常处理:

template <class ForwardIterator, class Size, class T> ForwardIterator __uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __false_type) { ForwardIterator cur = first; try { for (; n > 0; --n, ++cur) construct(&*cur, x); return cur; } catch (...) { destroy(first, cur); throw; } }

这个实现用了"commit or rollback"策略:如果第k个对象构造失败,前面k-1个已经构造好的对象必须全部析构,否则内存泄漏。destroy(first, cur)正好清理了从first到cur之前的所有对象。

POD版本直接调用fill_n(first, n, x),没有异常风险,因为内置类型的赋值不会抛异常。这个分支不仅快,而且逻辑干净。

8.3 uninitialized_copy的实现

uninitialized_copy(first, last, result)把[first, last)区间内的元素拷贝到从result开始的未构造内存上:

template <class InputIterator, class ForwardIterator> inline ForwardIterator uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result) { return __uninitialized_copy(first, last, result, value_type(result)); }

注意这里的value_type(result)取自目标迭代器,而不是源迭代器。因为要构造的是目标位置上的对象,需要判断的是目标元素类型是否为POD。非POD版本的做法和uninitialized_fill_n如出一辙,逐元素construct,异常时destroy已构造区间,然后重新抛出。POD版本则调用copy(first, last, result)。

uninitialized_copy还有一个容易忽略的语义差异:源区间和目标区间在vector扩容场景下往往来自同一个容器,但目标内存是新分配的,和源内存不重叠。它和std::copy最大的不同就是:std::copy要求目标区间已有对象,只管赋值;uninitialized_copy要求目标区间是裸内存,直接构造。

8.4 char与wchar_t的特化

对于char*和wchar_t*,SGI提供了特化版本,直接用memmove:

inline char* uninitialized_copy(const char* first, const char* last, char* result) { memmove(result, first, last - first); return result + (last - first); } inline wchar_t* uninitialized_copy(const wchar_t* first, const wchar_t* last, wchar_t* result) { wmemmove(result, first, last - first); return result + (last - first); }

注意选择的是memmove而不是memcpy。两者的区别在于源和目的重叠时memcpy是未定义行为,而memmove能正确处理。虽然uninitialized_copy的常规语义不要求支持重叠,但因为这里是裸内存构造,使用者可能会把源和目的设在同一个底层缓冲区上,用memmove更稳妥。

uninitialized_fill和uninitialized_fill_n也有针对char*和wchar_t*的特化,分别转向fill和fill_n。理论上memset更快,但SGI没有直接使用memset,因为fill本身对char就有优化,而且保持语义上的一致性更安全。

9. __type_traits优化:POD类型如何绕过构造函数

9.1 __true_type与__false_type

SGI-STL在C++98时代还没有std::is_trivial,它自己实现了一套__type_traits,用空类__true_type和__false_type打标记。关键是is_POD_type这个成员:

template <class T> struct __type_traits { typedef __false_type is_POD_type; // 其他标记成员省略 }; template <> struct __type_traits<int> { typedef __true_type is_POD_type; };

对内置类型做全特化,对自定义类型默认是__false_type。为什么非要绕这么多弯,直接用if判断不好吗?关键在于"运行期判断"和"编译期分派"的差别。类型是否是POD在编译期就已确定,可以通过函数重载决议选择不同的实现,而不是在运行时用typeid或if判断。编译期分派能消除所有运行期判断开销,让POD类型走的路径和直接调用fill/copy几乎一样快。

这套traits后来被现代C++的std::is_trivially_copyable、std::is_trivial取代了,但思想完全一致。你甚至可以在C++11以后手写一个更简洁的版本:

template <typename T> using is_pod_like = std::integral_constant<bool, std::is_trivial_v<T> && std::is_standard_layout_v<T>>;

9.2 非POD类型为什么必须逐元素构造

自定义类型里可能有指针成员、锁、虚函数表,或者构造函数中会申请资源。如果跳过构造函数直接拷贝内存,轻则资源泄漏,重则产生悬垂指针和二次释放。比如一个std::string内部持有堆指针,POD优化下直接把旧string的内存拷贝到新位置,两个string的指针指向同一块堆内存,析构时双重释放,程序直接崩溃。

uninitialized_copy的非POD路径不仅逐元素构造,还要在构造过程中异常安全地清理前面已经构造的对象。这比"一次memcpy完事"慢很多,但这是必须付出的代价。工程师可以用std::is_trivially_copyable在编译期判断自定义类型是否可以安全地用memcpy,SGI的__type_traits正是在做最早期的这类尝试。

9.3 定位到正确特化:value_type的传递

uninitialized_copy源码里反复出现value_type(result),这个惯用法借助迭代器traits拿到指向的类型。如果目标迭代器是泛型类型,value_type返回该迭代器的value_type嵌套类型;如果是指针,则直接返回指针所指的类型。拿到类型后,__type_traits<T>::is_POD_type才能工作。这种在编译期层层分派的写法,是模板元编程在STL里最经典的应用之一。

10. 异常安全:commit or rollback策略

10.1 构造中途抛异常怎么办

uninitialized_copy在构造大量对象时,假设第5个对象的构造函数抛了异常,而前4个已经构造完成。如果不做任何处理,前4个对象永远不会被析构,它们持有的资源就泄漏了。SGI的处理方式是:把已经构造出来的那部分区间全destroy掉,然后重新抛出异常:

template <class InputIterator, class ForwardIterator> ForwardIterator __uninitialized_copy_aux(InputIterator first, InputIterator last, ForwardIterator result, __false_type) { ForwardIterator cur = result; try { for (; first != last; ++first, ++cur) construct(&*cur, *first); return cur; } catch (...) { destroy(result, cur); throw; } }

这段代码的细节值得仔细看。cur在每次构造成功后前移,所以异常发生时,cur恰好指向那个构造失败的位置,destroy(result, cur)析构的是[result, cur)这段已经成功构造的对象。注意它没有析构cur本身,因为那个位置上的对象还没构造成功,对它调用析构是危险的。

这也是为什么uninitialized_xx系列函数不能简单地用for循环实现就完事。普通的循环不管异常,裸奔;SGI管了,这就是它值得被研究的价值。现代C++标准库里的std::uninitialized_copy依然保留了commit or rollback语义,标准委员会甚至把它写进了异常安全规范。

10.2 destroy的递归分派与析构优化

SGI的destroy也有两级分派。销毁单对象总是调用析构;销毁区间时,先判断元素类型是否有trivial destructor。如果元素是内置类型,析构什么都不做,区间销毁直接跳过;如果自定义类型有非平凡析构,就逐个调用:

template <class ForwardIterator> inline void destroy(ForwardIterator first, ForwardIterator last) { __destroy(first, last, value_type(first)); } template <class ForwardIterator, class T> void __destroy(ForwardIterator first, ForwardIterator last, T*) { typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor; __destroy_aux(first, last, trivial_destructor()); }

这一层优化与uninitialized_xx里的POD判断相辅相成:构造时省掉无意义的默认构造,析构时省掉无意义的析构调用。对std::vector<int>这种容器,扩容的拷贝本质就是一次memmove,而这一切在编译期就被决定好了。

11. 从SGI到现代C++:uninitialized_xx的演进与取舍

11.1 C++11/C++17标准库的变化

SGI-STL的设计虽然经典,但现代标准库已经做了大量演进。C++11以后,std::allocator_traits把分配器的接口统一起来,容器不再直接依赖allocator::construct,而是通过allocator_traits转发。C++17引入了std::uninitialized_default_construct和std::uninitialized_value_construct:

  • uninitialized_default_construct:在裸内存上不初始化直接构造,对内置类型完全不做任何写入操作;
  • uninitialized_value_construct:在裸内存上做值初始化,内置类型会被清零。

为什么要区分这两个?因为默认构造和值初始化的语义在类类型上可能不同,而性能敏感场景里,避免不必要的清零可能省下巨额开销。比如你准备用一段内存当缓冲区,写入前本来就要覆盖它,那清零纯属浪费时间。

C++20又增加了std::uninitialized_move和std::uninitialized_move_n,专门用于移动语义下的裸内存构造。要知道SGI的时代根本还没有移动语义,vector扩容只能拷贝,而现代容器扩容能对移动便宜的类型走移动构造,性能提升非常明显。

11.2 现代libstdc++还保留着SGI的影子

如果你打开libstdc++的<bits/stl_uninitialized.h>,会发现里面的基本结构和SGI几乎一脉相承:先判断类型是否trivial,选择不同分派路径,再决定是逐元素construct还是批量赋值。区别在于判断工具从__type_traits换成了std::is_trivial、std::is_trivially_copyable,异常处理和迭代器traits也更规范。这算是"面向对象演进、核心思想不变"的典型案例。

11.3 手写SGI配置器的实际场景

现在还用不用得着SGI这套东西?我认为它的价值分为两个层面。第一层是学习价值,理解free-list、内存池、POD特化这些概念,对读任何现代内存管理库都有帮助,比如jemalloc、tcmalloc里的size class和per-thread cache,本质上都是SGI二级配置器的超集。第二层是应用价值,在嵌入式环境、游戏客户端中,没有标准库或者需要绕过通用分配器,完全可以把SGI的二级配置器思想拿出来改造:按对象大小分桶、复用空闲链表、批量向系统申请内存。我就见过一个网络框架,把每个连接的小缓冲区都放进类似SGI的free-list里管理,分配和释放都不碰全局堆,长连接场景下的性能改善非常明显。

12. 实测与踩坑:配置器微基准和常见错误

12.1 替换std::allocator后遇到的内存只升不降

有段时间我在一个测试服务里把std::allocator换成基于SGI二级配置器思想定制的分配器,小对象分配确实快了很多,但监控系统报出了内存持续增长。查到最后发现不是泄漏,而是free-list永远不会把空闲块归还给操作系统。

free-list的设计本质是"延迟释放"。内存块被deallocate后只是塞回链表头,不会调用free,这些块会一直待在进程的虚拟内存里。如果业务模式是"短时间大量分配,之后空闲很多",你会看到RSS居高不下。解决方式有两个:一是增加一个定时清理机制,当free-list里的块数量超过某个阈值时,真正free一部分;二是对分配大小做更严格的分桶限制,避免一个8字节块把整块大内存池都拖住。

SGI原始代码没有这个回收机制,因为它在设计时假设了进程生命周期和内存池一致。现代工程里如果直接抄,一定要考虑这个问题。

12.2 uninitialized_xx误用导致的未定义行为

uninitialized_copy的目标区间必须是裸内存,这一点违反时的后果非常隐蔽。有人会在已经构造好对象的数组上再次调用uninitialized_copy,表面上程序能跑,实际上每个对象被构造了两次。如果对象内部有指针,第一次构造的指针值直接被第二次覆盖,原先持有的资源没人释放,后续析构时又重复释放同一块堆内存,崩溃往往发生在完全不相干的地方。

另一个常见错误是在非POD类型上用了POD优化路径。有人会自作聪明地对自定义类型直接memcpy,因为"看起来就是个普通结构体"。一旦这个结构体里加了std::string或者虚函数,memcpy就把对象搞坏了。SGI的__type_traits默认保守地返回__false_type,就是提醒你不要轻易相信自己的直觉。

12.3 关闭线程开关后多线程分配的崩溃现场

SGI二级配置器在threads=false时,free-list的摘取和挂回没有任何同步保护。如果两个线程同时调用allocate(8),可能都读到同一个头节点,然后各自更新链表指针,最终导致链表断裂或重复分配同一块内存。这个Bug在测试环境里不一定会立刻触发,一旦触发就是莫名其妙的段错误。

我在自己的项目里把SGI配置器按threads=false编译到了一套单线程渲染引擎里,引擎本身是单线程的没问题。后来接入了一个异步IO线程,它在回调里也会分配某些小对象,当晚就出现了崩溃。排查时把所有allocator相关调用栈打出来才定位到free-list竞争。这提醒我:任何全局性的内存池都要明确标注线程安全边界,并建立一套自检机制。

12.4 一个微基准结果供参考

最后给一个简单的对比数据。用SGI二级配置器思路实现的分配器,对16字节大小对象的"分配+释放"循环1000万次,比直接malloc+free快大约3到5倍。这个数字依赖平台和malloc实现,不能当普遍结论,但趋势是一致的:减少系统调用、复用内存块、在用户态维护链表,这些优化对小对象密集场景确实有效。

如果要在自己的项目里尝试,我建议从最简版本开始:只保留free-list和内存池,不要一开始就上线程安全、不需要考虑系统调用失败后的复杂回退。跑通之后再加锁、加回收策略、加对齐扩展。这个渐进路线比一次性照搬SGI全部代码要靠谱得多。等踩过一遍坑,再回头读<stl_alloc.h>里的每个分支,你会真正明白那些看似多余的处理逻辑,都是在真实系统里用崩溃换来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进