1 前言内存管理是Linux内核中非常重要的部分,今天和大家一起学习一下。
当我们要学习一个新知识点时,比较好的过程是先理解出现这个技术点的 背景原因,同期其他解决方案,新技术点解决了什么问题以及它存在哪些不足和改进之处,这样整个学习过程是 闭环 的,个人觉得这是个很好的学习思路。
凡事都是相通的,计算机学科的一些问题在现实生活中都可以找到原型,所以我觉得计算机科学家大部分都是善于观察生活并总结归纳的。人类社会就是一台复杂的机器,其中充满了机制和规则,所以有时候跳进代码海洋不如先回到生活之中,寻找原型再探究代码,可能理解会更深刻。
2 为什么需要管理内存老子的著名观点是无为而治,简单说就是不过多干预而充分依靠自觉就可以有条不紊地运作,理想是美好的,现实是残酷的。
在linux系统中如果以一种原始简单的方式管理内存是存在一些问题的,我们来看几个场景。
2.1 内存管理的问题
进程空间隔离问题
假如现在有ABC三个进程运行在linux的内存空间,设定os给进程A分配的地址空间是0-20M 进程B地址空间30-80M,进程C地址空间90-120M,如图:
在某些时候程序空间的访问可能出现问题,比如进程A访问了属于进程B的空间,进程B访问了属于进程C的空间,甚至修改了空间的值,这样就会造成混乱和错误,所以实际中是不允许这种情况发生的。
内存效率和内存不足问题
机器的内存是有限资源,而进程数量是无法确定的,如果在某些时候已经启动的进程占据了所有内存空间,此时就无法启动新进程了,因为没有新内存可分配了,但是我们观察到已经启动的进程有时候是在睡大觉,也就是给了内存也不用,这样效率确实是有点低,所以我们需要一个管理员把不用的内存倒腾出来,另外连续内存实在是很珍贵,很多时候我们没法有效及时地分配连续内存,因此虚拟化和离散化可能会有效提高内存的使用率。
程序定位调试和编译运行问题
由于程序运行时的位置时不确定的,我们在定位问题、调试代码、编译执行时都会存在很多问题,我们希望每个进程有一致且完整的地址空间,同样的起始位置放置了堆、栈以及代码段等,从而简化编译和执行过程中的 linker 链接器、loader 加载器的使用。
2.2 虚拟地址空间
为了解决上述的一些问题,linux系统引入了虚拟空间的概念,虚拟化的出现和硬件有密不可分的联系,可以说是软硬件组合的结果,虚拟地址空间就是在程序和物理空间所增加的中间层,这也是内存管理的重点。
磁盘 disk 作为一种大容量的存储也作为“内存”的一部分参与程序的运行,内存管理系统会将不常用非活跃内存进行页面换出,可以认为内存是磁盘的缓存,内存中保留了活跃的数据,从而间接扩展了有限的物理内存空间,这部分空间称为虚拟内存是相对于物理内存而言的。
3.段页管理机制本文并不深入地将分段管理内存和分页管理内存,因为将这些细节的优秀文章很多,感兴趣的使用搜索引擎一键即达。
段页机制也不是一蹴而就的,经历了单纯物理分段、单纯分页、单纯逻辑分段等阶段,最终演进出来了分段和分页结合的内存管理方式,段页结合获得了分段和分页的优势也避免了单一模式的弊端,是一种比较好的管理模式。
本文对于段页管理机制只想通俗地说明一些概念,段页管理机制是分段式管理和分页式管理的组合,段式管理是逻辑上的管理方式,分页管理是偏物理上的管理方式。
计算机里面的一些技术和实现都可以在现实生活中找到缩影,所谓艺术和科技源自生活大概就是这个意思吧。
举个栗子:
在进行居民户籍管理时都会有区县市的概念,但是实际上并没有这种实体,都是逻辑上的,增加了这些行政单位之后可以让地址管理更加直接。
对于我们居民来说唯一的实体就是自己的房子住所,这是物理上的单位,是真实存在的,这也是最基本的单位。
对比linux段页时管理来说,段是逻辑上的单位相当于区县市的概念,页是物理上的单位相当于小区/房屋的概念,这样就方便很多。
多级页表也很好理解,总的物理内存假如有4GB,页大小为4KB,那么就总共有2^20个页,数量还是非常大的,这样编号来建立索引寻址比较不方便,所以引入多级页表,来减少存储便于管理。
段页机制加持下的逻辑地址和物理地址的映射关系简图,也就是虚拟地址到物理地址的对应关系:
内存管理单元( MMU Memory Management Unit )是硬件层组件,主要提供将虚拟地址映射为物理地址。
MMU 的工作流程:CPU 生成逻辑地址交给分段单元,分段单元进行处理将逻辑地址转换为线性地址,再线性地址交给分页单元,分页单元根据页表映射转换内存物理地址,其中可能出现缺页中断。
缺页中断( Page Fault )是只当软件试图访问一个虚拟地址时,经过段页转换为物理地址之后,此时发现该页并没有在内存中,这时 cpu 就会报出中断,再进行相关虚拟内存的调入工作或者分配工作,如果出现异常也可能直接中断。
4.物理内存和内存碎片前面说的段页管理机制算是虚拟空间的部分,然而linux内存管理的另外一个重要部分就是物理内存的管理了,也就是如何分配和回收物理内存,这就涉及到一些内存分配算法和分配器。
4.1 物理内存分配器
分配器和分配算法就像公司财务,内存就像公司资金,如何把资金合理使用是财务的本职工作,如何把物理内存合理使用是分配器的分内之事。
4.2 内存碎片分类和机理
如果我们不知道内存碎片是什么,试想一下我们常说的碎片化的时间,也就是那些虽然空闲但是没有被利用的时间,其实内存也是如此。
无论是时间还是内存被碎片化之后都无法被有效利用,因此合理管理减少碎片对我们来说是至关重要的,这也是物理内存分配算法和分配器的研究重点。
按照碎片的位置和产生原因,内存碎片分为外部碎片和内部碎片,我们看下这两种碎片的直观展示:
从图中可以知道,外部碎片是进程与进程间未分配的内存空间,外部碎片的出现和进程频繁的分配和释放内存有直接关系,这个很好理解,模拟一下分配不同空间的进程不同时间释放就可以看到外部碎片的产生了。
内部碎片主要因为分配器粒度问题以及一些地址限制导致实际分配的内存大于所需内存,这样在进程内部就会出现内存空洞。
虽然虚拟地址让进程使用的内存在物理内存上是离散的,但是很多时候进程需要一定量连续物理内存,如果大量碎片存在,就会造成无法启动进程的问题,如图Process7需要一块连续的物理内存却无法被分配:
5. 伙伴系统算法基本原理5.1 一些准备知识
物理页框
linux将物理内存按照页来划分,内存页的大小在不同的软硬件中可能不一样,linux内核设置为4KB,有的内核可能更大也可能更小,当时不同的大小在实际中都是有考量的,就像面包一样有大有小,并不是整齐划一的。
页框记录结构
在内核中为了建立对物理内存页page的使用情况的监控,会有struct page这样的数据结构来记录页的位置地址/使用情况等,相当于内核对内存页管理的一本账目。
延时分配和实时分配
linux系统有内核态和用户态之分,内核态申请内存就立刻满足并且认为这个请求一定是合理的。然而用户态申请内存的请求,总是尽量延后分配物理内存,所以用户态进程是先获得一个虚拟内存区,在运行时通过缺页异常获得一块真正的物理内存,我们执行 malloc 时获取的只是虚拟内存而已,并不是真实的物理内存,也是这个原因造成的。
5.2 伙伴系统简介
第一次听到这个算法名称就很好奇为什么叫伙伴系统?让我们来一起揭秘。
伙伴系统要解决什么问题
伙伴系统算法是解决外部碎片的有力工具,简单来说它针对频繁请求和释放不同大小的一组连续页框的场景,建立一套管理机制来高效的分配和回收资源,降低外部碎片。
解决外部碎片的思路
第一种思路:把已经存在的外部碎片通过新的技术把这些非连续的空闲内存映射到连续的线性空间,其实相当于没有去降低外部碎片的产生而是治理型方案,但是这种方案在真实需要连续物理内存时是无效的。
第二种思路:把这些小的空闲的不连续内存记录在案,如果有新的分配需求就从中搜索合适的将空闲内存分配出去,这样就避免了在新的区域进行分配内存,有种变废为宝的感觉,其实这样场景也很熟悉当你想吃一包饼干时,你妈妈肯定会说先把之前剩下一半没吃完的吃掉,不要先开新的了。
基于一些其他方面的考量,linux内核选择了第二种思路来解决外部碎片。
伙伴内存块的定义
在伙伴系统中把大小相同且物理地址连续的两块内存区域称为伙伴,连续地址的要求其实是比较苛刻了,但是这也是算法的关键,因为这样的两块内存区域可以合并成一块更大的区域。
伙伴系统的核心思想
伙伴系统将不同大小的连续物理页框进行管理,在申请时从最接近的页框大小进行分配,剩余的进行新的拆解,并将有伙伴关系的内存会进行合并成为大的页框。
5.3 伙伴系统的基本过程
伙伴系统维护了 n=0~10 共 11 个块链表,每个块链表分别包含了大小为 2^n 个连续的物理页。当 n=10 时即 1024 个 4KB 页对应 4MB 大小的连续物理内存块,这里的 n称为 order,在伙伴系统中 order为0~10,也就是最小的是 4KB,最大的内存块是4MB,这些相同大小的物理块组成双向链表进行管理,如图展示了 order=0 和 order=2 的两个双向链表的情况:
申请内存过程:假设请求一个页框块,伙伴系统算法先在 order=0 的链表中检查是否有空闲块可分配。如果没有则查找下一个更大的块,在 order=1 的链表中找一个空闲块,链表中存在就把2个页框拆分,1个页框分配出去1 个页框加入到 order=0的链表中。如果 order=1 的链表中仍未找到空闲块,就继续向更大的order搜索,如果找到进行拆分处理,如果最终至 order=10 的链表也没有空闲块,则算法报错。
合并内存过程:合并内存的过程是伙伴算法中伙伴块的体现,算法把两个块具有相同大小 A且它们物理地址连续的内存合并为一个大小为 2A 的单独块。伙伴算法是自底向上迭代合并的,其实这个过程和 leveldb 中 sst 的合并过程很相似,区别在于伙伴算法要求内存块是连续的,这个过程也体现了伙伴系统对于大块内存的友好。
5.4 伙伴系统的优势和不足
伙伴系统算法较好地解决了外部碎片问题,并且对于大内存块的分配比较友好小粒度的内存可能造成内部碎片,但是伙伴系统对于伙伴块的定义很苛刻,并且在合并伙伴块的过程涉及较多的链表操作,对于一些频繁的申请可能刚合并就会被拆分掉,这就做了无用功,所以伙伴系统还是存在一些问题的。
6. Slab分配器从伙伴系统的介绍可以知道其分配的最小单位是 4KB 的页框,这对于一些频繁申请的小到几十字节的内存来说还是十分浪费的,所以我们需要更细粒度的分配器,这就是slab分配器。
slab分配器并不是和伙伴系统分立的,而是建立在伙伴系统之上,可以看作是伙伴系统的二级分销商,更加靠近用户侧,但是slab分配器因为更靠近使用方,因此在结构实现上比伙伴系统更加复杂,本文只能简单概括。
个人感觉slab分配器的亮点包括:最小粒度为对象和内存惰性归还。
Linux 所使用的 slab 分配器的基础是 Jeff Bonwick 为 SunOS 操作系统首次引入的一种算法。Jeff 的分配器是围绕对象缓存进行的。在内核中,会为有限的对象集(例如文件描述符和其他常见结构)分配大量内存。Jeff 发现对内核中普通对象进行初始化所需的时间超过了对其进行分配和释放所需的时间。因此他的结论是不应该将内存释放回一个全局的内存池,而是将内存保持为针对特定初始化的状态。
from 《linux slab 分配器剖析》
slab采用对象作为最小单位的理论基础就在于初始化一个结构的时间可能超过了分配和释放的时间。
slab分配器可以看作是一种内存预分配机制,就像超市会把常用的物品放到大家更容易找到的位置,事先把这些对象准备好申请时就可以立刻分配出去了。
slabs_full:链表中slab已经完全分配出去
slabs_partial:链表中的slab部分已经被分配出去了
slabs_empty: 链表中的slab都是空闲的 也就是可以被回收
对象是从 slab 中进行分配和释放的,每个kmem_cache的slab列表是存在状态迁移的,但是被回收的部分slab并不会立刻归还给伙伴系统,并且在分配时会优先分配最近被释放的对象,目的是利用cpu缓存的局部性原理,可以看出来slab分配器的细节做的很足,但是为了实现这一套复杂的逻辑,就要维护多个队列会比伙伴系统更复杂。