尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入剖析SGI STL二级空间配置器:内存池与自由链表的高效实现

深入剖析SGI STL二级空间配置器:内存池与自由链表的高效实现 1. 从“为什么需要它”说起内存管理的现实困境如果你写过C尤其是用过STL容器那你一定对std::vector、std::list这些老朋友不陌生。它们用起来很方便但不知道你有没有想过当你不停地push_back、erase容器在背后是如何为你申请和释放那一块块内存的默认情况下它们调用的是new和delete底层是malloc和free。在大多数场景下这没问题但一旦进入高性能、高频次小对象分配的领域比如网络服务器、游戏引擎、高频交易系统原生的malloc/free就会成为性能瓶颈。问题出在哪核心就两点效率和碎片。每次调用malloc它都需要去管理一个全局的堆寻找合适大小的内存块可能还需要加锁来保证线程安全。对于频繁申请释放、大小固定的小对象比如几十个字节的链表节点、哈希表节点这种开销是巨大的。更糟糕的是频繁的小块内存分配释放会在堆中产生大量无法被利用的内存碎片就像一块瑞士奶酪看着总空间很大但找不到一块连续的空间来分配一个稍大的对象。SGI STL的设计者们很早就意识到了这个问题。他们想要的不是一个“通用但平庸”的内存分配器而是一个为STL自身数据结构特点量身定制的、高性能的分配器。这就是SGI STL二级空间配置器诞生的背景。它不是C标准的一部分但因其卓越的性能成为了GCC等编译器STL实现的默认选择在std::allocator背后深刻影响了C生态。今天我们就撕开它的外衣看看这个二十多年前设计的内存池其精妙之处到底在哪。理解它不仅能让你对STL有更深的认识更是学习系统级内存池设计的绝佳范例。2. 核心设计思想化整为零与自由链表二级空间配置器顾名思义它不是唯一的。SGI STL的内存分配其实有两套策略合称“双层级配置器”。第一级就是直接包装malloc和free用于大块内存分配默认阈值是128字节。而我们重点要剖析的第二级则专门处理小块内存128字节的分配。它的核心思想可以用两个词概括内存池和自由链表。想象一下如果你是一个包工头手下有很多工人小对象需要临时宿舍。与其每次来一个工人就去找政府操作系统申请一块地盖间房malloc效率太低。更聪明的做法是你一次性向政府申请一大块地内存池然后自己在这块地上规划建好一排排标准间。每个标准间大小固定比如8字节一间、16字节一间、32字节一间等等。当有工人需要宿舍时你就从对应大小的标准间里分配一间工人退房时房间不是拆掉而是标记为空闲等待下一个相同需求的工人入住。这些空闲的标准间就用链表串起来管理这就是自由链表。SGI STL二级配置器正是这么做的。它维护了一个数组里面是16个自由链表头。每个链表负责管理一种特定大小的内存块。这16种大小分别是8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128字节。你会发现它们都是8的倍数。这是一种对齐策略同时也简化了管理。那么一个关键问题来了当容器需要一个N字节的内存时配置器如何找到对应的自由链表它使用了一个非常巧妙的向上取整算法(N _ALIGN - 1) ~(_ALIGN - 1)其中_ALIGN是8。这个位操作等价于((N 7) / 8) * 8但效率更高。例如申请30字节计算后得到32字节于是就去第4号自由链表负责32字节获取内存。每个自由链表节点即那些“标准间”其结构更加巧妙。在分配出去给用户使用时它是一块纯净的内存。但当它被释放回来、挂在自由链表上时它的前4个字节在32位系统或前8个字节在64位系统被用来存储一个指针指向下一个空闲块。这意味着自由链表本身不需要额外的内存来维护节点结构它利用内存块自身的头部来存储链表指针。这是一种典型的嵌入式指针技术将数据结构嵌入到数据本身实现了零开销的内存管理。这是二级配置器第一个精妙绝伦的设计。3. 源码骨架与关键数据结构拆解理论说再多不如直接看代码。SGI STL的二级空间配置器通常实现在stl_alloc.h文件中具体文件名可能因版本而异。我们来看其最核心的数据结构和函数骨架。为了清晰我会对原始宏定义和模板参数进行适当简化聚焦于逻辑本质。首先是自由链表节点的定义。它本质上就是一个联合体union objunion obj { union obj* free_list_link; // 指向下一个空闲块 char client_data[1]; // 指向实际分配给用户的数据区 };这个union是理解自由链表管理的关键。当这块内存空闲、在链表上时第一个字段free_list_link有效它指向链表中下一个空闲块。当这块内存被分配出去时整个内存块从起始地址开始都交给用户程序使用client_data这个字符数组的起始地址就是用户拿到的指针。因为union共享内存所以不会为了维护链表而多占用一个指针的空间实现了零额外开销。接下来是二级配置器的类主体。我们称之为__default_alloc_template。它内部维护的核心状态如下class __default_alloc_template { private: // 向上取整到8的倍数 static size_t ROUND_UP(size_t bytes) { return (bytes _ALIGN - 1) ~(_ALIGN - 1); } // 根据字节数找到对应的自由链表索引 (0~15) static size_t FREELIST_INDEX(size_t bytes) { return (bytes _ALIGN - 1) / _ALIGN - 1; } // 核心16个自由链表的头指针数组 static obj* volatile free_list[_NFREELISTS]; // 内存池状态起始位置和剩余字节数 static char* start_free; static char* end_free; static size_t heap_size; // ... 其他成员函数如 allocate, deallocate, refill, chunk_alloc };这里有几个关键点free_list一个包含16个指针的数组每个指针指向一个空闲块链表。volatile关键字在某些实现中用于防止编译器过度优化保证多线程环境下尽管SGI这个版本本身不是线程安全的读取该值的正确性。start_free和end_free这两个指针定义了内存池的边界。start_free指向池中剩余内存的起始点end_free指向池的末尾。它们之间的区域就是可被切割成小块分配给自由链表的内存。heap_size一个累计值记录总共向系统申请了多少内存用于一些启发式策略。这个结构清晰地勾勒出了二级配置器的全景图一个由16条自由链表组成的“零售部”和一个由start_free、end_free围起来的“中央仓库”内存池。零售部没货了就去中央仓库补货中央仓库缺货了就向系统malloc大批量进货。4. 分配算法 allocate从零售到补货的全流程现在我们深入到最常用的allocate函数。当vector需要为一个新元素分配内存时就会调用它。它的逻辑是一个典型的分层查找过程。第一步需求对齐与索引计算。用户传入一个字节数n。配置器首先调用ROUND_UP(n)将其上调至8的倍数。然后通过FREELIST_INDEX计算出对应大小的自由链表索引idx。第二步尝试从自由链表获取零售。查看free_list[idx]是否为空。如果不为空那么太好了链表第一个节点就是一块现成的、大小刚好合适的内存。操作就是经典的链表头删将指向这块内存的指针result赋值为当前链表头free_list[idx]然后将链表头更新为result-free_list_link即下一个空闲块。最后将result返回给用户。这个过程极快几乎就是几次指针操作。第三步自由链表为空触发补货流程refill。如果对应的自由链表是空的说明这种尺寸的“标准间”暂时售罄。这时就需要调用refill(size_t n)函数来补充库存。n已经是调整后的大小如32字节。refill的责任是向内存池申请一批默认为20个大小为n的块把它们串成一条新的自由链表并返回第一块给用户使用。但refill自身并不直接与系统交互它调用的是更底层的chunk_alloc函数来从内存池获取一大块连续内存。第四步chunk_alloc——内存池的精华。这是整个二级配置器中最复杂、也最体现设计者功力的函数。它的签名大致是char* chunk_alloc(size_t size, int nobjs)。参数size是每个块的大小如32nobjs是期望获取的块数传入20但函数可能会修改这个值返回实际获得的块数。chunk_alloc的逻辑如下计算总需求total_bytes size * nobjs。检查内存池余额bytes_left end_free - start_free。情况一最理想池中剩余空间足够满足20块。直接调整start_free指针将其向后移动total_bytes字节然后返回移动前的start_free作为这一大块内存的起始地址。refill拿到这块内存后将其切割成20个size大小的块并串成自由链表。情况二池中剩余空间不足以分配20块但至少能分配1块。这是权衡的艺术。此时chunk_alloc会修改nobjs为实际能分配的块数例如bytes_left / size。然后分配这些块更新start_free。虽然这次补货数量少了但好过没有能缓解当前的需求。情况三池中剩余空间连1块都分配不出来bytes_left size。这是最棘手的情况内存池完全枯竭了。此时配置器不会轻易放弃它还有以下几步棋 a.废物利用将池中这最后一点零碎内存bytes_left字节“塞”到合适的自由链表里去。例如剩下15字节会被上调至16字节然后挂到16字节的自由链表上。物尽其用避免浪费。 b.向系统申请新内存调用malloc申请一大块内存。申请的大小是2 * total_bytes ROUND_UP(heap_size 4)。这个公式很有意思2 * total_bytes是为了满足当前需求并留足余量ROUND_UP(heap_size 4)是一个附加量随着累计申请量heap_size的增长而增长是一种自适应策略避免频繁向系统申请。 c.申请成功后的处理如果malloc成功更新heap_size将新内存并入内存池start_free指向这块内存end_free start_free 申请的大小然后递归调用自己chunk_alloc。因为此时池里有货了递归调用自然会落入情况一或二成功分配。 d.申请失败的挽救措施如果malloc也失败了系统内存不足配置器还没到山穷水尽。它会有一个“最后一搏”的策略遍历所有比当前需求size更大的自由链表例如当前需要32字节就去查看40、48...128字节的链表。如果找到一个非空的链表就从那个链表中“劫持”一块内存过来。但这块内存比需要的大不能直接给用户。配置器会把这大块内存回收到内存池然后再次递归调用chunk_alloc。这相当于把“大额钞票”换成“零钱”来用。 e.终极失败如果连“劫持”大块内存都失败了配置器会抛出bad_alloc异常或调用第一级配置器期望第一级配置器有更复杂的异常处理机制如调用new_handler。整个allocate的流程是一个从高速缓存自由链表到缓冲区内存池再到慢速后备存储系统堆的逐层查找过程完美体现了计算机体系结构中“缓存”思想的应用。5. 释放算法 deallocate回收与合并的智慧有借有还再借不难。deallocate函数负责将用户还回来的内存块回收。它的逻辑比allocate简单但同样重要。用户传入一个指针p和大小n。配置器首先对n进行判断如果n 128字节说明这是大块内存直接调用第一级配置器的deallocate即free释放。如果n 128则进入二级配置器的回收流程。回收流程极其简单高效根据n计算对应的自由链表索引idx。将用户还回来的指针p强制转换为obj*类型记为q。将q的free_list_link指向当前链表头free_list[idx]。将链表头free_list[idx]更新为q。完毕。这就是一个标准的链表头插操作。回收的内存块被挂到了对应自由链表的头部等待下一次分配。这里有一个非常重要的点二级配置器没有在释放时尝试将相邻的小块内存合并成大块。这是其设计上的一个取舍也被很多人讨论。为什么不合并因为合并也称为“压缩”是一个相对耗时的操作需要遍历和比较地址。在频繁分配释放小对象的场景下合并带来的收益减少碎片可能抵不上其开销。二级配置器选择相信通过内存池批量申请和自由链表的精准尺寸管理已经能够很好地抑制碎片的产生。这种“以空间换时间”和“将问题简化”的策略在特定场景下是非常有效的。6. 多线程环境下的考量与局限性读到这里你可能已经发现了我们上面剖析的整个流程无论是操作自由链表还是更新内存池指针都没有看到任何锁mutex的影子。是的经典的SGI STL二级空间配置器不是线程安全的。它的free_list、start_free、end_free都是静态变量被所有线程共享。如果多个线程同时调用allocate或deallocate会导致竞态条件破坏内部数据结构。这在早期不是大问题因为一个程序通常只有一个线程在使用STL容器。但在现代多核多线程编程成为主流的今天这显然是个缺陷。因此在实际使用中例如GCC的libstdc通常会通过以下两种方式之一来包装它在使用容器的外层加锁例如每个容器对象自己管理一把锁但这粒度太粗影响性能。使用线程本地存储更现代的方案是每个线程拥有自己独立的内存池实例和自由链表。这就是所谓的“线程本地分配器”或“无锁内存池”的思想。C11引入的thread_local关键字为此提供了语言层面的支持。一些第三方库如tcmalloc,jemalloc也实现了高效的多线程内存管理。理解二级配置器的非线程安全特性能帮助我们在多线程程序中正确使用STL容器。通常的实践是要么避免跨线程共享同一个容器要么在共享时进行外部同步。7. 与现代内存管理技术的对比与启发SGI STL二级空间配置器诞生于上世纪90年代其设计思想至今仍闪耀着智慧的光芒。它与现代的一些内存管理技术相比既有传承也有差异。对比tcmalloc/jemalloc这些是现代系统级的内存分配器它们同样使用线程本地缓存和中心堆的概念但设计更为复杂和精细。例如tcmalloc对大小类的划分更细并且有专门的中等对象和大对象处理路径。它们的线程本地缓存是动态增长和缩放的而SGI的配置器是静态的16个固定大小。现代分配器还更注重减少虚假共享False Sharing等对多核CPU更友好的特性。对比C标准库的std::allocatorC标准库的默认分配器通常非常简单就是new和delete的包装。但标准也提供了std::allocator_traits和内存池工具如std::pmr::memory_resourceC17。pmr多态内存资源允许用户自定义内存池并注入到容器中其思想与SGI配置器一脉相承但接口更加通用和灵活。给我们的启发尺寸分级是减少碎片的关键将内存请求归类到几个固定的尺寸等级能极大简化分配和合并逻辑提高速度。这是几乎所有高效内存池的基础。批量申请零散分配向操作系统申请内存是昂贵的涉及系统调用和可能的内核态切换。一次性申请一大块chunk然后在用户态精细管理能摊薄每次分配的成本。嵌入式指针实现零开销管理利用内存块自身的空间存储管理信息是空间效率的极致体现。这种技巧在资源受限的嵌入式系统开发中非常有用。取舍的艺术SGI配置器选择了不合并释放的块选择了固定的16个大小类选择了非线程安全。这些都是在特定性能目标下高频小对象分配做出的合理权衡。没有完美的设计只有适合场景的设计。回过头看SGI STL二级空间配置器更像一个精致的“特化工具”而非通用解决方案。它完美地解决了STL容器在特定场景下的痛点。阅读它的源码就像在观摩一位大师在有限的条件下通过精巧的结构和算法将性能压榨到极致。这种对底层细节的掌控和优化思维是每一个追求高性能的C程序员应该学习和掌握的。下次当你使用std::vector时或许会对它背后那个默默工作的、古老而高效的内存池多一份敬意。
返回列表