高并发内存池 - central cache 结构设计
项目 gitee 链接: 高并发内存池项目
项目 github 链接: 高并发内存池项目
central cache 也是一个哈希桶结构,并且映射关系与 thread cache 保持一致,但是链接部分不再是自由链表,而是一个名为SpanList的链表结构,链表的存储对象是名为span的大内存块,span中存储着切割好的小内存块链接而成的自由链表,当 thread cache 申请时,便返回的是自由链表的部分。
这里有几点需要注意:
- 对于central cache线程与进程均共享一个,所以设计为单例模式
- central cache有被多进程并发访问的可能(多个 thread cache 同时没有资源,同时请求),所以要设计锁
对于锁的位置,需要分析一下,多个 thread cache 可能申请的不是一个桶中的资源,比如一个申请 6KB,一个申请 8KB,所以不能给 central cache 整体上锁,而是只在哈希桶内部上锁,确保桶只能在任意时刻,被一个线程访问。
下面我们深入探索一下,谈谈为何这么设计,而不是只困在应该这么设计:
为何要使用 Span
如果还使用自由链表去设计 central cache 这一层,就会产生一个致命的问题,内存泄露!
假设 central cache 使用自由链表设计,现在这个程序已经运行了很长时间,多线程疯狂申请和释放 8 字节的内存。此时,CentralCache 的 8 字节桶里,有一个超级巨大的自由链表,里面串着 100 万个 8 字节的空闲内存块(总共占用了约 8MB 的物理内存)。
灾难发生了:程序突然进入了低谷期,不再需要这些小内存了。
这时候,你想把这 8MB 的内存还给操作系统(或者借给需要 16 字节内存的桶用),你该怎么做?
面对单纯的自由链表,你绝望了。因为自由链表里的节点是打散的!这 100 万个 8 字节内存块,可能散布在 2000 个不同的物理页(Page)中,每个页里只闲置了一半的内存,另一半还在被其他线程使用。
操作系统的规则是冷酷的:你必须把完整的一页(4KB)或连续的几页原封不动地还给我,我才能回收。你不能把“半页”内存还给我。
结果:你的内存池患上了“内存泄漏”的绝症。那 8MB 内存永远卡在 CentralCache 的巨大链表里,既无法被其他大小的桶使用,也无法还给操作系统,只能白白浪费。
结论:单纯的自由链表只能“借出”内存,很难完整地“收回”内存。
此时的破局之道,便是引入 Span。
Span 申请的是连续的内存页块,当 thread cache 需要内存是,Span 对大块内存进行分割,将分割后的小块内存进行分配,对已经使用的小块内存数进行记录,如果最后使用的内存都已归还,说明此时这块连续的物理内存已经空闲,可以将其打包再还给上层,解决内存泄露与外部碎片的问题。
总结:
实现内存的 “有借有还”(核心目的)
- ThreadCache为什么不需要 Span? 因为ThreadCache是线程私有的,它的设计哲学是“只管拿,不管还”。它把内存囤在自己手里是为了极致的分配速度。就算内存浪费,也只浪费在当前线程。
- CentralCache是全局共享的枢纽。它必须承担回收内存、降低系统整体内存水位的责任。Span 是内存回收的最小管理单元。没有 Span,CentralCache就无法把打散的内存重新聚合成完整的“页”还给 OS。
解决外部碎片问题
- 如果CentralCache直接用大链表,长时间运行后,物理内存会被切得稀碎(这里空 8 字节,那里空 16 字节),导致程序明明有 100MB 空闲内存,却无法申请一个连续的 1MB 内存块(这就是外部碎片)。
- 使用Span,内存总是以“连续的页”为单位申请和释放,保证了物理内存的连续性,极大地减少了外部碎片。
提高分配效率(局部性原理)
- 当ThreadCache来进货时,CentralCache会优先从同一个Span里连续切出多个小块给它。
- 这些小块在物理内存上是紧挨着的。当 CPU 去访问这些内存时,能极大地提高CPU Cache(L1/L2缓存)的命中率,从而提升整个程序的性能。如果是单纯的大自由链表,拿出来的内存块在物理地址上可能是东一个西一个,CPU 缓存命中率会很低。