OOM 往往是我们经常遇到的 “严重” 问题之一,那内存究竟是如何被合理分配和使用的呢?
本期大纲
你的电脑上或许此时插着一根 8G 的内存条,你经常在使用它,但你有没有想过操作系统是如何管理内存的?如果让你来分配使用,你是否会想着:给正在运行的游戏分配其中的 4G,给我的视频软件分配 2G,给音乐软件分配 1G,分配各自独立,互不干扰。但当我的游戏需要更多的内存的时候,是否我的视频就无法播放了呢?
那么对于操作系统来说,如何合理的分配和管理好内存就是我们今天要解决的问题。
首先要引出一个概念:虚拟地址。我们将实际在内存条上存储的地址称为:物理地址。所谓虚拟地址,就是我们人为创建的一个地址,对于进程来说,只能看见虚拟地址。
就像前言中所描述的,如果我们直接都使用物理地址会出现什么问题呢?
虚拟地址只是一个虚拟的东西,最终我们的数据还是存放在物理地址上。那么势必需要建立虚拟地址和物理地址映射的桥梁。那么他们之间如何关联呢?
CPU
中的 MMU
就是做这件事的。MMU 叫做内存管理单元,给他虚拟地址,他就能帮你转换为实际的物理地址。那 MMU 究竟是如何做的呢?
你是不是想的非常简单,只需要一个 map,key 是虚拟地址,value 是物理地址,这样不就好了吗?那光是存放这个 map 就花费太多存储空间了。所以我们需要一个合理的数据结构来存放这样的映射关系。
分段存储,顾名思义,就是将内存分为一段一段,如:代码段,数据段等等。访问时需要有两个东西:段号、段内地址(段内偏移)。寻址时,通过段号定位到你属于哪一个段,然后通过段内地址,找到在段内的那个部分。
分段机制下,不仅仅虚拟地址会被划分成一段段,实际的物理地址也会被划分成大小不一的段,导致很多内存碎片,就是段与段之间无法利用的空间。
为了解决分段机制内存碎片多的问题,于是分页机制就来了。
其实分页机制原理和分段差不多,但是,分页机制的核心点是:将内存分为相同大小的页。
如果有一块蛋糕,让你去分配:有的人要大块,有的人要小块,分到最后你就会发现,有很多小的剩下来了,此时有人问你要一整块大的,那你就分不出来了;如果把蛋糕分成均等的很小份,那么要多的人呢,我将多块一起给你,要少的人呢,即使你用不到最小份,也给你一份,减少了最后剩下来的问题。
分页机制也类似,有着一张页表:通过页号、页内偏移量,来对应最终的物理地址。
但是这样也会带来新的问题,原来我们使用分段的时候,很大的空间算一段,而我们使用分页的时候往往划分就比较小,一般是 4k 一页,假设每个实际物理地址要 8 字节去表示,那么一个 64 位的地址空间需要多少页表才能记录呢?33554432GB … 显然不会这样去记录,而是通过多级页表来实现。
多级页表的设计有点像一棵查找的分段树:首先通过 0 级页表找到你在 1 级页表的位置,然后在 1 级页表中找到你在 2 级页表中的位置… 以此类推,最终在 3 级页表中找到最终的实际物理地址。
你想,原来如果只有一个表,那么查询的速度肯定很快,找到 key 就能找到对应的 value,但是有了多级页表之后,那么查询的速度自然就受到影响了。所以为了解决查询时间长的问题,那么第一想到的就是加缓存。
TLB(Translation Lookaside Buffer) 转址旁路缓存,就是做这个的。当 MMU 需要将一个虚拟地址转换为一个物理地址的时候,就会先问 TLB,如果 TLB 知道那么就直接返回,不需要重新进行页表的查询。
根据时间局部性原理,最近访问的内存,那么在未来短时间内被访问多次的可能性较大,故 TLB 其实命中的可能性就很大
但是 我们也要意识到一个问题,不同的进程之间,相同的虚拟地址映射的物理内存是不同的,所以在进程的切换时,会导致 TLB 所有的缓存内容失效,需要刷新 TLB,故进程的切换成本是比较高的。
fock
系统调用创建子进程的时候,如果每次都需要将 task_struct
里面的所有内容都复制一次,那肯定影响性能,于是在共享内存的基础上,有了 COW 机制。当没有修改的情况下,父子进程看到的是相同的内存;当出现修改的时候才进行复制操作。实现方式是,一开始只有只读权限,当修改时会触发缺页异常(违反权限)。首先有了虚拟地址,全部的地方也并非都是你的,你至少要留一部分给内核。所以一部分为内核空间,一部分为用户空间。32位和64位也不一样,如下图所示:
然后用户空间继续细分是 Text Segment
,Data Segment
,BSS Segment
。
首先,我们进程申明了我们要用那么内存,而实际情况往往是用着用着才用到的。那么其实页表中,虚拟地址其实并不一定有相应的物理页的映射,那怎么办呢?
当发现虚拟页未映射到实际的物理页时,就会发送缺页异常,调用缺页异常处理函数,它就会去找到一个空闲的物理页,将这个新的物理页作为这个虚拟地址的映射,称为 swap in。
当我们用着用着发现物理也不够我们使用了的时候,操作系统就要出马了,将一些暂时用不到的内存写入磁盘,然后将这个物理页回收,称为 swap out,这样就能供给给其他进程使用了。
那么哪些是暂时用不到的内存呢?这肯定就需要一个策略去筛选这些不需要的内存了,这其实与缓存淘汰的策略类似:
内存的分配有很多的方法和实现,我这里以 malloc
和 TCMalloc
为例。
malloc 申请虚拟内存方式为 (注: glibc版本不同实现不一致):
brk
系统调用mmap
系统调用
当然 malloc 也有优化,因为如果每次都进行系统调用那势必来回在内核态和用户态切换影响性能,同时如果堆从下向上增长的时候下方的内存没有被释放,高地址不能被回收,会产生内存碎片。所以 malloc 先申请一块大内存到自己的内存池,然后每次从内存池中进行返回和处理,相当于加了一层缓存。当 free 释放内存的时候不会马上还给操作系统,而是先到内存池中,以便下次继续使用。
tcmalloc 是 google 开发的内存分配器,据说它的内存分配速度是 glibc2.3 中实现的 malloc 的数倍。
前面的 TC 两个字母意思是:Thread Cache
,也就是每个线程各自独立的 cache。它设定了一系列的概念:
由于 tcmalloc 的细节非常多,内部结构也较为复杂,这里并不详细讨论,给出简单的分配流程:
总的来说就三点:一个是按需分配,根据不同大小的对象来合理分配,一个是针对小对象进行缓存,并且设了一个三级缓存有 ThreadCache 还有 CentralCache,还有一个是针对大对象的特殊处理。
在物理内存使用的时候,我们很多时候最关注的一点就是碎片的问题,内存碎片越少,内存资源的利用率也就越高。
于是伙伴系统就被设计了出来,它的基本思想很简单:
伙伴系统的设计思想其实和二进制一样,一个十进制转换为二进制之后就类似这里的使用 2 的 n 次幂来分配类似。
但是,如果只有伙伴系统也还有问题,内核常常需要分配的内存大小往往是几十个字节,远小于一个物理页,那每次都分一页也太浪费了。所以就有了 SLAB 分配器,专门处理这种小内存的分配工作。
SLAB 简单来说就是做了一层缓存工作,缓存大量常用的已经初始化的对象,每次申请这类对象时,就从缓存中分配出去,当要释放回收时,也不会直接返回伙伴系统,而是返回缓存中。
从操作系统内存的学习我们其实能够学到很重要的几个设计理念:
tcmalloc
还是 伙伴系统
,其思想都类似,先分成最小单位,然后根据 2 的指数次进行组合,组合出各种各种可能来减少碎片,这里有着一些二进制的思想。TLB
帮 MMU
缓存,还是 Thread Cache
缓存,都有缓存的身影。总之,针对操作系统的内存,我们要知道它这样设计的目的,还需要知道它其中做了哪些优化,这些优化是为了什么,这些思想在我们以后的开发过程中是否有可以借鉴的地方。