前言

这段时间关注到微软开发的一个内存分配器mimalloc,感觉很厉害,从官方的 benchmark 看,比 tcmalloc 提升了 7%, 比 jemalloc 提升了 14%,而且它的核心代码只有几千行,看起来是值得好好研究一下。

在研究之前,我专门看了一些内存分配的算法,虽然对这些算法都有了解,但系统学习下来还是获益良多。所以在这篇文章里,我打算先介绍这些基础算法,然后在下一篇再来分析 mimalloc 的实现。

linear allocator

它的思路是预先创建内存块,然后在内存块上一直分配内存,这些分配出去的内存不用释放,到最后再一次性把内存块回收。

这种分配算法有很大的限制,但在一些特定场景里却很有用,比如一些局部逻辑,你需要创建大量的小对象,而这些对象会在使用完毕后一起释放掉,此时用 linear allocator 就能极大的提高分配效率。

它的核心代码非常简单,只有几十行,如下所示:

  1. // 大的内存块
  2. typedef struct linear_block {
  3. struct linear_block *next;
  4. } linear_block_t;
  5. // 线性分配器
  6. typedef struct linear_allocator {
  7. linear_block_t *header; // 内存块链表头
  8. uint8_t *end;
  9. size_t blksz; // 内存块的大小,包含头结构的
  10. } linear_allocator_t;
  11. // 最小的块大小
  12. #define LINEAR_MIN_BLOCK_SIZE 1024
  13. // 内存块头大小
  14. #define HEADER_SIZE sizeof(linear_block_t)
  15. // 取内存块的Buffer
  16. #define LINEAR_BUFFER(b) ((uint8_t*)(b) + HEADER_SIZE)
  17. // 初始化
  18. static void linear_init(linear_allocator_t *a, size_t size) {
  19. a->blksz = size + HEADER_SIZE < LINEAR_MIN_BLOCK_SIZE ? LINEAR_MIN_BLOCK_SIZE : size + HEADER_SIZE;
  20. a->header = NULL;
  21. a->end = (uint8_t*)HEADER_SIZE; // 此举是为了在分配时减少一次判断
  22. }
  23. // 分配内存器
  24. static inline linear_block_t* _linear_new_block(size_t size) {
  25. return (linear_block_t *)malloc(size);
  26. }
  27. // 分配内存
  28. static void* linear_alloc(linear_allocator_t *a, size_t size) {
  29. if (a->end - LINEAR_BUFFER(a->header) < size) { // 内存不足,分配新的内存块
  30. if (size + HEADER_SIZE > a->blksz) {
  31. // 如果请求的大小比默认内存块大,则尝试创建更大的内存块
  32. linear_block_t *block = _linear_new_block(HEADER_SIZE + size);
  33. uint8_t *buffer = LINEAR_BUFFER(block);
  34. if (a->header != NULL) {
  35. // 如果头结点存在,则加到头结点后面去,这样头结点的内存块下次还能使用
  36. block->next = a->header->next;
  37. a->header->next = block;
  38. } else {
  39. // 如果头结点不存在,则成为头结点
  40. block->next = NULL;
  41. a->header = block;
  42. a->end = buffer;
  43. }
  44. return buffer;
  45. } else {
  46. // 否则正常分配内存块,并成为链表头
  47. linear_block_t *block = _linear_new_block(a->blksz);
  48. block->next = a->header;
  49. a->header = block;
  50. a->end = (uint8_t*)block + a->blksz;
  51. }
  52. }
  53. a->end -= size;
  54. return a->end;
  55. }
  56. // 终止
  57. static void linear_term(linear_allocator_t *a) {
  58. linear_block_t *block = a->header;
  59. linear_block_t *temp;
  60. while (block) {
  61. temp = block;
  62. block = block->next;
  63. free(temp);
  64. }
  65. }

代码中有少量注释,仔细看应该不难理解;这个分配器的核心结构可以用下图来表示:

几种常见的内存分配算法 - 图1

