天天看点

内存分配器设计的演进

文章目录

  • ​​栈内存空间是否够用​​
  • ​​系统调用申请内存​​
  • ​​最简单的内存分配器实现 -- bump allocator​​
  • ​​可扩容的 Bump alloactor​​
  • ​​通过free-list 管理的 allocator​​
  • ​​通过size-buckets 维护多个free-list 的 allocator​​
  • ​​Cache friendly allocator​​
  • ​​需要考虑更多问题的allocator​​
  • ​​性能​​
  • ​​易用性​​
本文希望描述一个基本的内存分配器的设计演进,包括基于ptmalloc 实现的glibc-malloc ,jemalloc 以及 tcmalloc 为什么会被设计成如今的样子(他们的基本形态都是非常接近的,无非tcmalloc 比 jemalloc和glibc-malloc 多了一些内存高效利用上的设计)

在没有内存分配器的时候,我们os系统最开始的时候拥有自己的栈空间,用来保存函数栈以及内部的变量,大家先抛开我们习惯用的malloc/free 以及 new/delete,来整体看看内存分配器的演进过程,本文更多的是一些设计上的思考。

栈内存空间是否够用

如下代码提就是一个栈空间的基本数据存储。

#include <stdio.h>

int main() {
  int a = 10;
  int b = 20;

  int c = a + b;
  return 0;
}      

在编译生成的汇编代码中,我们可以看到对于变量 a 和 b的处理,其所占用的内存大小已经被分配好。10这个数值被填充到了代表a的栈底指针减去4字节之后的地址​

​-4%(rbp)​

​​,同理20 被填充到了栈底指针减去 8字节之后的地址​

​-8(%rbp)​

​。

可以看到,编译期间,变量a,b 以及最后的c 都已经明确了具体的存储空间的大小以及存储对应的虚拟地址。

.file   "stack.c"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp # 入栈
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp  # 添加栈底指针
        .cfi_def_cfa_register 6
        movl    $10, -4(%rbp) # 变量a
        movl    $20, -8(%rbp) # 变量b
        movl    -4(%rbp), %edx
        movl    -8(%rbp), %eax
        addl    %edx, %eax
        movl    %eax, -12(%rbp) # 变量c
        movl    $0, %eax
        popq    %rbp # 出栈
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (GNU) 7.3.1 20180303 (Red Hat 7.3.1-5)"
        .section        .note.GNU-stack,"",@progbits      

栈内存分配基本优劣如下:

优势:

  • Automacitcally handled by the compiler,编译器会编译期间分配好对应的栈内存
  • very fast allocation 拥有对应的cpu指令来专门处理栈内存的分配
  • very fast cleanup 释放的时候也拥有对应的指令(函数栈弹栈)

劣势:

  • fixed sizes,内存大小需要是已知的,内存大小有限,比如x86_64 系统下默认的栈空间只有10M(​

    ​ulimit -s​

    ​)
  • fixed lifetimes : 作用域仅仅被限制在了当前函数内部。比如,想要在一个函数内部创建一个链表并返回,是完全不可能的。

栈内存对于我们应用程序的使用场景来说显然还是不够,那有没有可以直接分配内存的系统调用?

系统调用申请内存

mmap : 设置内存protection(mmap的内存区域可以被当前进程的其他程序更改),频繁的中断 以及 会陷入内核;释放的时候unmap即可。

Mmap 解决了 栈方式分配内存的 fixed-sizes 的问题,用户可以不限制内存大小来分配内存。

  1. 整体非常慢,频繁得触发page fault 中断并使当前进程陷入内核。
  2. 浪费内存较为严重。因为申请内存的粒度只能是4K的page,在一些应用场景中对内存浪费较为严重(申请8byte的区域,创建一个链表节点,需要消耗4k的内存)。

Mmap 并不能作为构造一个高效可靠的内存分配器的雏形,接下来还需要做更多的工作来对内存进行管理。

那我们想像这样一个简单的内存分配器,就是先模拟栈空间进行内存分配,取一个定长的内存区域被用来存储数据,分配的过程可以根据用户的需求来分配。

最简单的内存分配器实现 – bump allocator

bump allocator,类似一个用户栈空间(不用 os 自己的进程栈空间)。

内存分配器设计的演进

用户申请一个内存区域,则将申请的这个内存对象入栈。这个栈空间管理的内存能够控制变量的生存周期,只有当你从栈中移除这个内存对象的时候,这个内存对象的生命周期才算结束。

