Linux内存背后的那些神秘往事
发布时间:2022-02-16 11:21:17 所属栏目:Linux 来源:互联网
导读:前言大家好,我的朋友们! CPU、IO、磁盘、内存可以说是影响计算机性能关键因素,今天就聊探究下内存的那些事儿。 内存为进程的运行提供物理空间,同时作为快速CPU和慢速磁盘之间的适配器,可以说是个非常重要的角色。 通过本文你将了解到以下内容: 本文均
前言大家好,我的朋友们! CPU、IO、磁盘、内存可以说是影响计算机性能关键因素,今天就聊探究下内存的那些事儿。 内存为进程的运行提供物理空间,同时作为快速CPU和慢速磁盘之间的适配器,可以说是个非常重要的角色。 通过本文你将了解到以下内容: 本文均围绕Linux操作系统展开,话不多说,我们开始吧! 虚拟内存机制 当要学习一个新知识点时,比较好的过程是先理解出现这个技术点的背景原因,同期其他解决方案,新技术点解决了什么问题以及它存在哪些不足和改进之处,这样整个学习过程是闭环的。 内存为什么需要管理 老子的著名观点是无为而治,简单说就是不过多干预而充分依靠自觉就可以有条不紊地运作,理想是美好的,现实是残酷的。 Linux系统如果以一种原始简单的方式管理内存是存在一些问题: 进程空间隔离问题 假如现在有ABC三个进程运行在Linux的内存空间,设定OS给进程A分配的地址空间是0-20M,进程B地址空间30-80M,进程C地址空间90-120M。 虽然分配给每个进程的空间是无交集的,但是仍然无法避免进程在某些情况下出现访问异常的情况,如图: 比如进程A访问了属于进程B的空间,进程B访问了属于进程C的空间,甚至修改了空间的值,这样就会造成混乱和错误,实际中是不允许发生的。 所以我们需要的是每个进程有独立且隔离的安全空间。 内存效率低下问题 机器的内存是有限资源,而进程数量是动态且无法确定的,这样就会出现几个必须要考虑的问题: 如果已经启动的进程们占据了几乎所有内存空间,没有新内存可分配了,此时新进程将无法启动。 已经启动的进程有时候是在睡大觉,也就是给了内存也不用,占着茅坑不拉屎。 连续内存实在是很珍贵,大部分时候我们都无法给进程分配它想要的连续内存,离散化内存才是我们需要面对的现实。 定位调试和编译运行问题 由于程序运行时的位置是不确定的,我们在定位问题、调试代码、编译执行时都会存在很多问题。 我们希望每个进程有一致且完整的地址空间,同样的起始位置放置了堆、栈以及代码段等,从而简化编译和执行过程中的链接器、加载器的使用。 换句话说,如果所有进程的空间地址分配都是一样的,那么Linux在设计编译和调试工具时就非常简单了,否则每个进程都可能是定制化的。 综上,面对众多问题,我们需要一套内存管理机制。 中间层的引入 大家一定听过这句计算机谚语: Any problem in computer science can be solved by another layer of indirection. 计算机科学领域的任何问题都可以通过增加一个中间层来解决,解决内存问题也不例外。 Linux的虚拟内存机制简单来说就是在物理内存和进程之间请了个管家,内存管家上任之后做了以下几件事情: 给每个进程分配完全独立的虚拟空间,每个进程终于有只属于自己的活动场地了 进程使用的虚拟空间最终还要落到物理内存上,因此设置了一套完善的虚拟地址和物理地址的映射机制 引入缺页异常机制实现内存的惰性分配,啥时候用啥时候再给 引入swap机制把不活跃的数据换到磁盘上,让每块内存都用在刀刃上 引入OOM机制在内存紧张的情况下干掉那些内存杀手 ...... 虚拟内存下数据读写问题 引入虚拟机制后,进程在获取CPU资源读取数据时的流程也发生了一些变化。 CPU并不再直接和物理内存打交道,而是把地址转换的活外包给了MMU,MMU是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。 页表的存储和检索问题 每个进程都会有自己的页表Page Table,页表存储了进程中虚拟地址到物理地址的映射关系,所以就相当于一张地图,MMU收到CPU的虚拟地址之后开始查询页表,确定是否存在映射以及读写权限是否正常, 当机器的物理内存越来越大,页表这个地图也将非常大,于是问题出现了: 对于4GB的虚拟地址且大小为4KB页,一级页表将有2^20个表项,页表占有连续内存并且存储空间大 多级页表可以有效降低页表的存储空间以及内存连续性要求,但是多级页表同时也带来了查询效率问题 我们以2级页表为例,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。 MMU在2级页表的情况下进行了2次检索和1次读写,那么当页表变为N级时,就变成了N次检索+1次读写。 可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低,这个问题还需要优化才行。 本段小结 敲黑板 划重点 页表存在于进程的内存之中,MMU收到虚拟地址之后查询Page Table来获取物理地址。 单级页表对连续内存要求高,于是引入了多级页表。 多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。 MMU和TLB这对黄金搭档 CPU觉得MMU干活虽然卖力气,但是效率有点低,不太想继续外包给它了,这一下子把MMU急坏了。 MMU于是找来了一些精通统计的朋友,经过一番研究之后发现CPU用的数据经常是一小搓,但是每次MMU都还要重复之前的步骤来检索,害,就知道埋头干活了,也得讲究方式方法呀! 找到瓶颈之后,MMU引入了新武器,江湖人称快表的TLB,别看TLB容量小,但是正式上岗之后干活还真是不含糊。 当CPU给MMU传新虚拟地址之后,MMU先去问TLB那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。 TLB容量比较小,难免发生Cache Miss,这时候MMU还有保底的老武器页表 Page Table,在页表中找到之后MMU除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。 TLB容量不满的时候就直接把新记录存储了,当满了的时候就开启了淘汰大法把旧记录清除掉,来保存新记录,仿佛完美解决了问题。 本段小结 敲黑板 划重点 MMU也是个聪明的家伙,集成了TLB来存储CPU最近常用的页表项来加速寻址,TLB找不到再去全量页表寻址,可以认为TLB是MMU的缓存。 缺页异常来了 假如目标内存页在物理内存中没有对应的页帧或者存在但无对应权限,CPU 就无法获取数据,这种情况下CPU就会报告一个缺页错误。 由于CPU没有数据就无法进行计算,CPU罢工了用户进程也就出现了缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler 处理。 缺页中断会交给PageFaultHandler处理,其根据缺页中断的不同类型会进行不同的处理: Hard Page Fault 也被称为Major Page Fault,翻译为硬缺页错误/主要缺页错误,这时物理内存中没有对应的页帧,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立VA和PA的映射。 Soft Page Fault 也被称为Minor Page Fault,翻译为软缺页错误/次要缺页错误,这时物理内存中是存在对应页帧的,只不过可能是其他进程调入的,发出缺页异常的进程不知道而已,此时MMU只需要建立映射即可,无需从磁盘读取写入内存,一般出现在多进程共享内存区域。 Invalid Page Fault 翻译为无效缺页错误,比如进程访问的内存地址越界访问,又比如对空指针解引用内核就会报segment fault错误中断进程直接挂掉。 不同类型的Page Fault出现的原因也不一样,常见的几种原因包括: 非法操作访问越界 这种情况产生的影响也是最大的,也是Coredump的重要来源,比如空指针解引用或者权限问题等都会出现缺页错误。 使用malloc新申请内存 malloc机制是延时分配内存,当使用malloc申请内存时并未真实分配物理内存,等到真正开始使用malloc申请的物理内存时发现没有才会启动申请,期间就会出现Page Fault。 访问数据被swap换出 物理内存是有限资源,当运行很多进程时并不是每个进程都活跃,对此OS会启动内存页面置换将长时间未使用的物理内存页帧放到swap分区来腾空资源给其他进程,当存在于swap分区的页面被访问时就会触发Page Fault从而再置换回物理内存。 本段小结 敲黑板 划重点 缺页异常在虚拟机制下是必然会出现的,原因非常多,没什么大不了的,在缺页异常的配合下合法的内存访问才能得到响应。 我们基本弄清楚了为什么需要内存管理、虚拟内存机制主要做什么、虚拟机制下数据的读写流程等等。 内存分配 虚拟机制下每个进程都有独立的地址空间,并且地址空间被划分为了很多部分,如图为32位系统中虚拟地址空间分配: 64位系统也是类似的,只不过对应的空间都扩大为128TB。 来看看各个段各自特点和相互联系: text段包含了当前运行进程的二进制代码,所以又被称为代码段,在32位和64位系统中代码段的起始地址都是确定的,并且大小也是确定的。 data段存储已初始化的全局变量,和text段紧挨着,中间没有空隙,因此起始地址也是固定的,大小也是确定的。 bss段存储未初始化的全局变量,和data段紧挨着,中间没有空隙,因此起始地址也是固定的,大小也是确定的。 heap段和bss段并不是紧挨着的,中间会有一个随机的偏移量,heap段的起始地址也被称为start_brk,由于heap段是动态的,顶部位置称为program break brk。 在heap段上方是内存映射段,该段是mmap系统调用映射出来的,该段的大小也是不确定的,并且夹在heap段和stack段中间,该段的起始地址也是不确定的。 stack段算是用户空间地址最高的一部分了,它也并没有和内核地址空间紧挨着,中间有随机偏移量,同时一般stack段会设置最大值RLIMIT_STACK(比如8MB),在之下再加上一个随机偏移量就是内存映射段的起始地址了。 看到这里,大家可能晕了我们抓住几点: 进程虚拟空间的各个段,并非紧挨着,也就是有的段的起始地址并不确定,大小也并不确定 随机的地址是为了防止黑客的攻击,因为固定的地址被攻击难度低很多 我把heap段、stack段、mmap段再细化一张图: 从图上我们可以看到各个段的布局关系和随机偏移量的使用,多看几遍就清楚啦! 内存区域的组织 从前面可以看到进程虚拟空间就是一块块不同区域的集合,这些区域就是我们上面的段,每个区域在Linux系统中使用vm_area_struct这个数据结构来表示的。 内核为每个进程维护了一个单独的任务结构task_strcut,该结构中包含了进程运行时所需的全部信息,其中有一个内存管理(memory manage)相关的成员结构mm_struct: 复制 struct mm_struct *mm; struct mm_struct *active_mm; 结构mm_strcut的成员非常多,其中gpd和mmap是我们需要关注的: pgd指向第一级页表的基地址,是实现虚拟地址和物理地址的重要部分 mmap指向一个双向链表,链表节点是vm_area_struct结构体,vm_area_struct描述了虚拟空间中的一个区域 mm_rb指向一个红黑树的根结点,节点结构也是vm_area_struct 我们看下vm_area_struct的结构体定义,后面要用到,注意看哈: vm_area_start作为链表节点串联在一起,每个vm_area_struct表示一个虚拟内存区域,由其中的vm_start和vm_end指向了该区域的起始地址和结束地址,这样多个vm_area_struct就将进程的多个段组合在一起了。 我们同时注意到vm_area_struct的结构体定义中有rb_node的相关成员,不过有的版本内核是AVL-Tree,这样就和mm_struct对应起来了: 这样vm_area_struct通过双向链表和红黑树两种数据结构串联起来,实现了两种不同效率的查找,双向链表用于遍历vm_area_struct,红黑树用于快速查找符合条件的vm_area_struct。 内存分配器概述 有内存分配和回收的地方就可能有内存分配器。 以glibc为例,我们先捋一下: 在用户态层面,进程使用库函数malloc分配的是虚拟内存,并且系统是延迟分配物理内存的,由缺页中断来完成分配 在内核态层面,内核也需要物理内存,并且使用了另外一套不同于用户态的分配机制和系统调用函数 从而就引出了,今天的主线图 从图中我们来阐述几个重点: 伙伴系统和slab属于内核级别的内存分配器,同时为内核层面内存分配和用户侧面内存分配提供服务,算是终极boss的赶脚 内核有自己单独的内存分配函数kmalloc/vmalloc,和用户态的不一样,毕竟是中枢机构嘛 用户态的进程通过库函数malloc来玩转内存,malloc调用了brk/mmap这两个系统调用,最终触达到伙伴系统实现内存分配 内存分配器分为两大类:用户态和内核态,用户态分配和释放内存最终还是通过内核态来实现的,用户态分配器更加贴合进程需求,有种社区居委会的感觉 常见用户态内存分配器 进程的内存分配器工作于内核和用户程序之间,主要是为了实现用户态的内存管理。 分配器响应进程的内存分配请求,向操作系统申请内存,找到合适的内存后返回给用户程序,当进程非常多或者频繁内存分配释放时,每次都找内核老大哥要内存/归还内存,可以说十分麻烦。 总麻烦大哥,也不是个事儿,于是分配器决定自己搞管理! 分配器一般都会预先分配一块大于用户请求的内存,然后管理这块内存 进程释放的内存并不会立即返回给操作系统,分配器会管理这些释放掉的内存从而快速响应后续的请求 说到管理能力,每个人每个国家都有很大差别,分配器也不例外,要想管好这块内存也挺难的,场景很多要求很多,于是就出现了很多分配器: dlmalloc dlmalloc是一个著名的内存分配器,最早由Doug Lea在1980s年代编写,由于早期C库的内置分配器在某种程度上的缺陷,dlmalloc出现后立即获得了广泛应用,后面很多优秀分配器中都能看到dlmalloc的影子,可以说是鼻祖了。 http://gee.cs.oswego.edu/dl/html/malloc.html ptmalloc2 ptmalloc是在dlmalloc的基础上进行了多线程改造,认为是dlmalloc的扩展版本,它也是目前glibc中使用的默认分配器,不过后续各自都有不同的修改,因此ptmalloc2和glibc中默认分配器也并非完全一样。 tcmalloc tcmalloc 出身于 Google,全称是 thread-caching malloc,所以 tcmalloc 最大的特点是带有线程缓存,tcmalloc 非常出名,目前在 Chrome、Safari 等知名产品中都有所应有。 tcmalloc 为每个线程分配了一个局部缓存,对于小对象的分配,可以直接由线程局部缓存来完成,对于大对象的分配场景,tcmalloc 尝试采用自旋锁来减少多线程的锁竞争问题。 jemalloc jemalloc 是由 Jason Evans 在 FreeBSD 项目中引入的新一代内存分配器。 它是一个通用的 malloc 实现,侧重于减少内存碎片和提升高并发场景下内存的分配效率,其目标是能够替代 malloc。 jemalloc 应用十分广泛,在 Firefox、Redis、Rust、Netty 等出名的产品或者编程语言中都有大量使用。 具体细节可以参考 Jason Evans 发表的论文 《A Scalable Concurrent malloc Implementation for FreeBSD》 复制 论文链接:https://www.bsdcan.org/2006/papers/jemalloc.pdf 1. glibc malloc原理分析 我们在使用malloc进行内存分配,malloc只是glibc提供的库函数,它仍然会调用其他函数从而最终触达到物理内存,所以是个很长的链路。 我们先看下malloc的特点: malloc 申请分配指定size个字节的内存空间,返回类型是 void* 类型,但是此时的内存只是虚拟空间内的连续内存,无法保证物理内存连续 mallo并不关心进程用申请的内存来存储什么类型的数据,void*类型可以强制转换为任何其它类型的指针,从而做到通用性 复制 /* malloc example */ #include <stdio.h> #include <stdlib.h> int main () { int i,n; char * buffer; scanf ("%d", &i); buffer = (char*) malloc (i+1); if (buffer==NULL) exit (1); for (n=0; n<i; n++) buffer[n]=rand()%26+'a'; buffer[i]=' |