header 总是指向当前可分配的内存块,end 指向可用内存的结束点,在分配内存时有两种情况:

  1. 如果可用内存足够,只需要把 end 向前移动 size,然后把 end 返回就可以了。
  2. 如果不够分配,则需要创建一个新的内存块,此时也有两种情况要处理:
  3. 请求的内存比默认的 buffer 小:创建一个 blksz 大小的内存块,将其插入到链表头,将 end 指向内存块尾;然后跳回第 1 步。
  4. 请求的内存比默认的 buffer 大:创建一个足够大的内存块,将其插入到链表头的下一个;然后直接返回这个内存块。这种情况用下图表示:

几种常见的内存分配算法 - 图2

fixed size allocator

fixed size allocator顾名思议,只能分配和释放固定大小的内存,其用途要比linear allocator广得多,它的核心思想是预创建内存块,然后将内存块切割成多个固定大小的小块,并将它们链接起来形成一个freelist;分配的时候从 freelist 取出小块返回;释放的时候将小块重新链接回 freelist;如果 freelist 没有多余内存就再创建一个内存大块;如此重复,最终的内存布局像下面这样:

几种常见的内存分配算法 - 图3

代码也不复杂,甚至比上面的还要简单:

  1. // 内存项
  2. typedef struct fixed_item {
  3. struct fixed_item *next;
  4. } fixed_item_t;
  5. // 内存块
  6. typedef struct fixed_block {
  7. struct fixed_block *next;
  8. } fixed_block_t;
  9. // 内存块头大小
  10. #define FIXED_HEADER sizeof(fixed_block_t)
  11. // 取内存块的Buffer
  12. #define FIXED_BUFFER(b) ((uint8_t*)(b) + FIXED_HEADER)
  13. // 固定大小分配器
  14. typedef struct fixed_allocator {
  15. fixed_block_t *block; // 内存块链表
  16. size_t blocksize; // 内存块大小
  17. size_t itemsize; // 内存项大小
  18. fixed_item_t *freelist; // 可用的内存项链表
  19. } fixed_allocator_t;
  20. #define FIXED_MIN_BLOCK_SIZE 256
  21. #define MIN_ITEM_SIZE 16
  22. // 初始化
  23. static void fixed_init(fixed_allocator_t *a, size_t blocksize, size_t itemsize) {
  24. assert(itemsize + FIXED_HEADER < blocksize);
  25. a->block = NULL;
  26. a->blocksize = blocksize > FIXED_MIN_BLOCK_SIZE ? blocksize : FIXED_MIN_BLOCK_SIZE;
  27. a->itemsize = itemsize > MIN_ITEM_SIZE ? itemsize : MIN_ITEM_SIZE;
  28. a->freelist = NULL;
  29. }
  30. // 结束
  31. static void fixed_term(fixed_allocator_t *alloc) {
  32. fixed_block_t *block = alloc->block;
  33. fixed_block_t *temp;
  34. while (block) {
  35. temp = block->next;
  36. free(block);
  37. block = temp;
  38. }
  39. }
  40. // 分配内存项,由于是固定大小,所以不用指定大小
  41. static void* fixed_alloc(fixed_allocator_t *a) {
  42. if (a->freelist == NULL) {
  43. // 没有可用内存,新建一个大块,加入链表
  44. fixed_block_t *block = (fixed_block_t*)malloc(a->blocksize);
  45. block->next = a->block;
  46. a->block = block;
  47. // 初始化可用项
  48. size_t i;
  49. size_t size = a->blocksize - FIXED_HEADER;
  50. fixed_item_t *item;
  51. for (i = 0; i + a->itemsize <= size; i += a->itemsize) {
  52. item = (fixed_item_t*)(FIXED_BUFFER(block) + i);
  53. item->next = a->freelist;
  54. a->freelist = item;
  55. }
  56. }
  57. // 分配内存
  58. fixed_item_t *item = a->freelist;
  59. a->freelist = a->freelist->next;
  60. return item;
  61. }
  62. // 释放内存项
  63. static void fixed_free(fixed_allocator_t *a, void *item) {
  64. fixed_item_t *bitem = (fixed_item_t *)item;
  65. bitem->next = a->freelist;
  66. a->freelist = bitem;
  67. }
  68. // 取地址偏移
  69. static uintptr_t _get_offset(fixed_allocator_t *a, fixed_item_t *item) {
  70. fixed_block_t *block = a->block;
  71. uint8_t *p = (uint8_t*)item;
  72. while (block) {
  73. if (FIXED_BUFFER(block) <= p && p < (uint8_t*)block + a->blocksize)
  74. return p - FIXED_BUFFER(block);
  75. block = block->next;
  76. }
  77. return 0;
  78. }
  79. static void fixed_print(fixed_allocator_t *a) {
  80. printf("========================================\n");
  81. fixed_item_t *item = a->freelist;
  82. while (item) {
  83. uintptr_t offset = _get_offset(a, item);
  84. printf("(%lu--%lu), ", offset, offset+a->itemsize-1);
  85. item = item->next;
  86. }
  87. printf("\n");
  88. }

