原文:https://arjunsreedharan.org/post/148675821737/memory-allocators-101-write-a-simple-memory
与本文相关的代码: github.com/arjun024/memalloc
本文的话题是用C语言实现一个简单的内存分配器。我们将实现 malloc(), calloc(), realloc() and free() 。
这是一个入门水平的文章,不会涉及到每个细节。这个内存分配器不会追求速度和效率,也没有考虑内存分配的内存页对齐问题,我们的目标就是实现一个可以工作的内存分配器,仅此而已。
如果你想要查看完整的代码,可以访问github repo memalloc
在开始实现之前,我们需要先熟悉一个程序的内存布局。每个程序都运行在各自的虚拟地址空间中,这个虚拟地址空间包含5个部分:
Text section: 该部分包含供处理器执行的二进制指令序列 Data section: 包含已初始化的静态数据(non-zero initialized static data) BSS (Block Started by Symbol,以符号开头的块) : 包含未未初始化的数据. 未初始化的数据将会被初始化为0并被放在这个区域. Heap: 包含动态分配的数据. Stack: 包含自动分配的变量,函数参数,基指针拷贝等。
如图所示,stack 和 heap 的增长方向是相反的。有时候,我们将 data, BSS 和 heap 三个部分 统称为 "data segment",data segment的结尾由一个指针确定,叫做 program break, 简称 brk ,brk 指向 heap 的末尾处。
现在,我们要在 heap 上 分配更多的空间,我们需要请求系统往内存增长方向移动 brk,同理,释放内存的时候,需要请求系统往内存减小的方向方向移动brk。
如果我们的系统环境是Linux或者其他的类Unix系统, 我们可以使用系统调用 sbrk()
来操纵 program break 。
调用 sbrk(0)
获取 program break 的当前地址 .
调用 sbrk(x)
,x 为正数时,请求分配 x bytes 的内存空间 .
调用 sbrk(x)
, x 为负数时,请求释放 x bytes 的内存空间 .
失败时,sbrk()
返回 (void*) -1
.
老实说,sbrk()
并不是我们现在的最佳选择,更好的选择是 mmap()
, 而且sbrk() 也不是线程安全的。他只会按照 LIFO 的顺序 增长和缩小内存。
如果在mac 上你执行 man 2 sbrk
它会告诉你
The brk and sbrk functions are historical curiosities left over from earlier days before the advent of virtual memory management.
不过 glibc 的malloc实现 还也是使用 sbrk() 来分配 小尺寸的内存空间。这里我们暂且还是使用 sbrk
。 [1]
malloc(size) 函数 分配 size bytes 的 内存,并返回一个指针,指向这块分配到的内存。下面是我们的简易版 malloc :
void *malloc(size_t size)
{
void *block;
block = sbrk(size);
if (block == (void*) -1)
return NULL;
return block;
}
如代码所示,我们调用sbrk(size). 如果成功,heap上会分配 size bytes 的内存. 这很简单,不是吗?
比较有难度的是如何释放这块内存. free(ptr) 函数释放ptr 指向的内存区域 , 这个ptr 必须是通过前面调用 malloc(), calloc() 或者 realloc() 得到的 . 要释放一块内存,首先需要知道要释放的这块内存的尺寸。 在目前的方案中,这是不可能的,因为尺寸信息没有保存在任何地方. 所有,我们首先需要确定哪里保存这个尺寸信息 。
更重要的一点是,我们需要明白这个由操作系统分配的heap memory是连续的,所有我们只能释放heap末端的内存。我们可以把heap想象成一个枕头面包,你可以在一端进行拉伸和压缩,但是必须保持还是完整的一块面包。 为了能够释放heap 中任意区域的内存,我们需要区分free memory 和 releasing memory 。 事实上,free 一块内存并不总是需要将内存 release 给操作系统,我们可以继续持有这块内存,只是把它标记成free,这是我能想到的最好的方法 .
到这里,我们需要为已分配的每个内存块保存2个信息:
- size
- 内存块的状态: free 或者 not-free ?
为了保存这些信息,我们需要为每个新分配的内存块增加一个header. 这个header 大体如下所示 :
struct header_t {
size_t size;
unsigned is_free;
};
这个Idea很简单,当一个程序请求 size bytes 的内存,我们计算 total_size = header_size + size
, 然后调用 sbrk(total_size)
. 我们使用这个返回的内存空间来填充 header 和 实际的内存块 . 这个 header 在内部管理,对于调用的程序来说是完全透明的.
现在,每个内存块长这样:
我们无法保证,用 malloc 配到的内存块都是相互连续的。想象一下,调用程序另外调用了 sbrk(),或者存在一块 内存,在我们的内存块之间被 mmap() 请求所分配,这些情况下内存块都是不连续的。因此,我们还需要一个方法来遍历我们所有的内存块(为什么要遍历,在实现free()的时候,我们就会知道原因)。,为了保存追踪 malloc 分配到的内存,我们将这些内存库放进一个链表中,此时,header 变成这样:
struct header_t {
size_t size;
unsigned is_free;
struct header_t *next;
};
内存块链表变成这样:
现在,我们将header结构体 和 一个 16字节的stub 封装进一个 union
. 这个header 最终会指向一个对齐16字节的地址(因为union
的尺寸等于成员中的最大尺寸 ), 这就保证header的尾部是内存对齐 。header尾部是实际内存块 开始的地方,因此提供给调用者的内存会按16字节对齐。
typedef char ALIGN[16];
union header {
struct {
size_t size;
unsigned is_free;
union header *next;
} s;
ALIGN stub;
};
typedef union header header_t;
然后,我们用 head 和 tail 指针 来 跟踪 这个列表。
header_t *head, *tail;
为了支持多线程并发地访问内存,我们会引入一个基本的锁机制。
我们使用一个全局锁,在执行任何动作之前,你需要请求这个锁,一旦完成动作需要释放这个锁。
pthread_mutex_t global_malloc_lock;
现在,我们的 malloc 变成这样:
void *malloc(size_t size)
{
size_t total_size;
void *block;
header_t *header;
if (!size)
return NULL;
pthread_mutex_lock(&global_malloc_lock);
header = get_free_block(size);
if (header) {
header->s.is_free = 0;
pthread_mutex_unlock(&global_malloc_lock);
return (void*)(header + 1);
}
total_size = sizeof(header_t) + size;
block = sbrk(total_size);
if (block == (void*) -1) {
pthread_mutex_unlock(&global_malloc_lock);
return NULL;
}
header = block;
header->s.size = size;
header->s.is_free = 0;
header->s.next = NULL;
if (!head)
head = header;
if (tail)
tail->s.next = header;
tail = header;
pthread_mutex_unlock(&global_malloc_lock);
return (void*)(header + 1);
}
header_t *get_free_block(size_t size)
{
header_t *curr = head;
while(curr) {
if (curr->s.is_free && curr->s.size >= size)
return curr;
curr = curr->s.next;
}
return NULL;
}
这里来解释一下这段代码:
我们检查size是否为0,是则返回 NULL
.
对于合法的size,我们首先请求锁. 然后我们调用 get_free_block()
,这个函数的功能是 遍历链表找到合适的内存块:看看其中是否有被标记为free,且尺寸合适的内存块. 这里,我们采用First-Fit 方法(首次适配方法)。
如果有找到,我们就将这个内存块标记为 not-free,然后释放全局锁,返回这个内存块的指针。这种情况下,header指针会指向这个找到内存块的header部分,记住,这个header对外部是完全隐藏的。当我们执行 (header + 1)
时,指针指向header尾部的下一个位置,它也是实际内存块的第一个字节,这也是调用者想要的,它会被转换成 (void*)
返回。
如果没有找到合适的内存块,就调用 sbrk()
来扩充 heap。heap 扩充的长度必须满足请求和size和 header的size。所以,我们首先计算total_size
: total_size = sizeof(header_t) + size;
. 然后我们请求操作系统来正向移动 program break: sbrk(total_size)
.
在操作系统给的这块内存中, 我们首先为header分配空间 . 在C语言中, 不需要将void*
转换成任意指针的类型,这被认为是始终安全的。 这就是为什么我们没有显示的做转换: header = (header_t *)block;
我们按请求的size填充header,并将它标记为 not-free,然后更新 next
指针,并更新 head
tail
,即更新链表的最新状态。 就像前面说的那样,我们对调用者隐藏了header,只返回 (void*)(header + 1)
,然后释放全局锁。
现在,我们看看 free() 应该做些什么。free() 首先要确定,待free的内存块是否在heap的尾部,如果是,我们可以直接release给操作系统,否则,我们将它标记为 free,期待它之后被重新使用。
void free(void *block)
{
header_t *header, *tmp;
void *programbreak;
if (!block)
return;
pthread_mutex_lock(&global_malloc_lock);
header = (header_t*)block - 1;
programbreak = sbrk(0);
if ((char*)block + header->s.size == programbreak) {
if (head == tail) {
head = tail = NULL;
} else {
tmp = head;
while (tmp) {
if(tmp->s.next == tail) {
tmp->s.next = NULL;
tail = tmp;
}
tmp = tmp->s.next;
}
}
sbrk(0 - sizeof(header_t) - header->s.size);
pthread_mutex_unlock(&global_malloc_lock);
return;
}
header->s.is_free = 1;
pthread_mutex_unlock(&global_malloc_lock);
}
首先要获取待free的内存块的header,也就是通过一个固定的header大小,获取这个块的header,这里,我我们将block转换成 header 指针,并往前移动一个单元
header = (header_t*)block - 1;
sbrk(0)
获取 program break 的当前位置。为了检查 这个 block 是否是 heap的末尾,我们首先获取当前 block的末尾,这个末尾位置end 可以通过 (char*)block + header->s.size
计算得来。然后用这个end 和 program break 做比较。
如果该block就是program break的末尾,则我们可以直接 缩小 heap的空间,将内存释放给操作系统。我们首先修改链表的 head和tail,即从链表中移除这个 block;然后计算内存需要释放的总量 total_release_size ,sizeof(header_t) + header->s.size
。 为了释放这个数量的内存,我们可以 调用 sbrk( -total_release_size )
calloc(num, nsize)
函数 为数组分配内存,数据包含 num个元素,每个元素的尺寸为 nsize,并返回分配到内存的指针。另外,整块内存都被初始化为0.
void *calloc(size_t num, size_t nsize)
{
size_t size;
void *block;
if (!num || !nsize)
return NULL;
size = num * nsize;
/* check mul overflow */
if (nsize != size / num)
return NULL;
block = malloc(size);
if (!block)
return NULL;
memset(block, 0, size);
return block;
}
这里,我们检查一下是否越界,然后调用我们的 malloc(),然后使用 memset() 进行初始化。
realloc()
修改一个给定内存块的尺寸
void *realloc(void *block, size_t size)
{
header_t *header;
void *ret;
if (!block || !size)
return malloc(size);
header = (header_t*)block - 1;
if (header->s.size >= size)
return block;
ret = malloc(size);
if (ret) {
memcpy(ret, block, header->s.size);
free(block);
}
return ret;
}
这里,我们首先获取 block header ,然后检查当前 size >= 新size ,如果是,则什么也不干;如果小于,则使用 malloc() 来获取一个 新 size 的block, 然后使用memcpy() 将上下文重定向到这个新的block,并free 旧的block。
你可以从作者的github 仓库获取完整的代码 - memalloc. 我们编译这个内存分配器,然后在 这个内存分配器之上 运行 ls .
这里,我们首先将它编译成一个动态库.
$ gcc -o memalloc.so -fPIC -shared memalloc.c
-fPIC 和 -shared 选项 ,前者确保编译输出的是位置无关的代码,后者告诉链接器产生动态链接库。
在linux上,如果你设置环境变量 LD_PRELOAD 为 某个链接库的路径,这个文件会在其他库之前加载。我们可以使用这个技巧来首先加载我们编译好的链接库,在这个shell中执行的后续命令都将使用我们开发的 malloc(), free(), calloc() 和 realloc() .
$ export LD_PRELOAD=$PWD/memalloc.so
Now,
$ ls
memalloc.c memalloc.so
看呀,这是基于我们的内存分配器的ls. 如果你不信的话,可以在maloc()函数里打印一些debug信息,来验证这是不是真的.
See a list of memory allocators: liballoc [Doug Lea’s Memory Allocator](http://oswego.edu/dl/html/malloc.html dlmalloc). TCMalloc ptmalloc
[1] The GNU C Library: Malloc Tunable Parameters OSDev - Memory allocation Memory Allocators 101 - James Golick