
1. 项目概述为什么我们需要一个自己的高并发内存池如果你写过C服务端程序或者参与过任何对性能有要求的C项目大概率都遇到过内存管理的痛点。标准库的new/delete或malloc/free在单线程、低频次申请的场景下表现尚可但一旦进入高并发、高频次小内存分配的场景它们就会迅速成为性能瓶颈甚至导致系统不稳定。这个项目就是带你从零开始亲手打造一个能应对这种挑战的“高并发内存池”。简单来说内存池的核心思想就是“空间换时间”和“专事专办”。与其让每个线程都去竞争一个全局的内存堆不如预先分配一大块内存然后由每个线程管理自己的“小仓库”Thread Cache大部分内存申请和释放都在线程本地无锁完成。只有当本地仓库空了或者满了才需要去访问全局的“中央仓库”Central Cache或“大仓库”Page Cache并且这些全局访问也通过精巧的设计来减少锁的竞争。这听起来是不是很像现实世界里的物流仓储系统每个快递员线程有自己的配送车Thread Cache车上的包裹小内存块可以快速存取车空了就去区域分拣中心Central Cache补货区域中心没货了才向总仓Page Cache调拨。这样大部分时间快递员都在独立工作互不干扰效率自然就上去了。这个项目不仅是一个优秀的C练手项目能让你深入理解内存管理、数据结构、多线程同步等核心知识更是面试中展示你系统编程能力的绝佳素材。接下来我会带你一步步拆解它的设计思路、核心实现并分享我在实现过程中踩过的坑和总结的经验。2. 整体架构设计三层缓存模型详解一个成熟的高并发内存池通常会采用类似tcmalloc、jemalloc的设计采用三层缓存结构来平衡速度、空间利用率和并发能力。我们的项目也遵循这一经典范式。2.1 三层结构的分工与协作我们的内存池主要由三个部分组成Thread Cache、Central Cache和Page Cache。它们的关系和职责如下Thread Cache线程缓存定位每个线程独享的本地缓存。这是内存分配的第一站也是速度最快的一环。数据结构本质上是一个哈希桶std::vector或数组每个桶挂着一个自由链表Free List用于管理特定大小范围的内存块。例如第一个桶管理8字节的内存块第二个桶管理16字节的以此类推通常按照8字节、16字节、24字节...直到256字节或更大进行对齐划分。关键特性无锁操作。因为每个线程只访问自己的Thread Cache所以申请和释放内存时不需要加锁这是高并发性能的基石。Central Cache中心缓存定位所有线程共享的缓存。它是Thread Cache的“后勤补给中心”。数据结构同样是一个哈希桶桶的划分规则与Thread Cache完全一致。但每个桶里挂的不再是单个内存块而是由多个内存块组成的“Span”对象链表。一个Span代表一大块连续的内存页例如4KB或8KB它被切分成多个符合该桶大小要求的小块。关键特性桶锁。Central Cache是共享资源所以每个桶或者说每个大小类需要一把独立的锁桶锁这样不同大小的内存申请释放不会互相阻塞减少了锁的竞争范围。Page Cache页缓存定位整个内存池最底层的大内存管理者。它以“页”Page如4KB为单位向系统申请和释放内存并负责将这些大内存块组织成Span提供给Central Cache。数据结构一个哈希映射或数组下标是Span包含的页数如1页2页...128页值是该页数对应的Span双向链表。它还负责Span的合并将释放回来的、物理地址相邻的小Span合并成大Span以减少内存碎片。关键特性全局锁。Page Cache的访问频率相对较低且其数据结构合并操作需要全局视图因此通常用一把大锁保护。但通过设计可以尽量减少线程进入Page Cache的次数。协作流程举例 当一个线程需要申请一个24字节的内存时线程首先访问自己的Thread Cache中管理24字节假设对齐后是24字节的自由链表。如果链表不为空直接弹出头节点返回过程无锁极快。如果链表为空线程就会向Central Cache申请一批比如5-10个24字节的内存块。Central Cache在对应的24字节桶上加锁查找是否有可用的Span及其内存块。如果有则转移一批给Thread Cache然后释放桶锁。如果Central Cache也空了它会向Page Cache申请一个新的Span比如一个4KB的页能切成170个24字节的块。Page Cache加全局锁查找或分配一个合适的Span切好并返回给Central CacheCentral Cache再将其挂到对应桶上并切分出部分块给Thread Cache。线程最终从Thread Cache拿到内存块。释放过程则是反向的优先还到Thread Cache当Thread Cache的缓存块过多时再批量归还给Central CacheCentral Cache的Span所有块都归还后再将整个Span还给Page Cache进行合并。2.2 关键设计决策对齐规则、哈希桶与自由链表对齐规则Size Class 这是内存池高效管理不同大小内存的核心。我们不能为每一个字节大小都维护一个自由链表那样哈希桶会太大。常见的策略是采用分段对齐。例如8字节对齐 [1, 8] - 8字节 [9, 16] - 16字节 [17, 24] - 24字节 ... 直到128字节。然后可以增大对齐步长比如[129, 256] - 256字节。超过256字节的申请可能直接走Page Cache或系统调用因为大内存分配不频繁且碎片化问题不突出。 我们需要一个函数RoundUp(size_t bytes)来将用户申请的大小向上对齐到最近的标准大小类。自由链表Free List的实现 这是Thread Cache和Central Cache中管理内存块的数据结构。它不是一个std::list而是我们自己用“嵌入指针”实现的单链表。原理每一块分配出去的内存在返还给内存池后这块内存本身的前8个字节在64位系统下可以用来存储一个指针指向下一块空闲内存。这样我们不需要额外的数据结构来管理空闲块节省了空间。操作Push(void* obj)将对象头插到链表Pop()从链表头弹出一个对象。这些操作就是简单的指针赋值效率极高。Span对象的设计 Span是Central Cache和Page Cache管理内存的基本单位。它需要记录以下信息struct Span { PAGE_ID _pageId 0; // 起始页号将地址右移12位得到用于合并时快速判断是否相邻 size_t _n 0; // 这个Span包含多少页 Span* _next nullptr; // 用于Central/Page Cache中的链表 Span* _prev nullptr; size_t _useCount 0; // 已被分配给Thread Cache的内存块计数为0时表示整个Span空闲 void* _freeList nullptr; // 指向Span内切分出来的自由链表头 size_t _objSize 0; // 每个小块的大小属于哪个Size Class };_pageId是关键它通过将内存地址除以页大小如4096得到是一个整数。Page Cache通过比较不同Span的_pageId和_n就能判断它们物理地址是否连续从而进行合并。3. 核心模块实现与源码解析接下来我们深入到代码层面看看每个模块如何实现。我会用伪代码和关键片段来说明并解释为什么这么做。3.1 Thread Cache的实现无锁快车道Thread Cache的核心是线程局部存储TLS。在C中我们可以使用thread_local关键字来让每个线程拥有自己的实例。class ThreadCache { public: // 申请内存 void* Allocate(size_t size); // 释放内存 void Deallocate(void* ptr, size_t size); private: FreeList _freeLists[NFREELISTS]; // 哈希桶NFREELISTS是对齐后大小类的数量 // 当自由链表为空时从Central Cache获取内存 void* FetchFromCentralCache(size_t index, size_t size); // 当自由链表过长时归还部分内存到Central Cache void ListTooLong(FreeList list, size_t size); }; // 线程局部指针 static thread_local ThreadCache* pTLSThreadCache nullptr;Allocate流程根据size计算对齐后的索引index。访问_freeLists[index]。如果链表不为空Pop()一个对象返回。这是最常见、最快的路径。如果为空调用FetchFromCentralCache(index, size)。FetchFromCentralCache流程这里涉及一个“慢启动”的批量申请策略。不是一次只申请一个块也不是一次性申请太多。我们维护一个每次申请数量的上限_maxSize初始值较小如1。调用CentralCache::GetInstance()-FetchRangeObj(start, end, batchNum, size)尝试从Central Cache获取batchNum不超过_maxSize个对象。如果实际获取到的数量小于batchNum说明Central Cache也比较紧张本次获取是“慢”的下次可以少申请点_maxSize递减。如果获取到的数量等于batchNum说明获取顺利下次可以尝试申请更多_maxSize递增但不超过一个阈值。这是一种简单的自适应负载策略。将获取到的这批对象除了第一个直接返回给用户其余的通过Push放入Thread Cache的自由链表中以备下次快速分配。注意thread_local变量的初始化需要小心。我们通常会在第一次调用ThreadCache::GetInstance()一个静态方法时检查pTLSThreadCache是否为空如果为空则创建一个新的ThreadCache对象。同时在线程退出时需要有一个机制如注册析构回调将ThreadCache中未释放的内存归还给Central Cache避免内存泄漏。这在某些场景下如使用线程池尤为重要。3.2 Central Cache的实现共享资源与桶锁Central Cache是单例模式因为整个进程只需要一个。class CentralCache { public: static CentralCache* GetInstance(); // 获取一个范围的内存块给Thread Cache [start, end] size_t FetchRangeObj(void* start, void* end, size_t batchNum, size_t size); // 接收从Thread Cache归还的一串内存块 void ReleaseListToSpans(void* start, size_t size); private: SpanList _spanLists[NFREELISTS]; // 每个桶是一个Span的双向链表 // 每个桶配一把锁减少竞争 static std::mutex _mtx[NFREELISTS]; };FetchRangeObj流程根据size找到对应的桶_spanLists[index]。给这个桶加锁std::unique_lockstd::mutex lock(_mtx[index])。遍历该桶下的Span链表找到一个有空闲块的Span_freeList不为空。从这个Span的_freeList中批量取出最多batchNum个对象更新Span的_useCount。如果取出后该Span的_freeList为空可以将其从链表中移除或标记为满优化后续查找。释放桶锁。将获取到的内存块链表通过start和end输出参数返回。ReleaseListToSpans流程这是Thread Cache批量归还内存的入口。传入的start是一串通过嵌入指针连接起来的内存块链表。根据内存块地址和size需要找到它属于哪个Span。这是难点我们需要一个从内存块地址到其所属Span的映射。地址映射设计这是整个内存池的一个关键设计。我们利用Page Cache的页号映射。对于一个给定的内存地址ptr我们通过(PAGE_ID)ptr PAGE_SHIFT得到它的页号。Page Cache维护一个巨大的映射数组std::unordered_mapPAGE_ID, Span*或Span* _idSpanMap[MAX_PAGES]记录每一页属于哪个Span。这样通过地址就能快速找到Span。找到Span后给对应的Central Cache桶加锁。将这串内存块头插到该Span的_freeList中并减少_useCount。如果_useCount减为0说明这个Span的所有块都归还了此时可以调用PageCache::GetInstance()-ReleaseSpanToPageCache(span)将这个完整的Span还给Page Cache进行合并。释放桶锁。实操心得Central Cache的桶锁竞争是需要关注的点。如果项目中对某一特定大小的内存块申请极其频繁对应的桶锁可能会成为热点。在实际项目中可以进一步优化比如使用更细粒度的锁或者尝试无锁队列。但在大多数场景下桶锁的设计已经能很好地分散竞争。3.3 Page Cache的实现大内总管与合并算法Page Cache同样是单例它直接使用VirtualAllocWindows或mmap/brkLinux等系统调用向操作系统申请以页为单位的大块内存。class PageCache { public: static PageCache* GetInstance(); // Central Cache申请一个Span Span* NewSpan(size_t n); // Central Cache归还一个Span void ReleaseSpanToPageCache(Span* span); // 根据地址获取Span (供Central Cache使用) Span* MapObjectToSpan(void* obj); private: SpanList _spanLists[NPAGES]; // 下标是页数管理空闲Span std::unordered_mapPAGE_ID, Span* _idSpanMap; // 页号到Span的映射 std::mutex _mtx; // 全局一把锁 // 向系统申请内存的封装函数 void* SystemAlloc(size_t kpage); };NewSpan流程加全局锁。检查_spanLists[n]链表是否为空是否有恰好n页的空闲Span。如果有直接取出一个返回。如果没有则向后遍历_spanLists[n1],_spanLists[n2]... 直到找到第一个非空链表假设找到m页的Spanmn。将这个m页的Span拆分成一个n页的Span和一个(m-n)页的Span。将n页的Span返回给Central Cache将(m-n)页的Span挂到对应的_spanLists[m-n]链表中。如果向后找遍了都没有则调用SystemAlloc向系统申请一个较大的内存块比如128页将其组织成一个128页的大Span然后递归地调用NewSpan来切分。在返回Span之前需要对这个Span进行“切块”根据Central Cache请求时附带的对象大小size将这个Span的起始地址_pageId PAGE_SHIFT切分成一个个size大小的块并用嵌入指针连接起来形成_freeList。同时要更新_idSpanMap将这个Span包含的每一页都映射到它自身。释放全局锁。ReleaseSpanToPageCache流程合并是核心加全局锁。得到要归还的Span的起始页号id和页数n。向前合并计算前一个Span的页号prevId id - 1。通过_idSpanMap查找prevId对应的Span如果该Span存在、空闲、且与当前Span物理连续则将它们合并。合并后从原链表中移除前一个Span更新当前Span的起始页号和页数。向后合并计算后一个Span的起始页号nextId id n。同样查找并尝试合并。合并完成后将最终得到的大Span插入到_spanLists[最终页数]链表中。更新_idSpanMap中这个新Span所覆盖的所有页的映射关系。释放全局锁。踩坑记录_idSpanMap的设计和更新是极易出错的地方。在NewSpan切分Span时不仅要更新新Span的映射还要注意原大Span剩余部分的映射需要更新为新的小Span。在ReleaseSpanToPageCache合并时被合并的小Span的映射需要清除合并后的大Span需要建立新的映射。任何一步遗漏都会导致后续MapObjectToSpan查找失败引发致命错误。建议为_idSpanMap的更新编写独立的辅助函数并进行严格测试。4. 项目难点、调试技巧与性能优化实现一个能稳定运行的内存池远比理解原理要复杂。下面分享几个关键的难点和调试技巧。4.1 难点一指针与整型的转换与运算内存池中充斥着大量的指针运算和类型转换必须非常小心。// 计算对象所属的Span先将指针转为char*进行字节运算再右移得到页号 PAGE_ID id ((PAGE_ID)obj PAGE_SHIFT); Span* span PageCache::GetInstance()-MapObjectToSpan((void*)id); // 注意这里id是页号不是地址 // 从Span的页号计算起始地址 void* ptr (void*)(span-_pageId PAGE_SHIFT); // 自由链表的嵌入指针操作 void* next *(void**)obj; // 读取obj头部的指针 *(void**)obj _freeList; // 将obj头部指向当前链表头注意事项确保PAGE_SHIFT的值如12对应4KB页与系统内存对齐和你的设计一致。在64位系统下指针是8字节PAGE_ID应使用size_t或uintptr_t。4.2 难点二多线程环境下的调试内存池的Bug常常在多线程压力测试下才暴露出来比如数据竞争、死锁。使用ThreadSanitizer (TSan)在Linux下使用-fsanitizethread编译可以检测数据竞争。这是发现锁使用不当或无锁数据结构问题的利器。死锁排查确保锁的获取顺序一致。例如如果函数A先锁桶1再锁桶2那么所有其他需要同时锁这两个桶的函数都必须遵循同样的顺序。压力测试编写多线程测试程序让大量线程随机申请和释放不同大小的内存并验证没有内存泄漏使用Valgrind的memcheck和内存损坏使用AddressSanitizer。日志输出在关键路径如向系统申请内存、Span合并添加简单的日志但注意日志输出本身也可能影响并发行为最好能开关控制。4.3 性能优化点Size Class的优化对齐规则直接影响内存利用率内部碎片。可以调研tcmalloc或jemalloc的size class设计它们通常有更精细的划分在8-256字节区间内可能使用更小的增长因子以减少浪费。Thread Cache的批量大小FetchFromCentralCache中的慢启动算法参数初始值、增长因子、最大值需要根据实际负载调整。可以通过测试找到适合你应用场景的参数。Central Cache的锁优化如果发现某个size class的锁竞争激烈可以考虑使用更高效的锁如自旋锁std::atomic_flag或尝试无锁结构但这会大大增加实现复杂度。Page Cache的全局锁Page Cache的访问频率较低一把大锁通常可以接受。但如果项目需要极致性能可以考虑使用分层或分片Sharding的Page Cache将不同页号范围的Span用不同的锁管理。避免False SharingThreadCache的_freeLists数组可能很大确保每个线程的ThreadCache实例在内存中独立对齐例如使用alignas(64)避免多个线程的缓存行Cache Line伪共享影响性能。5. 测试、验证与集成到现有项目5.1 如何测试你的内存池单元测试单线程正确性测试连续申请释放、随机大小申请释放、交错申请释放等场景确保分配的内存可写且归还后不会重复分配已释放的内存。对齐检查验证RoundUp函数是否正确。Span合并专门测试Page Cache的合并逻辑构造前后Span空闲的场景验证是否能正确合并成大Span。多线程压力测试void ThreadFunc() { std::vectorvoid* ptrs; for (int i 0; i 100000; i) { size_t size rand() % 256 1; // 申请1-256字节 void* ptr ConcurrentAlloc(size); memset(ptr, 0, size); // 写操作触发潜在错误 ptrs.push_back(ptr); if (ptrs.size() 100) { // 随机释放一些 int idx rand() % ptrs.size(); ConcurrentFree(ptrs[idx], size); // 注意这里需要记录大小 ptrs[idx] ptrs.back(); ptrs.pop_back(); } } // 清理剩余内存 for (auto p : ptrs) ConcurrentFree(p, 0); } // 启动多个线程执行ThreadFunc使用Valgrind的--toolmemcheck --leak-checkfull检查内存泄漏。性能对比测试编写相同的测试用例分别使用你的内存池和系统的malloc/free。使用std::chrono高精度计时器。对比指标总耗时、每秒操作数Ops、不同线程数下的扩展性。通常在高并发小内存分配场景下你的内存池应该有数倍甚至数十倍的性能提升。5.2 替换系统的 new/delete要让你的项目使用自定义的内存池你需要重载全局的operator new和operator delete。void* operator new(size_t size) { return ConcurrentAlloc(size); } void operator delete(void* ptr) noexcept { ConcurrentFree(ptr, 0); // 需要想办法获取size见下文 } // 同样需要重载 new[], delete[], 以及带nothrow的版本最大的挑战operator delete不知道要释放的内存块大小而我们的ConcurrentFree需要这个信息来确定属于哪个自由链表。解决方案存储大小信息在分配内存时多分配一点空间在返回给用户的指针前面存储这块内存的大小或对齐后的size class索引。释放时通过(char*)ptr - 4假设32位系统读取大小。这是常见做法但有额外开销。查询映射像Central Cache释放时一样通过MapObjectToSpan找到所属的SpanSpan里记录了_objSize。这需要PageCache::MapObjectToSpan函数非常高效。这通常是我们采用的方法因为它没有额外的存储开销但依赖高效的页号映射。5.3 集成注意事项初始化确保内存池在程序开始时正确初始化单例的获取会触发初始化。线程退出如前所述处理好thread_local的ThreadCache析构将剩余内存归还。与第三方库兼容某些第三方库可能内部使用自己的内存管理或特定的对齐要求。全局替换new/delete可能导致兼容性问题。在生产环境中有时更安全的做法是只在你自己的模块内使用内存池的ConcurrentAlloc/ConcurrentFree接口而不是全局替换。实现一个高并发内存池是一个系统工程它几乎涵盖了C系统编程的所有难点指针、内存、数据结构、多线程、锁、性能优化。通过这个项目你获得的不只是一个工具更是对计算机系统如何工作的深刻理解。当你看到自己写的内存池在压力测试下稳稳超越malloc时那种成就感是无与伦比的。在实际编码中从最简单的单线程固定大小内存池开始逐步增加对齐、Thread Cache、Central Cache、Page Cache和合并功能步步为营调试和测试贯穿始终这才是最终成功的保证。