由于只能分配固定大小的内存,它的功能也是受限的。但它却是很多现代内存管理器的重要组成部分,这些管理器会创建很多不同 size class 的 freelist,这样就能快速创建不同大小的小对象。

buddy allocator

另外一种分配器叫伙伴分配器,它比 fixed size allocator 的限制小一些,可以分配不同大小的内存,不过这些大小必须是 2 的幂;在释放内存的时候,如果这块内存的伙伴内存也处于释放状态,分配器会将两块内存合并起来变成大一倍的内存,并且这个过程会一直重复,直到没有释放的伙伴内存为止。

那么伙伴内存究竟是什么东西呢,可以想象成二叉树里的左右子结点,对于左结点来说右结点就是它的伙伴,对于右结点来说左结点就是它的伙伴。这两个伙伴有一样的大小,且内存连续,所以可以合并成大内存。这就是伙伴分配器的核心思想。

我先贴出代码,能通过代码理解这个算法当然最好,如果不能理解也没关系,后面会通过一些图例来分析。

  1. // 最小的内存池大小
  2. #define MIN_POOL_SIZE 1024
  3. // 最小可分配的块大小
  4. #define MIN_BLOCK_SIZE 16
  5. // 最小可分配块的级别
  6. #define MIN_LEVEL 4
  7. // 内存块
  8. typedef struct buddy_block {
  9. struct buddy_block *next;
  10. } buddy_block_t;
  11. typedef buddy_block_t* buddy_block_p;
  12. // 伙伴分配器
  13. typedef struct buddy_allocator {
  14. uint8_t *pool; // 内存池
  15. uint8_t *base; // 内存池可分配的起始地址
  16. buddy_block_p *freelist; // freelist数组,每一个slot存放一个freelist,内存块尺寸=2^i,i即是slot的索引。
  17. int maxlv; // 最大级
  18. int minlv; // 最小级
  19. } buddy_allocator_t;
  20. // 取块的级别,级别存在块的前一个字节处
  21. #define GET_LEVEL(b) (*((uint8_t*)(b) - 1))
  22. // 计算级别代表的内存大小
  23. #define LEVEL_2_SIZE(lv) (1 << (lv))
  24. // 地址偏移
  25. #define GET_OFFSET(a, b) ((uintptr_t)(b) - (uintptr_t)(a)->base)
  26. // 向上取整到2的幂
  27. static inline uint64_t _roundup_pot(uint64_t v) {
  28. v--;
  29. v |= v >> 1;
  30. v |= v >> 2;
  31. v |= v >> 4;
  32. v |= v >> 8;
  33. v |= v >> 16;
  34. v |= v >> 32;
  35. return ++v;
  36. }
  37. // 计算大小代表的级别
  38. static inline int _calc_level(size_t size) {
  39. int lv = MIN_LEVEL;
  40. size_t sz = 1 << lv;
  41. while (size > sz) {
  42. sz <<= 1;
  43. lv++;
  44. }
  45. return lv;
  46. }
  47. // 取一个内存块的伙伴内存块:
  48. // |<---block--->|<---buddy--->|
  49. // |<---buddy--->|<---block--->|
  50. static inline buddy_block_t* _get_buddy(buddy_allocator_t *a, buddy_block_t *block, int lv) {
  51. // 取相对地址
  52. uintptr_t offsetaddr = GET_OFFSET(a, block);
  53. // 通过xor即可以取到伙伴的地址,比如:
  54. // 从左孩子取右孩子:offsetaddr=10000, lv=3: buddyaddr = 10000 ^ (1 << 3) = 10000^1000 = 11000
  55. // 从右孩子取左孩子:offsetaddr=11000, lv=3: buddyaddr = 11000 ^ (1 << 3) = 11000^1000 = 10000
  56. uintptr_t buddyaddr = offsetaddr ^ (1 << lv);
  57. // 取绝对地址
  58. return (buddy_block_t*)((uintptr_t)a->base + buddyaddr);
  59. }
  60. // 初始化
  61. static void buddy_init(buddy_allocator_t *a, size_t poolsz, size_t minsz) {
  62. poolsz = poolsz > MIN_POOL_SIZE ? poolsz : MIN_POOL_SIZE;
  63. minsz = minsz > MIN_BLOCK_SIZE ? minsz : MIN_BLOCK_SIZE;
  64. assert(minsz < poolsz);
  65. poolsz = (size_t)_roundup_pot(poolsz);
  66. minsz = (size_t)_roundup_pot(minsz);
  67. a->maxlv = _calc_level(poolsz);
  68. a->minlv = _calc_level(minsz);
  69. // 这里多分配了一个sizeof(void*),用于将level放到每个分配的block中
  70. a->pool = (uint8_t*)calloc(poolsz + sizeof(void*), 1);
  71. a->base = a->pool + sizeof(void*);
  72. a->freelist = (buddy_block_p*)calloc(a->maxlv + 1, sizeof(buddy_block_p));
  73. a->freelist[a->maxlv] = (buddy_block_t*)a->base;
  74. }
  75. // 结束
  76. static void buddy_term(buddy_allocator_t *a) {
  77. free(a->freelist);
  78. free(a->pool);
  79. }
  80. // 分配
  81. static void* buddy_alloc(buddy_allocator_t *a, size_t size) {
  82. size += 1; // 多1个字节,用于存放level
  83. int lv = _calc_level(size); // 得到该块的级别
  84. // 向后查找可用的内存块,越往后内存块越大
  85. int i = lv;
  86. for (;; ++i) {
  87. if (i > a->maxlv) return NULL;
  88. if (a->freelist[i]) break;
  89. }
  90. // 从链表取出内存块
  91. buddy_block_t *block = a->freelist[i];
  92. a->freelist[i] = a->freelist[i]->next;
  93. // 将内存块一级一级分割,并放入相应的freelist
  94. buddy_block_t *buddy;
  95. while(i-- > lv) {
  96. buddy = _get_buddy(a, block, i);
  97. a->freelist[i] = buddy;
  98. }
  99. // 记录该内存块的level,在free的时候会用到
  100. // 这个level是放在block的前一个字节的
  101. GET_LEVEL(block) = lv;
  102. return block;
  103. }
  104. // 释放
  105. static void buddy_free(buddy_allocator_t *a, void *p) {
  106. buddy_block_t *block = (buddy_block_t*)p;
  107. int i = GET_LEVEL(block);
  108. buddy_block_t *buddy;
  109. buddy_block_t **list;
  110. for (;; ++i) {
  111. // 取当前块的buddy块
  112. buddy = _get_buddy(a, block, i);
  113. // 判断buddy块是否有在freelist中
  114. list = &a->freelist[i];
  115. while ((*list != NULL) && (*list != buddy))
  116. list = &(*list)->next;
  117. if (*list != buddy) {
  118. // 如果没找到buddy块,将block加入freelist
  119. block->next = a->freelist[i];
  120. a->freelist[i] = block;
  121. return;
  122. } else {
  123. // 如果找到,将block和buddy合并成大块
  124. block = block < buddy ? block : buddy;
  125. // 从链表删除,然后继续循环合并块
  126. *list = (*list)->next;
  127. }
  128. }
  129. }
  130. // 打印内存情况
  131. static void buddy_print(buddy_allocator_t *a) {
  132. printf("========================================\n");
  133. for (int i = a->minlv; i <= a->maxlv; ++i) {
  134. buddy_block_t *block = a->freelist[i];
  135. size_t sz = LEVEL_2_SIZE(i);
  136. printf("Lv %-2d (%lu) : ", i, sz);
  137. while (block) {
  138. uintptr_t offset = GET_OFFSET(a, block);
  139. printf("(%lu--%lu), ", offset, offset + sz - 1);
  140. block = block->next;
  141. }
  142. printf("\n");
  143. }
  144. }

