Linux 之 VM

2014 年 6 月 15 日

虚拟存储

虚拟存储(virtual memory, VM)的基本思想是: 维护一个虚拟的逻辑内存机制(通常比物理内存大得多), 进程都基于这个虚拟内存, 在进程运行时动态的将虚拟内存地址映射到实际的物理内存.

VM的设计体现了软件工程思想: 封装, 抽象, 依赖倒置, 非常棒. 每个运行中的进程无需再去关心实际物理内存是多大, 分配内存会不会超出限制, 哪些内存已经被其他进程占用等等, 这些都交由kernel的内存管理单元来解决, 暴露给进程的"接口"只有每个进程独有的虚拟地址空间.

如上图所示: 程序中产生的内存地址成为虚拟地址(virtual address), 又称为逻辑地址(logic address), 逻辑地址被送到内存管理单元(memory manager unit, MMU), 映射成物理内存地址之后, 再送到内存总线上.

分页

MMU的主要职责就是将逻辑地址, 转成物理地址. 以32位Linux为例, MMU可以当成一个数学函数: f(x) = y, 输入x是一个0-4G范围内的逻辑地址, 输出y是实际的物理地址.

最简单暴力的方法, 莫过于直接建一层映射关系, 不过这样映射表就得4G大小了……

因为实际上我们并不需要把所有的映射关系都建立起来, 而只需要为用到的内存做映射, 所以, 前辈们用了分页的方法来解决这个问题(实际是一个多阶哈希).

一个典型的二级页表来处理分页: 32位的逻辑地址被分成了3段, 10位的一级页表索引(page table 1 index), 10位的二级页表索引(page table 2 index)和剩下的12位页面偏移量(page offset). 所谓页面(page), 是现在大部分MMU中用来管理内存的单位, Linux下常见的page大小是4k(12位的偏移量刚好是一个page, 即4k).

对于一个进程而言, 它需要用到的页表: 一级页表,以及部分用到的二级页表(不需要全部的). 以一个占用16M内存地址空间的进程为例, 理论上它只需要1个一级页表, 和4个二级页表, 页表开销即 5 * 4k = 20k, 能节省大量的页表开销.

在多级页表中, 页表分级越多, 越灵活, 但是带来的时间成本也就越高, 复杂度也越高. 二级, 或者三级页表是一个比较合理的选择. 为了兼容不同的CPU, Linux 2.6.11 之后使用了四级分页机制, 在不同的CPU环境下可以灵活扩展成二级或者三级.

逻辑地址映射成物理地址的过程是通过MMU硬件来完成的. 除此之外, 还有一个TLB的硬件, translation lookaside buffer, 即页表缓冲, 它是一块高速cache, 通过CR3寄存器来刷新, 能加速虚拟内存寻址的过程.

页面置换

进程中用到的代码段, 数据段和堆栈的总大小可能超过可用的物理内存总数, VM提供了一种机制来解决这个问题: 把当前使用的那一部分放到内存中, 其他部分保存在磁盘上, 并在需要时在磁盘和内存中做交换. 这就是页面置换.

当一个逻辑地址, 经过MMU映射后发现, 对应的页表项还没有映射到物理内存, 就会触发缺页错误(page fault): CPU 需要陷入 kernel, 找到一个可用的物理内存页面, 从页表项映射过去. 如果这个时候没有空闲的物理内存页面, 就需要做页面置换了, 操作系统通过某些算法, 从物理内存中选一个当前在用的页面, (是否需要写到磁盘, 取决于有没有被修改过), 重新调入, 建立页表项到之的映射关系.

分段

分段的思想, 说穿了就是把内存分成若干段, 每个段是一个单独的地址空间, 有自己的起始的基地址, 根据 基地址+偏移量 来做寻址.

分段的好处是带来了比较大的灵活性, 也更安全. 每个段都构成了自己的独立地址空间, 增大或者减小而不会影响其他段. 还可以对每个段设置不同的保护级别.

Linux下采用的是段页式内存管理, 先分段, 再分页. 但是因为Linux中所有的段基址都设置成了0, 段偏移量相当于就是线性地址, 只用了一个地址空间, 效果上就是正常的分页. 这么做的原因是为了兼容各种硬件体系.

虽然Linux下, "分段"只是一个摆设, 但是在进程的内存管理中, 还是应用了分段的思想的: 每一个进程在运行时, 它的逻辑地址空间都会被分为代码段, 数据段, 堆, 栈等, 当访问段之外的内存地址时, kernel 能监测到并给出段错误(segment fault). 具体细节可以参考这一篇译文: 《进程的内存剖析》}

