作者 | Ankur Anand
译者 | 闫亮
内存分配器一直是性能优化的重头戏,其结构复杂、内容抽象,涉及的数据结构繁多,相信很多人都曾被它搞疯了。本文将从内存的基本知识入手,到一般的内存分配器,进而延伸到 Go 内存分配器,对其进行全方位深层次的讲解,希望能让你对进程内存管理有一个全新的认识。
物理内存 VS 虚拟内存
在研究内存分配器之前,让我们先看一下物理内存和虚拟内存的背景知识。剧透一下,内存分配器实际上操作的不是物理内存而是虚拟内存。
内存细胞作为物理内存结构的最小单元,工作原理如下:
地址线(三相晶体管)其实是连接数据线与数据电容的三相开关。
当地址线负载时(红线),数据线开始向电容中写数据,电容处于充电状态,逻辑值变为 1
当地址线空载时(绿线),数据线不能向电容中写数据互联网项目,电容处于未充电状态,逻辑值为 0
当 CPU 从 RAM 中读值时,它首先会给地址线发送一个电流信号从而合上开关,连通数据电路。这时如果电容处于高电位,则电容中的电流会流向数据线,CPU 读数为 1;否则,数据线中没有电流负载,CPU 读数为 0。
CPU 和内存的交互
CPU 实际上通过地址总线、数据总线和控制总线实现对内存的访问。
让我们进一步分析一下 地址线 和 按字节寻址:
在 DRAM 中,每一个字节都有一个唯一的地址。“可寻址字节不一定等于地址线的数量”,
例如 16 位的 Intel 8088、PAE(物理地址扩展)等,其物理字节大于地址线数量。
每一条地址线可以传送 1-bit 的数值,可表示寻址字节中的一位。
图中有 32 位地址线,所以可认为可寻址字节是 32 位的。
[ 00000000000000000000000000000000 ] —低位内存地址。
[ 11111111111111111111111111111111 ] — 高位内存地址。
因为上图物理字节有 32 条地址线,所以其寻址空间大小为 2 的 32 次方,也就是 4GB
可寻址字节的大小其实取决于地址线的数量,例如具有 64 个地址线的 CPU(x86–64 处理器)可以寻址 2 的 64 次方,但是目前大多数 64 位的 CPU 其实只使用了其中的 48 位(AMD)或者 42 位(Intel)。尽管理论上可访问 2 的 64 次方(256TB)大小的地址空间,但是通常操作系统并没有完全支持它们(Linux 的 四层页表结构 允许处理器访问 128TB 大小的地址空间,Windows 支持 192TB)。
由于实际物理内存的大小是有限制的,所以每个进程都运行在各自的沙盒中,也就是所谓的“虚拟地址空间”,简称虚拟内存。
虚拟内存中的字节地址其实并不是实际的物理地址。操作系统需要记录所有虚拟地址到物理地址的映射转换,也就是我们熟知的页表。
进程中的虚拟地址如下图所示:
虚拟地址空间示意图
所以当 CPU 执行内存中一条指令的时候,它首先需要把 VMA(虚拟内存区域)中的逻辑地址转换为线性地址,转化过程通过 MMU(内存管理单元)实现。
虚拟地址与实际物理地址的映射
由于逻辑地址太大很难被有效地管理,于是引入了页(page)的概念。所有的虚拟内存空间被分成很多相对较小的区域(通常为 4KB),也就是我们所称的页。页是虚拟内存管理中最小的单位,虚拟内存通常不存储任何内容,只是简单的将程序地址空间映射到底层的物理地址。
用户进程只能使用虚拟内存地址。让我们来看一下程序如何申请堆内存空间:
(堆内存申请的汇编实现)
程序通常使用系统调用 brk(sbrk/mmap) 来获取更多的内存,内核仅更新堆的 VMA,并没有进行进行实际的申请操作。
系统在内存分配的时候,其实并没有申请相应的物理页帧,只有在真正赋值的时候才会申请物理页帧。这也是 VSZ(进程虚拟内存大小)和 RSS(常驻物理内存大小)的最大区别。
内存分配器
相信通过前面对“虚拟地址空间”以及堆内存申请的学习逻辑地址怎么转化为物理地址,相信我们对内存分配器说也就不难理解了。
如果堆中有足够多的内存空间,那么分配器就可以独立完成内存的申请而不需要访问内核。否则,系统将会通过系统调用函数 brk 来扩展堆,通常是增加变量 MMAP_THRESHOLD 的默认值 (128KB)。
当然内存分配器的职责不仅仅是更新 brk 地址,更多的还是用于减少碎片以及快速分配内存块。让我们来看一个实例:假设我们的程序通过函数 malloc 来申请一块连续内存块,使用函数 free 来释放申请的内存块,步骤 p1 到 p4 的整个操作顺序如下:
到步骤 p4 的时候,尽管剩余的内存块数量大于需要申请的数量逻辑地址怎么转化为物理地址,但是因为碎片的关系,我们已经不能获得 6 个连续的内存块了。我们该如何减少内存碎片呢?答案要取决于具体使用的分配算法。
由于 Go 内存分配器同 TCMalloc 分配器非常相似,我们先看一下相对简单的 TCMalloc。
TCMalloc
TCMalloc(Thread Cache Malloc)的核心思想是将内存分解为多层,从而减小内存锁的粒度。TC-Malloc 内存管理分为 线程内存 以及 页堆 两部分:
线程内存
为减少内存碎片,每个内存页都被分成了多个固定类大小的空闲列表。这样每一个线程都都有一个不带锁的小对象缓存,从而可以高效的为并行程序分配小对象(