深入浅出linux内存管理(一)
前言
最近断断续续补充了一些linux内存管理的知识。包括之前看 nginx 源码,看 tcmalloc 原理也有一些心得。对于内存管理这个话题也有了一些浅薄的见解。现在针对 linux 下的内存管理这个话题做一个整理,整合一些目前学到的内存管理相关知识。
本文涉及操作系统层面的内存管理原理,同时也包括现在主流的内存管理方式,并结合一些优秀的开源项目来针对不同场景的内存管理去理解。在开始之前,我们需要先提出几个问题,以便更有深度的思考。
- 什么是内存管理? 为什么要进行内存管理?
- 当前有哪些主流的内存管理方式?
- 不同场景下应该如何做好内存管理?
- 如何衡量内存管理的效率?
现在,先回到初学者的状态,带着以上的问题一步步去解决这些疑问。
linux 虚拟内存系统
虚拟寻址
说到 linux 内存管理,必然要谈一谈 linux 的虚拟内存系统。我们先从操作系统最底层的内存组织原理开始,一步一步往应用层去理解。
首先,大家都是知道,整个计算机最基本的体系结构就是 CPU+内存。CPU负责计算,内存负责存储临时的计算数据。当然实际模型比这个要复杂的多。那么现在就涉及一个寻址的问题,也就是CPU需要获取内存的数据,如何进行内存寻址?
假设我们的内存被组织成一个字节数组,每个字节都有唯一的物理地址,按照这种方式,CPU访问内存最简单的就是使用物理寻址。
物理寻址有很多问题,一个是进程之间不隔离的问题,如果多个进程同时运行,怎么能保证进程A不越权访问进程B的内存呢?一个是内存划分的问题,没有一种有效的方式可以最大效率的满足的各个进程对内存的使用。当然物理寻址方式更加简单粗暴,现在一些嵌入式系统也采用物理寻址方式。这不是我们当前讨论范围,因为现代操作系统基本都采用另一种寻址方式——虚拟寻址。
由虚拟寻址又引申出另一个概念——虚拟内存空间。
由虚拟地址组织起来的虚拟内存是一个抽象概念,它使得每个进程都独占的使用整个主存。每个进程看到的内存都是一致的,这个进程的内存空间叫做虚拟地址空间。
这样说可能很不好理解。我们举个例子。
首先从CPU到磁盘有多级缓存,从上至下分别是寄存器,L1/L2/L3(SRAM) 高速缓存,DRAM,磁盘。
DRAM 呢就是我们最熟悉的内存。比如一台笔记本内存是16GB,DRAM就是16GB。这16GB是实实在在的物理内存大小。但是对于每个进程就不一样了。每个进程都有一个虚拟内存空间,这个虚拟内存空间的大小是多少呢? 对于32 位操作系统,是4GB。
为什么是4GB呢?
这个大小其实是机器的字长决定的。因为CPU和内存之间传送数据是通过系统总线实现的,系统总线每次传输单元是一个字。在32位系统上,1个字=32位=4个字节。1个字可以表示的最大范围是 0~2^32,我们用16进制来表示地址的话,1个字可以表示的最大地址就是 0xFFFFFFFF,也就是虚拟内存空间的寻址范围是 0~0xFFFFFFFF,也就是4GB。
那么对于一个32位操作系统,16G内存的计算机来说,每个进程的虚拟内存空间都是4GB大小,这些进程共同使用DRAM=16G的物理内存。当然对于64位操作系统,进程的虚拟内存空间就远远不止4GB了,而是 2^64 = 128TB 大小。这个暂时不予讨论,我们只关注32位系统下的。
现在既然每个进程都有4GB的虚拟空间,而实际的物理内存又只有16G,那就要考虑如何将这些进程的虚拟内存映射到物理内存上。
上文提到的虚拟寻址就是如何根据一个虚拟空间的地址,获取到实际的物理内存地址。
负责虚拟地址到物理地址的转换的是一个叫做MMU( memory manage unit , 虚拟内存管理单元) 的硬件。
每当CPU需要访问物理内存时,都会产生一个虚拟地址,这个虚拟地址被 MMU 翻译成物理内存地址。为了方便地址的管理和调用,linux 将内存分成页,一页的大小是4KB,不管是虚拟内存还是物理内存,管理和调度的单元都是以页为单位进行的,每一页已分配的虚拟内存都对应着一页物理内存。此时虚拟内存空间的页,我们叫做虚拟页 VP(Vistural Page), 物理内存的页叫做物理页 PP(Physical Page),物理页又叫页帧。
MMU 并不存储这个映射关系,这个映射关系存储在一个叫做页表(Page Table)的地方, 页表是一个结构为PTE(Page Table Entry) 的数组。每一个PTE都存储了一个虚拟页到物理页的映射。
接下来我们需要知道 PTE 的内容。根据虚拟页是否分配了物理页和是否缓存的角度来区分虚拟内存页的话,分为3种虚拟内存页:
- 未分配物理页
- 已分配物理页,且已缓存。
- 已分配物理页,但未缓存。
为了区分上述3种情况,在一个PTE中有一个有效标志位和一个指向物理内存的地址。其中有效标志位,用于表明是否缓存在DRAM中。
如果地址为空,表示没有为虚拟页分配对应的物理页。
如果有效位为1,则表示物理内存页已分配且已缓存,此时地址指向DRAM中物理内存页的地址。
如果有效位为0,则表示物理内存页已分配但缓存未命中。
如图所示是8个虚拟页和4个物理页。其中4个虚拟页VP1,VP2,VP7,VP4都被缓存在DRAM中,其地址都不为空,且有效位为1。VP0和VP5 的地址是空的,表示还没有分配,VP3,VP6虽然分配了地址,但是有效位为0,表示没有被缓存。
上述页表一般是缓存在L1 cache 中的,而MMU到 L1 cache 所需的指令周期也很长,所以MMU自己也做了一个小缓存,叫做翻译后备缓冲器TLB((translation Lookaside buffer)。当MMU需要将虚拟内存地址转换为DRAM中的内存地址时,此时先查TLB,如果缓存命中直接就得到了DRAM中的地址,否则就需要到页表中去查。
查询页表,找到当前虚拟页对应的PTE,然后根据PTE的有效标志位判断DRAM中是否有缓存,
如果有缓存则直接根据地址去DRAM中获取数据。如果DRAM缓存不命中,此时将触发一个缺页异常。
缺页异常将程序的控制权转移给一个缺页异常程序,缺页异常程序从DRAM中选择一个牺牲页(如果牺牲页被修改过,会将牺牲页回写入磁盘),同时将所需页面从磁盘复制到DRAM中,替换掉牺牲页。
当然上述换页之后,PTE中的有效标志位也会随之更新。
这种设计方式会导致程序的性能降低吗?可能会有人疑问,每个进程都拥有4GB的虚拟内存,但是实际的物理内存却只有16GB,也就是 DRAM中缓存的物理页是远远小于进程中使用的虚拟页的,会不会频繁的出现缺页异常。实际上,由于程序的局部性原理,进程总是趋向于在一个较小的活动页面集合上工作,这个工作集合或者说常驻集合,在第一次访问的时候被 cache 到 DRAM 中之后,后续都会命中缓存。只有当的这个常驻集合大于物理内存的时候,才会产生不断的换页,此时就是内存不够用了。需要优化程序的性能。
接下来我们继续讨论 PTE,实际上PTE不仅仅只有一个有效标志位。还记得之前程序隔离的问题吗?如何保证进程A不会访问进程B的虚拟页,如何保证用户态不会访问内核态的虚拟页?答案还是在 PTE里。
比如上图中PTE添加了3个许可位。SUP表示进程是否必须运行在内核模式才能访问该页。SUP为1 的页在用户态是无法访问的。同时还有读权限和写权限。当某个指令进行越权访问时,CPU就会触发一个段错误。
一般来说,PTE可以实现以下的功能:
- 每个进程的代码段所在页是不可修改的
- 内核的代码和数据结构所在页也是不可修改的
- 进程不能读写其他进程的私有内存页
- 进程间通信可以通过设置进程间共享页来实现。即允许多个进程对某一页进行读写。
多级页表
说完了PTE,接下来再看看页表。我们先算一算对于32位系统,页表有多大,一页4KB,虚拟内存空间是4GB,也就是有4GB/4KB = 10^6 个页,假设一个 PTE 大小是4个字节,页表的大小也达到了 4GB/4KB* 4Byte = 4MB。由于页表是缓存在 L1 里的。4MB可不是个小数目。
更麻烦的是,如果是64 位系统,虚拟内存空间是128 TB 。页表的大小将指数增长。
页表多了也会非常浪费资源,而实际上一个进程虽然有4GB的虚拟内存空间,但大部分进程都不会用满4GB,这就导致很多页表项都是空的。为了压缩页表,就需要使用多级页表,将页按层次的组织。
以2级页表为例,2级页表每1024页为一个单位,由最上层的1级页表索引,1级页表的每个PTE将不再代表1页,而是1024页,也就是4MB。
如果1级页表的PTE为空,表示接下来的4MB内存都是空的,只有PTE中任意一页不为空的时候,才会指向2级页表。
这种组织方式就类似一颗树或一个跳表结构一样,基于底层的4KB页建立多级索引。
内存映射
讲完了虚拟寻址的过程,接下来我们需要了解进程是如何和虚拟内存空间相关联的。当然,原理上是通过页的方式,但是我们需要更进一步的了解这个关联过程。
首先祭出一张大家都非常熟悉的图,就是32位系统下的进程空间分布。
最上面的1GB是内核内存空间,用户态无法访问。
内核空间向下随机偏移一个值就是栈空间的起始地址。栈是向下生长的,栈空间最大是8M。
然后再偏移一个随机值,就是共享内存映射区域的起始地址。同时堆空间向上增长,与共享内存区域相对增长直到耗尽所有可用的区域。
随机偏移是为了防止缓冲区溢出的攻击,毕竟如果每次栈和堆和 mmap 的起始地址都固定的话,非常容易受到攻击。
接着在代码层面,linux 为每个进程都对应了一个结构体 task_struct。
可以看到 mmp 指向一个 vm_area_struct 的链表,每个 vm_area_struct 都描述了进程空间中的一个区域。一个具体的区域包括以下字段:
- vm_start :起始地址。
- vm_end : 结束地址。
- vm_prot : 该区域所在页的读写权限。
- vm_flags : 是共享的还是私有的。
- vm_next : 下一个区域结构。
有了区域的概念,我们再仔细回顾一下缺页异常的处理:
当 MMU 试图寻址一个虚拟地址A时,发现不在 DRAM中,于是触发一个缺页异常。接着缺页异常处理程序将执行以下步骤:
- A 是否合法?也就是在不在已有的区域内?此时处理程序需要遍历整个区域链表,看 A 是不是在 vm_start ~ vm_end 中。如果 A 不合法,此时就会触发一个段错误(Segment Fault)。进程退出。当然实际linux为了提高查找效率额外用一颗树来组织这些区域。这点暂时不表。
- 当A 的地址合法之后,接下来判断访问是否合法,也就是是否有读写的权限?有些区域只有只读权限,有些区域只能由内核访问,任何越权访问的行为会触发一个保护异常。进程终止。
- 当地址合法,访问也合法之后,就会向之前提到的那样,从 DRAM 中找一个牺牲页,然后淘汰,换上新的页。
以下就是整个缺页异常的处理过程。
有了区域的概念,现在再说下内存映射。内存映射指的是 linux 可以将进程中的区域和一个磁盘对象关联起来,来初始化这个区域的内容。
这个关联的对象分为2种:
- linux 系统中普通文件。
- 匿名文件。这种情况也叫请求二进制0的页。
当程序启动,可执行文件被加载到内存中,就是第一种情况,而我们在堆上或共享内存区域去申请一块可用内存,就属于第二种情况。
根据映射对象的访问性质呢,又可以分为2种类型——私有对象和共享对象。
比如很多进程会共享相同的内核代码,这一部分公共代码映射的就是共享对象。
内存映射有以下几个好处:
- 共享对象的映射减少了浪费,被共享的对象避免了在每个进程间都拷贝一份副本。
- 共享对象可以实现进程间的通信。通过多个进程同时读写同一个虚拟内存区域,可以进行通信。
下图展示了一个进程被加载到内存中的时候,不同区域与私有对象和共享对象的映射关系。
讲完了进程地址空间的映射关系,接下来说一说与实际开发相关的内存管理API。
写过C的都知道,在C里面进行内存的分配和释放是用的标准库的API malloc() / free()
malloc/free
实际上并不是系统调用,而是linux系统库 glibc 标准库函数。linux 提供的真正的申请内存的函数是 brk()/mmap()
。
#include <unistd.h>
int brk( const void *addr);
上述两个函数的作用都是扩展 heap 的上界 brk
brk()的参数设置为新的 brk 上界地址,成功返回1,失败返回 0;
当然,在标准库里还有一个
void* sbrk ( intptr_t incr );
sbrk()的参数为申请内存的大小,返回heap新的上界brk的地址。
brk 函数是用于 heap 区域内存的申请。共享内存区域是通过另一个函数来分配的:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
mmap分为2种用法:
一种是映射此盘文件到内存中,动态链接库就是用这种方式加载的。比如 dlopen() 等等。
一种是匿名映射,向映射区申请一块内存。 比如 malloc 。
这里使用 mmap 的内存映射和上述的内存映射是一样的,只是一个是用户级的内存映射和内核级的内存映射。