buddy allocator 使用一个 freelist 数组来存放各个 size 的内存块链表。数组的索引决定了内存块的大小,比如索引为 4 的槽位放的是 2^4=16 的内存块链表。假如我们创建的大内存是 1024,那么一开始索引 10 的 freelist 指向了这块内存,其他槽位皆为空:

几种常见的内存分配算法 - 图4
接下来我们请求分配大小为 100 的内存,因为只能是 2 的幂,所以向上取整为 128,即等于 2^7;这表明该内存要从索引为 7 的 freelist 分配,这个 freelist 现在还是空的,要从更高索引的 freelist 切割内存过来用,经过一翻切割之后,变成下面这样:

几种常见的内存分配算法 - 图5
重复再分配 3 个 100 的内存之后,变成下面这样:

几种常见的内存分配算法 - 图6

总共分配出去 4 块 128 大小的内存,其中第 2 块和第 3 块使用完毕后回收回来;第 1 块和第 2 块是伙伴,第 3 块和第 4 块是伙伴,所以回收回来的这两个不是伙伴,他们不能合并,只能串成 freelist 放在 7 号索引处:

几种常见的内存分配算法 - 图7
接着第 1 块内存也释放了,它和第 2 块内存是伙伴,这两内存都已经释放,可以将它们合并变成一块 256 的内存,即等于 2^8,最终索引 8 的 freelist 将指向它:

