资讯详情

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

📅 2026/10/13 9:01:37 | 华诺云谱 👁 阅读
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[]的语义是分配内存并默认构造所有元素这个默认构造往往是白干的。比如vectorint扩容到新容量时如果底层用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_template0和__malloc_alloc_template1是两个完全不同的类各自拥有独立的静态成员。后面你会看到二级配置器也用了同样的技巧目的是让用户可以在同一份代码里声明多个互相隔离的配置器实例。2.2 oom处理机制与new_handler挂钩malloc失败返回NULL时一级配置器不会直接放弃而是调用_S_oom_malloc进入一个循环template int inst void* __malloc_alloc_templateinst::_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而不是普通的structunion _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)的结果是16FREELIST_INDEX(10)的结果是1也就是16字节档。ROUND_UP(8)的结果是8FREELIST_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时传入nobjs20的引用_S_chunk_alloc可能会把这个数字改小。拿到一整块内存后_S_refill会把这块内存切分成多个小节点用_M_free_list_link串成链表void* __default_alloc_templatethreads, 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 160bytes_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 320total_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) 16free-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_templatefalse, 0和__default_alloc_templatefalse, 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操作的是同一套底层内存。静态成员带来一个工程上必须注意的问题如果启用了threadstrue所有allocate和deallocate都要加锁。SGI-STL在支持线程的构建下会使用原子操作或自旋锁保护free-list的摘取与挂回。但锁的粒度很大多个线程频繁分配小对象时会形成竞争。现代高性能系统往往不愿意用这种全局锁而是更倾向于每线程独立的内存池。这也是为什么尽管SGI配置器很经典真正在高并发场景中直接复用它的人并不多。另外静态内存池不会在进程结束前主动归还系统。free-list里存着的大量空闲块以及内存池中尚未切分的容量都会一直占着虚拟内存。这在长期运行的服务器程序里会造成一种假象内存占用只升不降。后面第12章会专门讲这个坑。7. uninitialized_xx函数要解决的根本问题7.1 placement new与分离的内存/对象生命周期空间配置器把内存分配和对象构造拆开了那么在已分配内存上构造对象就必须借助placement newtemplate 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已经拿到了内存为什么不在里面直接把对象构造好让使用者一步到位因为配置器一旦知道对象类型就丧失了通用性。allocatorvoid在标准里长期存在配置器本身需要支持各种不同元素类型。容器在扩容时往往需要先分配新内存再逐个拷贝旧元素这个拷贝动作本身有自己的异常安全要求必须由调用者在更高层次控制。如果配置器里直接构造对象那么当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_traitsU::is_POD_type is_POD; __uninitialized_fill_aux(first, last, x, is_POD()); }value_type(first)用来提取迭代器所指元素的类型然后通过__type_traits判断这个类型是不是POD。如果是POD类型直接调用std::fill走普通赋值如果不是POD就逐个placement newtemplate 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提供了特化版本直接用memmoveinline 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_typeSGI-STL在C98时代还没有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_traitsint { typedef __true_type is_POD_type; };对内置类型做全特化对自定义类型默认是__false_type。为什么非要绕这么多弯直接用if判断不好吗关键在于运行期判断和编译期分派的差别。类型是否是POD在编译期就已确定可以通过函数重载决议选择不同的实现而不是在运行时用typeid或if判断。编译期分派能消除所有运行期判断开销让POD类型走的路径和直接调用fill/copy几乎一样快。这套traits后来被现代C的std::is_trivially_copyable、std::is_trivial取代了但思想完全一致。你甚至可以在C11以后手写一个更简洁的版本template typename T using is_pod_like std::integral_constantbool, std::is_trivial_vT std::is_standard_layout_vT;9.2 非POD类型为什么必须逐元素构造自定义类型里可能有指针成员、锁、虚函数表或者构造函数中会申请资源。如果跳过构造函数直接拷贝内存轻则资源泄漏重则产生悬垂指针和二次释放。比如一个std::string内部持有堆指针POD优化下直接把旧string的内存拷贝到新位置两个string的指针指向同一块堆内存析构时双重释放程序直接崩溃。uninitialized_copy的非POD路径不仅逐元素构造还要在构造过程中异常安全地清理前面已经构造的对象。这比一次memcpy完事慢很多但这是必须付出的代价。工程师可以用std::is_trivially_copyable在编译期判断自定义类型是否可以安全地用memcpySGI的__type_traits正是在做最早期的这类尝试。9.3 定位到正确特化value_type的传递uninitialized_copy源码里反复出现value_type(result)这个惯用法借助迭代器traits拿到指向的类型。如果目标迭代器是泛型类型value_type返回该迭代器的value_type嵌套类型如果是指针则直接返回指针所指的类型。拿到类型后__type_traitsT::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_traitsT::has_trivial_destructor trivial_destructor; __destroy_aux(first, last, trivial_destructor()); }这一层优化与uninitialized_xx里的POD判断相辅相成构造时省掉无意义的默认构造析构时省掉无意义的析构调用。对std::vectorint这种容器扩容的拷贝本质就是一次memmove而这一切在编译期就被决定好了。11. 从SGI到现代Cuninitialized_xx的演进与取舍11.1 C11/C17标准库的变化SGI-STL的设计虽然经典但现代标准库已经做了大量演进。C11以后std::allocator_traits把分配器的接口统一起来容器不再直接依赖allocator::construct而是通过allocator_traits转发。C17引入了std::uninitialized_default_construct和std::uninitialized_value_constructuninitialized_default_construct在裸内存上不初始化直接构造对内置类型完全不做任何写入操作uninitialized_value_construct在裸内存上做值初始化内置类型会被清零。为什么要区分这两个因为默认构造和值初始化的语义在类类型上可能不同而性能敏感场景里避免不必要的清零可能省下巨额开销。比如你准备用一段内存当缓冲区写入前本来就要覆盖它那清零纯属浪费时间。C20又增加了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二级配置器在threadsfalse时free-list的摘取和挂回没有任何同步保护。如果两个线程同时调用allocate(8)可能都读到同一个头节点然后各自更新链表指针最终导致链表断裂或重复分配同一块内存。这个Bug在测试环境里不一定会立刻触发一旦触发就是莫名其妙的段错误。我在自己的项目里把SGI配置器按threadsfalse编译到了一套单线程渲染引擎里引擎本身是单线程的没问题。后来接入了一个异步IO线程它在回调里也会分配某些小对象当晚就出现了崩溃。排查时把所有allocator相关调用栈打出来才定位到free-list竞争。这提醒我任何全局性的内存池都要明确标注线程安全边界并建立一套自检机制。12.4 一个微基准结果供参考最后给一个简单的对比数据。用SGI二级配置器思路实现的分配器对16字节大小对象的分配释放循环1000万次比直接mallocfree快大约3到5倍。这个数字依赖平台和malloc实现不能当普遍结论但趋势是一致的减少系统调用、复用内存块、在用户态维护链表这些优化对小对象密集场景确实有效。如果要在自己的项目里尝试我建议从最简版本开始只保留free-list和内存池不要一开始就上线程安全、不需要考虑系统调用失败后的复杂回退。跑通之后再加锁、加回收策略、加对齐扩展。这个渐进路线比一次性照搬SGI全部代码要靠谱得多。等踩过一遍坑再回头读stl_alloc.h里的每个分支你会真正明白那些看似多余的处理逻辑都是在真实系统里用崩溃换来的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