浅谈 ptmalloc 设计思想

前置知识

虚拟地址空间, 缺页中断与页表, sbrk(), mmap()

ptmalloc

思想

ptmalloc 的设计思想十分朴素, 可以说是只是把 sbrk() 和 mmap() 简单包装了一下。
一句话: 大内存用 mmap()/munmap(), 小内存用 sbrk() 申请和释放。
大内存的问题倒是当甩手掌柜直接托管给系统了, 不过这样却给小内存带来几个显而易见的问题:

  • 每次申请内存都去调 sbrk(), sbrk() 是一个 syscall, 每次调用都会导致用户态和内核态的切换开销, 耗时是 us 级别的(通常在1us左右). 性能太差了!
  • 小内存释放怎么办? sbrk() 只能从堆顶释放内存, 万一我要 free() 的内存在中间呢?

设计

先来看看第一个问题怎么解决, 一个直接的思路是 sbrk() 的时候直接申一大块内存, 以后在用户态慢慢分.

用 chunk 作为内存管理单元

ptmalloc 设计了 chunk 的概念来实现这个想法.
一切都是 chunk, 大块内存从系统申来的时候, ptmalloc 认为它是个大 chunk, 我们给它取个名字叫 top-chunk, 用户调 malloc() 的时候再把这个 top-chunk 拆出一小部分, 做个小 chunk 返回给用户.
百闻不如一见, 我们直接写个 demo 看看 chunk 到底长什么样.

for (int i=0; i<4; i++) {
    void *a = malloc(1);
    printf("%p %d\n", a, malloc_usable_size(a));
    for (int i=0; i<33; i++) {
        printf("%02x ", *((unsigned char*)a+i));
    } printf("\n");
}

输出如下:

0x6a8010 24
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 e1 0f 02 00 00 00 00 00 00 
0x6a8030 24
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 c1 0f 02 00 00 00 00 00 00 
0x6a8050 24
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 a1 0f 02 00 00 00 00 00 00 
0x6a8070 24
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 81 0f 02 00 00 00 00 00 00

如 demo 所示, 我们通过 ptmalloc 申请了 4 块大小为 1B 的内存, 却实际用掉了 144B.
可以看到, 每一块的大小是 32B, 这里的"每一块"就是一个 chunk 了.
并且还可以看到, 虽然我们只申了 1B, 系统实际为我们分配了 24B, 还有 8B 消耗了但不能使用的内存, 是什么呢?

这里借用一张网图, 使用中的 chunk 其实是长这样, 可以看到, 后面的 8B 其实就是图中 next chunk 指向的部分, 也就是后一块 chunk 的大小, 这也和 demo 中每次分配后都会减少 0x20 对得上.
于是这里发生了什么就不言而喻了:

  1. 第一次 malloc(1) 的时候, 因为还没有可用内存, 系统先为我们申了一大块内存, 然后当作一个大的 free-chunk 管了起来
  2. 随后系统从中给我们割了一块大小为 32B 的内存下来做成了一个小 chunk 给我们并表示已经没有更小的了.
    当然, 这里只是做了一些感性的描述, 实际上 ptmalloc 在实际存储 chunk 时做了很多优化, 空间复用的地方很多, 所以不能拿 chunk 结构图来完全比对着 demo 实验结果看(不然你一定会质疑 chunk-header 去哪了), 具体细节比较多, 这里就不做展开了.

用 bin 来组织 chunk

目前为止, 我们用 chunk 解决了第一个如何分配的问题, 再来看看第二个问题, 如何释放.
ptmalloc 的回答是不释放, 以后接着用.
不同于可以随意调用的 mmap()/munmap(), 堆顶指针只有一个, 我们无法做到真正的任意动态分配和释放内存. ptmalloc 选择把堆当作内存池来用, free() 并不真正地还给系统, 只是还给内存池. malloc() 同样也是优先从内存池中获取之前用剩的, 并不一定每次都要去推一下堆指针新搞一块.

那么, 要怎么才能把堆当做内存池来用呢?
ptmalloc 给出的回答是 bin, 一个 bin, 其实就是一个由相同大小组成的 chunk 链表.
free() 的时候, 只把这个 chunk 挂到相应大小的 bin 上面去即可, 当然, 别忘了看看能不能和前后的空闲 chunk 合并成一个大 chunk, 以供分配大对象使用.
malloc() 的时候, 也去看看想要的大小的 bin 下面有没有 chunk, 有就直接拿出来用.
为了防止 bin 数量过多, ptmalloc 再事先限制住 chunk 的种类, 这也是为啥我们一开始只要 1B, 它却给了我们 24B.

优化 —— fast bin & unsorted bin

用了 chunk 和 bin, 其实 ptmalloc 就已经差不多成型了, 现在还可以做点优化工作.
前面提到, free() 挂到 bin 上的时候会尝试把连续的空闲 chunk 进行合并, 如果我们频繁申请和释放小内存, 就可能出现频繁的合并和拆分, 这不是我们愿意看到的.
ptmalloc 用 fastbin 来优化这种场景, 其实就是小 chunk 专用缓存, 可以理解为默认不合并的 bin, 当然一直不合并也是不可能的, 这会导致内存碎片, 以及进程迟迟无法归还内存, fastbin 在特定条件触发时也是会合并的, 这里也不展开, 感兴趣的话可以自行搜索.
unsorted bin 是 ptmalloc 引入的另一个优化 bin, 主要针对快速重用场景下的优化, 用户刚刚释放的较大 chunk, 不计大小, 都会放到这个 bin 里面, 以备许多场景中 for() { malloc(...); free(...); } 这样的需要.

更进一步 —— 用 arena 提升多线程性能

最后再考虑一个问题, 线程安全.
ptmalloc 做好后, 逻辑如此复杂, 频繁操作堆内存, 显然不是线程安全的, 这就意味着多线程场景下要使用 ptmalloc 就必须加锁.
为了支持并发分配内存, ptmalloc 引入了 arena 的概念, 其实就是把上面那一大堆东西全部 copy 一份, 每一份叫一个 arena.
你可能会问了, 堆不是只有一个吗? 还能这么搞的?
确实, 堆确实只有一个, 但别忘了我们还有 mmap, 实际上, ptmalloc 就是这么做的:

  • main arena: 就是前面介绍的那一大堆
  • non-main arena: 把前面介绍的那一大堆复制一份, 只不过用 mmap() 替代 sbrk() 来申请内存.
    线程每次 malloc() 时, 先尝试抢占一个 arena, 因此有多少个 arena 就可以有多少并发分配内存了.
posted @ 2022-01-12 23:06  erenn  阅读(188)  评论(0)    收藏  举报