VM管理

借鉴一张来自《Tuning Red Hat Enterprise Linux on IBM @server xSeries Servers》}的架构图. 这张图描述了Linux VM管理器:

  1. Linux kernel 主要提供了两种内存分配算法: buddy 和 slab, 结合使用。buddy 提供了2的幂大小内存块的分配方法,具有数组特性,简单高效, 但是缺点在于内存碎片。slab 提供了小对象的内存分配方法, 实际上是一个多级缓存列表, 最小的分配单位称为一个slab(一个或者多个连续页), 被分配为多个对象来使用.

  2. kswapd 是一个 daemon 进程, 对系统内存做定时检查, 一般是1秒一次. 如果发现没有足够的空闲页面, 就做页回收(page reclaiming), 将不再使用的页面换出. 如果要换出的页面脏了, 往往还需要写回到磁盘或者swap.

  3. bdflush 也是 daemon 进程, 周期性的检查脏缓冲(磁盘cache), 并写回磁盘. 不过在 Linux 2.6 之后, pdflush 取代了 bdflush, 前者的优势在于: 可以开多个线程, 而 bdflush 只能是单线程, 这就保证了不会在回写繁忙时阻塞; 另外, bdflush 的操作对象是缓冲, 而 pdflush 是基于页面的, 显然 pdflush 的效率会更高.

观察内存

"pmap –x pid" 这个命令, 能将/proc/pid/maps中的数据, 以更人性化的方式展示出来:

从上图可以看到, 每一项内容都清晰的标出了对象, 内存起始地址(逻辑地址), 占用的内存大小, 实际分配的内存(RSS, 也就是常驻内存), 以及脏内存, 这些单位都是kb, 并给出了最终的统计结果. 统计结果的前两项就是 top 中显示的 VIRT 和 RSS.

VM tuning

这里就只关注 Linux 2.6 之后的情况了(2.4之前诸如 bdflush 就不在讨论范围之内). 所有的VM可以调整的参数项, 都在/proc/sys/vm目录下:

可以"sysctl vm.param"观察参数的值, "sysctl –w vm.param=value"来修改参数.

具体的每一项参数的含义可以参考这里

  1. pdflush调优, 其实这一块跟磁盘IO关系比较紧.

    • dirty_writeback_centisecs, 默认是500, 单位是毫秒. 意思是每5秒唤醒 pdflush (多个线程), 将脏页面写回磁盘. 把这个参数调低可以增加 pdflush 被唤醒的频率, 不过在内核实现中, pdflush 在需要的时候会自动被唤醒, 所以这个参数的效果不可预期.
    • dirty_expire_centiseconds, 默认是3000, 单位是毫秒, 是指脏页面的过期时间, 超过了这个时间, 就会触发 pdflush 做回写.
    • dirty_background_ratio, 默认是10, 是指总内存中脏页面的百分比. 低于这个阈值时, pdflush 才会停止做回写, 有的内核版本的默认值是5.
    • dirty_ratio, 这也是一个百分比, 默认40, 是总内存中脏页面的百分比. 超过这个阈值, 就一定等待 pdflush 向磁盘回写. 与 dirty_background_ratio 的区别在与: 如果 cache 的增长超过了 pdflush 的回写速率时, 有可能 pdflush 来不及回写, 在超过40\%这个阈值时, 进程就会等待, 直到 pdflush 处理到这个阈值之下. 此时就是一个IO瓶颈.

      IO比较重的时候, 可以考虑的调优手段: 首先尝试调低 dirty_background_ratio, 其次是 dirty_background_ratio, 然后是 dirty_expire_centiseconds, dirty_writeback_centisecs 这一项可以不用考虑.

  1. swapness, 这个表示了 swap 分区的使用程度, 等于0时表示尽可能不用 swap, 等于100表示积极的使用 swap, 默认是60. 这个参数取决于具体的需求.

  2. drop_caches, 这个跟cache有关, 默认是0. 设置不同的参数可以回收系统的 cache 和 buffers, 不过略显粗暴(cache 和 buffer 的存在是有意义的).

    • free pagecache: sysctl -w vm.drop_caches=1
    • free dentries and inodes: sysctl -w vm.drop_caches=2
    • free pagecache, dentries and inodes: sysctl -w vm.drop_caches=3

参考文章