虚拟内存被组织为一个由存放在磁盘上连续的字节大小的单元组成的数组。DRAM 可以视为虚拟内存的缓存。
如果虚拟内存在 DRAM 上不命中的话,那么就需要从磁盘上获取,导致非常大的不命中惩罚,这就使得 DRAM 应该是全相连的。
同时,如果 DRAM 上缓存的虚拟页替换错了,那么也会导致很大的不命中惩罚,这就使得操作系统对 DRAM 缓存使用了更复杂精密的替换算法。
考虑到磁盘的写速度很慢,于是 DRAM 缓存总是使用写回而不使用直写。
计算机中使用 MMU 这个硬件来支持虚拟内存的地址翻译。
操作系统访问一个虚拟内存地址有如下的过程:
我们可以在 MMU 和 DRAM 中间加高速缓存(如 L1 缓存):
CPU 每次访问一个虚拟地址,MMU 就需要先获取一个 PTE 然后再访问其对应的物理地址,也就是会从内存中多取一次数据。然而从 DRAM 访问数据需要上百个周期,这会导致性能下降地厉害。如果我们考虑到 MMU 和内存之间的高速缓存,比如 PTE 恰好缓存到 L1 中,那么开销就下降到 1 到 2 个周期。
我们还可以再使用 TLB 来使得获取 PTE 的开销更小。TLB 是一个相连度比较高的缓存(甚至是全相连)。
TLB 根据 TLBI 来选择相应的 cache set,然后根据 TLB 标记选择相应的 cache line。
TLB 是内置在 MMU 的,所以 MMU 访问 TLB 的开销极小。
虚拟地址的 VPO 和物理地址的 PPO 部分是相同的。在 Intel Core I7 上,VPO 为 12 位,L1 缓存有 64 个 cache set,每个 cache set 可容纳 8 个 cache line,一个 cache line 上有 64 个 cache block。因此,VPO 或者说是 PPO 的这 12 位刚好可以对应于某个 cache set 和 cache block。当进行地址翻译的时候,MMU 将虚拟地址的高 52 位(去掉了低 12 位)用来查 PTE 以获取物理地址的高 52 位(即 PPN)。而同时,虚拟地址的低 12 位被发送到 L1 缓存中来定位是哪个 cache set。当得到 PPN 时,L1 缓存已经准备好将 PPN 与 8 个 cache line 上的标记进行比对了。这两个过程同时执行,可以进一步增加效率。
在 Linux 中,操作系统还维护了当前进程虚拟内存映射的情况,比如:
当 MMU 翻译某个虚拟内存地址产生缺页时,Linux 会借助维护的这个信息对缺页原因进行判断:
在 X86 上,PTE 最低位有一个有效位,表示其覆盖的内存区域是否存在于物理内存中。最后一级页表的 PTE 上,如果有效位为 0,则剩余的高位由操作系统自行组织,一般是用来记录位于磁盘上的位置:
其实如果使用多级页表的话,只有第一级页表需要常驻内存,其余级别的页表像普通内存区域一样可以触发缺页。
借助虚拟内存机制,我们可以有如下的操作:
Lazy allocation:当我们申请一块内存后,操作系统不会立刻给这块内存分配对应的物理页,而是程序实际用到的时候再分配。
Copy on write:多个进程都含有某个对象的时候(如一个可执行文件的不同进程),可以直接让其虚拟内存映射到同一个物理内存区域。当某个进程产生修改的时候,先将被共享的页复制,再应用修改。
Zero fill on demand:如果映射的区域全是 0 的话(比如 elf 的 bss 区域),那么操作系统不会真的去分配对应的物理页并清零,而是可能在 PTE 上标记一下,并映射到一个全为 0 的区域。当程序发生修改的时候,类似 copy on write 那样复制。
Demand paging:如果需要将磁盘上的某个文件放入内存的话,操作系统不会直接把文件的数据复制到内存中,而是当真正需要用到的时候触发缺页进行加载。
我们可以使用 mmap
函数进行用户级的内存映射:
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
unistd.h
和 sys/mman.h
两个头文件。start
开始,长度为 length
的内存区域。这个内存区域去映射文件描述符 fd
对应的资源的 offset
偏移处。start
可以设为 NULL
,此时将由内核自行决定分配到哪里。prot
给定了映射区域的权限位:
PROT_EXEC
:可执行PROT_READ
:可读PROT_WRITE
:可写PROT_NONE
:不能被访问flags
给定了映射区域的特征:
MAP_ANON
:映射区域全是 0,内核将会按照 zero fill on demand 来处理。此时 fd
和 offset
参数都给 0 就可以了。MAP_PRIVATE
:以私有方式进行映射。内核将使用 copy on write 机制,对映射区域的修改仅影响当前进程。MAP_SHARE
:以共享方式进行映射。当进行 fork 的时候父子进程之间会共享这个区域,可以用来进行进程间通信。MAP_FAILED(-1)
。int munmap(void *start, size_t length);
unistd.h
和 sys/mman.h
头文件。start
开始的 length
长度的区域的映射。使用 MAP_ANON | MAP_PRIVATE
可以创建一个全为 0 的私有区域。使用 MAP_ANON | MAP_SHARED
可以创建一个全为 0 的共享区域。如果不仅仅是父子进程之间的跨进程通信的话,可以考虑使用 shm_open
来在虚拟文件系统里创建一个文件,然后通过 mmap
来共享内存。
mmap
可以实现内存分配,但是其在不同操作系统上可能不一样。并且内存映射是以页为单位的,不能细粒度分配。并且当需要释放内存的时候 munmap
需要给出 length
,这也要求我们自己再维护 mmap
的长度。综上,我们需要动态内存分配器来进行内存分配。
程序的内存布局如上,其中用户栈从高地址往低地址生长,堆从低地址往高地址生长。内核中维护了堆顶部的指针 brk
。动态分配的内存会被放入堆中,堆是 zero fill on demand 的。
void *malloc(size_t size)
stdlib.h
中size
的内存块NULL
并设置 errno
double
),在 64 位系统中返回地址是 16 的倍数(64 位系统还要求对 SIMD 的类型有对齐支持)malloc
并不会初始化分配的内存块。可以使用 calloc
,这个函数是 malloc
的一个包装函数,来将分配的内存初始化为零void *realloc(void *ptr, size_t new_size)
stdlib.h
中new_size
ptr
必须是 malloc
返回的内容,否则会出现 UBrealloc
会尝试直接把原来的内存块进行扩容,如果原内存块的后方还有未分配的区域。否则,将会分配一个新的大小为 new_size
的内存块并把原来的数据复制过去void free(void *ptr)
stdlib.h
中ptr
必须是 malloc
返回的内容,否则会出现 UBvoid *sbrk(intptr_t incr)
unistd.h
中brk
指针增加 incr
,incr
既可以为正数或是负数brk
指针的旧值,否则,返回 -1
并将 errno
设置为 ENOMEM
malloc
内部来调用这个来自动扩容堆的一个比较简单地实现内存分配器的方法是,我们在要分配出去的内存块的前面加一个头部,来记录一些信息:
通过当前块位置和块大小就可以到达下一个块,这就相当于形成了一个隐式的链表。
假如我们要求分配的内存块是 8 字节对齐的,那么块大小的低 3 位必然是 0,因此我们可以直接让低 3 存储一些额外的信息。这里就用来标识当前块是已分配还是空闲的。
放置策略
当我们请求一个 字节的块时,分配器搜索空闲链表,此时有一些放置策略:
碎片
合并策略
有可能有多个空闲块相邻,此时我们需要考虑一下合并的策略:
立即合并也会有一个问题,比如现在有一个大的空闲块,此时反复地合并和释放,会造成反复地合并和分割。因此一个快速的内存分配器会以某种方式推迟合并。
由于合并的时候我们还需要知道前面的块的位置,所以我们可能还需要维护一个脚部,头部和脚部完全一致:
不过这样还会造成空间的浪费。更聪明的做法是在在当前块的头部记录一下上一个块是不是空闲的,然后只在空闲块中加入脚部。
和隐式空闲链表类似,但是空闲块还记录了前驱和后驱的数据:
这样空闲链表里就只有空闲块了,搜索的时间下降。
不过由于空闲块的额外信息,使得增大了最小块大小,增大了内部碎片的程度。
这里说明一下 glibc 的 malloc 实现。参考:Understanding glibc malloc – sploitF-U-N。
首先是块的结构:
其中 P
表示前面的那个块是否被分配了。
空闲块还记录了在同一个 binlist 中的前一个 chunk 和后一个 chunk 的指针。
当我们要求 8 字节对齐之后,最小块大小为 16 字节。
chunk 被放在 bin 中。bin 是一个空闲链表,有如下几个 bin:
分配块的过程如下:
将内存视为一个有向图,其中不可达的部分就是要清除的:
其中的 Root 节点可以通过扫描栈和全局变量得到。
我们可以在 C/C++ 中实现一个保守的 Mark & Swap 的垃圾收集器:
之所以保守,是因为上述代码把一块内存中所有的数据都视为了指针。但实际上有可能出现只是单纯存储的内容有点像指针,而并非真的指针引用的情况。
朴素的 Mark and Swap 需要暂停整个程序,等待 GC 结束之后再继续运行。Nettles-O'Toole Algorithm 是其渐进版本,原理大概是先让程序运行一段时间,然后暂停,推进 GC 执行(但不必完全执行完毕),程序和 GC 交替执行。但 Nettles-O'Toole Algorithm 需要引入一个 write barrier,每当将一个对象的引用写入到另一个对象的时候,将会触发 barrier,GC 会去检查这个对象是否已经被标记,没有被标记的话就会打上标记。
和 Mark and Sweep 类似。但会使用一个 from space 和一个 to space,并使用 BFS 进行遍历,将可到达的内存块从 from space 复制到 to space,并更新指针引用,然后交换两个 space。
Stop and Copy 也有渐进的版本:Baker's Algorithm。和 Nettles-O'Toole Algorithm 不同的是其需要一个 read barrier,每当读取一个对象的时候,如果这个对象没有被复制到 to space,那么就复制过去。