优势:

  • 高性能,fast allocation
  • 变量的存储生命周期可控

缺点:

  • fixed total memory,需要开辟一段固定大小的内存。
  • cannot free memory: 这个含义其实是说,因为使用的是栈空间管理的内存,想要释放最开始申请额8 bytes 的存储空间,需要先释放掉在 8之上的其他内存对象,这个时候其他的内存对象对于应用程序来说并不一定想要释放。

问题很明显,还是和传统的函数栈空间一样 fixed total memory 以及 无法快速释放内存。

可扩容的 Bump alloactor

那我们先来通过expandable memory 来解决 fixed total memory的问题。

内存分配器设计的演进

当用户程序耗尽了第一次申请的内存区域,则分配一个新的 内存区域用于用户申请(内存管理的arena机制),已经满的内存区域则保留,直到用户想要从栈顶释放内存。

优势:

  • Fast allocation
  • expandable total memory
  • manual lifetime

劣势:

  • cannot free memory,仍然无法有效释放内存,需要先弹栈,才能找到需要释放的内存对象。

通过free-list 管理的 allocator

free-list,通过一些元数据来管理内存空间,比如使用链表,将应用程序申请的内存对象通过链表串联起来, 如果用户想要释放一个变量,则释放这个变量对应内存的单链表节点即可。

内存分配器设计的演进

现在free memory终于可以随意进行了,能够随意申请,随意释放,不会受内存大小的限制,这样看起来功能确实没有问题了。

可是我们还需要性能 以及 成本,,,

劣势:

  • allocations have minium sizes,一个链表指针在64位系统下需要8byte的存储空间,表示每次申请至少需要8byte的存储空间 管理free-list 的节点。
  • very slow (释放的时候,可以随意释放任何一个节点,只是需要从头顺序扫描整个链表,代价很高)
  • memory fragmentation 内存碎片,整个内存空间在大内存和小内存对象混合在一起的时候无法有效管理小内存。比如一个链表节点大小最小是8bytes,用户申请了一个1byte 和 一个 7bytes的存储空间,这个时候需要至少16bytes的存储,1byte 对应的存储区域会有一个7bytes的内存碎片一直无法被使用。

这样的性能 以及 对成本这样的浪费,简直不能忍,继续向下看。

通过size-buckets 维护多个free-list 的 allocator

每一个size(不太可能每一个size 一个free-list,不易维护) 或者 一批 size 维护一个自己的free-list,这样当前free-list 只需要存储大小接近的内存对象就可以了,内存碎片问题会被极大的降低。

且释放内存的时候不需要扫描整个混合在一起的大链表,扫描被均分到不同的size buckets。

内存分配器设计的演进

优势:

  • 在前面的基础上能够正常释放内存
  • 解决了扫描慢的问题(释放时不需要扫描整个大链表,只需要扫描对应的size-buckets 的链表即可)
  • 内存碎片问题(通过按照size 来划分free list,有效得降低了内存碎片)

劣势:

  • Cache pressure ;在高并发下分配内存的过程中频繁的cache-miss 会导致cpu 的负载较高。cpu 需要频繁得在不同的size-buckets 下进行切换,无法保持良好的cache命中(每次遍历都需要重新load 内存到cpu-cache,链表的访问本身是随机访问,加上不同的size-buckets 的访问,都会导致cpu cache-miss较高)。

Cache friendly allocator

slab allocator,每一个size维护一个slab,这个 slab只需要管理当前size 的内存分配即可。

内存分配器设计的演进

解决了cpu 对链表数据的随机访问的影响, 每一个slab 内部使用数组来管理内存对象,数组对cpu-cache 更友好。

每一个slab 能够管理一批连续的内存空间,内存在slab上的分布连续性较强。

这个连续性肯定是相比于链表的随机访问来说的,在slab上尽可能连续的分配当前size,释放的时候能够尽可能得降低cache miss,减少cpu-cache 的压力。

需要考虑更多问题的allocator

性能

  • Alignment。尽可能对齐cache-line,来保证cpu 访问的性能
  • Multi-Thread / Multi-core 下的性能,比如jemalloc 和 tcmalloc 设计的 thread-cache / per-cpu cache等

易用性

  • C compatibility – 兼容c语言的malloc/free等,想要支持 C++,这需要支持 operator new/delete
  • Debugging,需要能够暴露内部状态,方便debug或者用户查看内存占用情况
  • Binary size – 运行库文件不能过大,它只是一个内存分配库

继续阅读