几种常见的内存分配算法 - 图8

最后第 4 块内存也释放了,它和第 3 块内存是伙伴,3,4 块内存合并成 256 的内存;这块合并完的内存和前面 256 大小的内存是伙伴,所以又合并变成一块 512 的内存;这块内存和最后那块 512 的内存又是伙伴,最终他们神奇的合并回 1024 的内存,并由索引为 10 的 freelist 引用着,也就是最前面那张图的样子。

经过这样的讲解,再结合代码来看是不是一眼了然了呢?代码里还有一些小技巧,比如通过异或得到伙伴的地址,用内存块前面一个字节来存放 Level 等,这些请通过代码和注释慢慢理解。

buddy allocator 有用在一些著名的内存分配器中,比如 jemalloc;另外 Linux 内核似乎也用这个算法来管理空闲的物理页,这足以说明它是一个很重要的算法。

后记

The C Programming Language 第 8.7 节介绍了一个内存分配算法,可以分配任意大小的内存,内存回收之后还能合并相领的内存块,看起来似乎是一个通用的内存分配器。不过这个分配器太慢了,分配的时候必须遍历 freelist 找到合适大小的内存块,释放的时候也须遍历 freelist,找到插入的位置,使内存块地址有序,这样才能合并。祖师爷的本意是想通过这个代码来介绍动态内存分配的核心技术;我们回过头来看看上面三个受限的分配器,也能得出内存分配其实是指针和链表的重度应用,通过自己实现一些分配器,你能更加理解指针和链表的操作。

但是,如果要实现一个通用的,高效的现代内存管理器,要考虑的问题就太多了:要抽象 OS 的内存分配接口,要满足多线程系统的性能要求,还要考虑安全性和可诊断,总之这是一个很有挑战的系统工程。

原文:https://zhuanlan.zhihu.com/p/369972058