操作系统之内存这点事儿

内存介绍

虚拟化内存

设计目标

目标:

  1. 透明, 这里指运行的程序感知不到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。
  2. 效率 这里依赖硬件。譬如TLB, 主要消耗是虚拟地址转为实际物理地址。
  3. 保护 即程序不能访问它的地址空间之外的任何内容。从而也实现了隔离。

用户看的内存地址都是虚拟地址。(这里指cpu运行在虚模式,而不是实模式下)。

用户程序地址空间分布示例:(这种放置方法只是一种约定)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gJbz8stE-1667013858163)(../../media/image-20211214102331302.png)]

内存种类

如上所见,有2个内存分配区域,堆和栈。

这是由内存的生命周期决定的。

在函数调用中,函数调用结束,内存就自动释放,所以也叫自动内存,被分配在栈上,都是临时使用的。

当无法确定内存的使用周期时,只能由程序员管理,由程序员来主动申请和释放,这种就分配到堆上。

内存分配库 提供mallocfree 对应申请/释放内存。

操作系统提供的接口是brksbrkmmap

内存申请,初始化,释放 管理需要很仔细。否则很容易出现运行时的段错误。这是C语言被认为难写的主要原因。

地址转换

由硬件+操作系统共同协作

  • 硬件:将虚拟地址转换成实际的物理地址
  • 操作系统:需要记录管理被占用和空闲的内存位置。

负责地址转换硬件设施又被称为内存管理单元(MMU)

基址寄存器+界限

硬件要求

硬件要求解释
特权模式防止用户模式的进程执行特权指令
基址/界限寄存器每个CPU需要一对寄存器来支持地址转换和越界检查
能够转换虚拟地址并检查它是否越界电路来完成转换和检查界限
修改基址/界限寄存器的特权指令在让用户程序运行前,操作系统必须能够设置这些值
注册异常处理程序的特权指令操作系统必须告诉硬件,如果异常发生,那么执行哪些代码
能够触发异常如果进程试图使用特权指令或越界的内存

操作系统的职责

操作系统的要求解释
内存管理需要为新进程
从终止的内存回收内存
一般通过空闲列表来管理内存
基址/界限管理必须在上下文切换时正确设置基址/界限寄存器
异常处理当异常发生时执行的代码,可能的动作是终止犯错的进程

基址寄存器+界限 的方式实现简单,但有个以下问题:

  • 由于是将进程的地址空间完整地加载到内存中,在堆和栈之中就大量的内存浪费。这叫内部碎片。
  • 如果剩余物理空间无法提供连续区域来放置完整的地址空间,进程便无法运行

解决办法就是 把 进程的地址空间分开。

分段

最自然的分段就是 分成 代码段、堆段、栈段。

自然也就避免了 堆和栈之间的内存浪费。

这需要硬件的支持,原来是一个进程一对基址/界限对,现在需要每个段一对基址/界限对。

如何知道虚拟地址引用的哪个段?(从而正确引用相应的基址/界限对)

  • 放在虚拟地址中,譬如前2个位 用于存段的标识。
  • 隐式指明。地址由程序计数器产生,说明是代码段。基于栈或基址指针,说明是栈段。其他为堆段。

由于段和栈的增长方向不一样,检查方式也不一样,所以需要引入 增长方向的标识。

子进程和父进程代码段是一样的,完全可以共享,从而节省内存。于是可以进一步引入保护位。

段寄存器的值(有保护)

基址大小是否反向增长保护
代码32KB2KB1读-执行
34KB2KB1读-写
28KB2KB0读-写

简单分为代码/堆/栈 是种粗粒度的划分,还有种细粒度的划分,这需要进一步的硬件支持,并在内存中保存某种段表。

事实上,即便分段,也无法避免内存碎片。当无使用的内存空间小到无法放入 段时,便产生了外部碎片。

有2中方案处理:

  • 内存紧凑法:就是重新放置内存,这需要复制和移动,很耗CPU
  • 采用更好的算法,减小碎片: 最优匹配,最坏匹配,首次匹配,伙伴算法。都只能减小,不能避免。
空闲空间算法说明

如果管理的都是固定大小的内存块,算法简单而高效,切不会产生碎片。

如果管理的是不定长的内存块,问题复杂很多。

内存分配库 提供mallocfree 对应申请/释放内存。

注意到malloc有个size_t 参数指明内存大小,但是free只有一个指针,那么free怎么知道需要释放多大的内存呢?

实际上malloc申请的空间不只是size_t那么大小, 而是size_t + sizeof(header_t)

typedef struct header_t {
  int size;
  int magic;
} header_t;

在返回给用户的指针前还有个header_t 的内存,size指明了分配内存的大小。

于是通过(void*)ptr - sizeof(header_t) 获取真正的指针,通过size就可以知道需要释放的内存是多少了。

magic 是为了避免内存为覆盖的。。如果magic 值和预期的不符,就说明这块内存被异常覆盖了。

还有空闲空间管理有点类似,需要管理空间空间需要数据结构支持,这个数据结构也要存到内存里。

其结构大致如下:

typedef struct node_t {
  int size; //剩余空间
  struct node_t *next;//下一块空闲空间
} node_t;

也就是说100KB的剩余空间其实不能分配出去100KB,而是100KB- sizeof(node_t)。

空间的分隔与合并没啥好说的,很自然。

譬如有100KB剩余空间时,只申请30KB, 我当然是分隔成30KB+70KB, 然后只分配出去30KB, 这叫分隔。

当30KB释放时,如果和70KB相邻,我当然得合并,这样才能满足70~100KB的内存申请需求,这叫合并。

查找可用空间时,常规算法有:

  • 最优匹配:就是遍历所有可用空间,看谁最小。优点:浪费的空间最小。缺点:需要遍历一次,同时分配后剩余的空间太小,容易产生碎片。
  • 最差匹配:为了解决最优匹配的碎片问题。同样是遍历所有可用空间,看谁最大。优点:选的最大的,剩余空间仍然够大,不容易产生碎片。缺点:同样需要遍历,实际产生的碎片过多。
  • 首次匹配:使用遍历时遇到的一个满足需求的块。优点:性能高。缺点:碎片集中在头部,因为都是从头开始遍历。
  • 下次匹配:针对首次匹配的,不是从头开始遍历,而是记住上一次遍历完的位置,接着上次的位置开始遍历。和首次匹配性能差不多。

其他算法:

  • 预分配:这个和memcache很像,先预分配256,512,1024等大小的内存块。当申请固定大小的内存块时,就用这些,否则就用常规的分配算法。
  • 伙伴算法:只分配2的倍数的内存块。譬如有64kb, 需要申请7KB, 64->32->16->8, 64KB一直1分为2, 当分到8KB时,分配出去。当回收时,如果伙伴也是空闲的那么合并为16KB。其优点就是合并算法容易,一直递归上去就好了。

分页

如前所述,分段是把内存分成不定长的内存,不论用什么算法,都避免不了内存外部碎片。

而把内存分成固定大小的段,问题就简单多了。这就是分页。

分页带来了2个好处:

  • 内存管理简单,不会产生外部碎片
  • 更灵活。譬如不用再假设堆和栈的增长方向,以及他们如何使用。

相应地他也带来了虚拟地址转换的挑战。

譬如操作系统必须为每个进程存一个页表。

可以简单地把也变看成一个数组。

索引就是虚拟的页编号(VPN)

内容就是页表项,页表项就是一个字节。

譬如32位的字节,

其中前N为是实际的物理地址页编号。(PFN)

后面的位也有作用:

  • 有效位: 表明是否已使用。如果未使用,就没必要分配实际的物理地址。也就是PFN是无效的
  • 保护位:表明页是否可以读取、写入或执行。以错误的方式访问会陷入操作系统。
  • 存在位:表示该页是在物理存储器还是在磁盘上。swap技术时使用
  • 脏位:表明页面被带入内存后是否被修改过。如果被修改过,把页重新置入磁盘时需要写磁盘,否则没必要。
  • 参考位/访问位:用于追踪页是否被访问,以及确定哪些页很受原因。用于置换页时参考。

譬如32位的机器上,有4GB的地址空间( $ 2^{32} $ ), 假设一页大小是4KB( 2 12 2^{12} 212), 那么可以分为 2 20 2^{20} 220个页。

一个32位的地址,前20位为虚拟地址空间,后12位为偏移量。

从进程的页表里 找到对应的页表项,如果页表项有效,就提取PFN, 加上偏移量就得到了实际的物理地址。

从这里其实也看出了分页的问题:

  • 太占内存。每个进程需要 2 20 2^{20} 220 (4MB)大小的页表项。在基址寄存器+界限中 只有1对。分段中只有3对,这里一下增长到了 2 20 2^{20} 220
  • 计算慢。需要访问页表,然后根据虚拟地址计算VPN, 然后从页表中找到页表项,提取PFN, 再转换成实际物理地址。 访问页表本身也是一次内存访问。

关于计算慢,主要是每次指令读取/内存读写 都需要访问页面,而页表本身是存在内存的。

对比 分段 是用到的寄存器,自然就慢多了。

寄存器的读写速度远远大于内存访问。

所以优化方向就是避免内存访问,而这又是缓存发挥作用的时候,只是这个缓存是硬件来实现的。

他的名字是地址转换旁路缓冲存储器 ( translation-lookaside buffer,TLB)

支持分页的硬件TLB

tlb 成功的关键原因是 局部性规律。

  • 时间局部性: 最近访问过的指令/数据项可能很快会再次访问。
  • 空间局部性:当程序访问内存地址x时,可能很快会访问邻近x的内存。

既然是缓存,就一定有未命中的情况,谁负责处理呢?

  • 1种可能是硬件,譬如CSIC指令集中。(Complex Instruction Set Computer)
  • 1种可能是软件,即操作系统。譬如在RSIC指令集中(Reduced Instruction Set Computer)

通过操作处理未命中时有个细节, 在常规的陷入操作系统时,操作系统处理完后,进程继续执行下一个指令。

而在TLB为命中时,则是重试原来的指令。(这里也要注意未命中的无限递归问题)

TLB的内容 为 VPN | PFN | 其他位。

由于TLB缓存的内容只对当前进程有效,那么在进程切换时怎么办呢?

可能的策略是:

  • 直接清空TLB。 这种其实不太好,切换回来TLB就未命中了。TLB命中率高不了
  • 共享TLB。增加位来表示进程号。其名为ASID, 一般为8位。同时需要寄存器存下当前进程的ASID。

缓存还有一个问题,就是大小,超过缓存大小咋办?这时候需要置换了,写入新缓存时,要去掉老的缓存。

如何替换:

  • 最近最少使用 (LRU)
  • 随机选择

有了TLB, 在软件处理未命中时,显然也需要硬件提供指令 给 操作系统使用。

TLB解决了分页过慢的问题,但是没解决分页太占内存的问题。

分页之所以太占内存是线性结构问题。

譬如 32位,1页4kb的话,还有20位。而一个页表项4字节。那么有 2 20 ∗ 2 2 = 4 M B 2^{20}* 2^2 = 4MB 22022=4MB大小。

显然问题是页表项过多。如何解决呢?

2个方向:

  • 增加页的大小,
  • 不用线性结构,换其他结构。

分页:较小的表

方案1: 更大的页

增加页大小确实能降低页表项,1页改成16Kb, 页表项变成 2 18 2^{18} 218个,降低到了1Mb。

但是页太大了,容易产生内部碎片。仅适应数据库等特殊应用。

混合使用分段+分页

具体方案是 单个进程不再有完整的页表。

而是有3个段,代码段、堆、栈。然后每个段分配页表。

段的基址寄存器+界限 改为记录 物理页帧和也页表打小。

虚拟地址结构 改为 Seg | VPN | Offset。

这样我们就不用为未分配的内存 分配页表项了,从而大大减小了页表。

但分段本身的问题也还存在:

  • 不够灵活,得假设地址空间的使用模式
  • 如果堆是大二稀疏的,仍然会产生大量的外部碎片。会浪费页表项。

多级页表:最有效

线性结构是问题的根源,把线性结构分级不就可以了。

这就像原来一个目录里有 2 20 2^{20} 220个文件,每个文件都是有效的内存页。

现在我们把目录改成多级目录。譬如2级目录。第一级目录有 2 10 2^{10} 210个目录项,每个目录项里有 2 10 2^{10} 210个文件。

如果某个目录项里的文件都是无效的。那么整个目录标识为无效,不再分配文件,于是就能节省 2 10 ∗ 4 2^{10} * 4 2104 = 4KB大小。

无效的目录项越多。节省的空间越多。

实际的设计目标是为了让 页表 的每一部分都能放入一个页。

举个例子:

有30为的虚拟地址空间,然后页大小为512字节。

那么虚拟地址为 2 21 ∗ 2 9 2^{21} * 2^9 22129 21位为vpn, 9位为偏移量。

如果采用二级结构,假设PTE为4字节,那么一页能存放 512/4 = 128 = 2 7 2^7 27 个PTE

这样 就有 2 14 2^{14} 214 个目录项。显然 达不到 让 页表 的每一部分都能放入一个页 这个目标。

得再拆。

一级页目录 包含 2 7 2^7 27个二级页目录。

二级页目录包含 2 7 2^7 27个三级页目录。

三级页目录 包含 2 7 2^7 27 个PTE。

每一级刚好包含一页。

现在vpn的表示法是

1级页目录 | 二级页目录 | 三级页偏移 | offset

但是多级页表也是有代价的,他增加了TLB未命中时的复杂性,需要多次加载内存。。有几级就要加载几次。

反向页表。

原来之所以浪费空间是因为 有太多的页其实没有使用。

如果反过来。记录物理页帧->虚拟页 的映射。。也就是只记录分配了内存的页。是不是好很多呢?

这就是反向页表。由于索引是物理页帧,也就可以只存一个页表就够了。(而不是每个进程一个)

交换到磁盘

页表不咋访问时,不如交换到磁盘。降低内存压力。

超出物理内存

机制

为了支持更大的地址空间(大到可以超越物理内存),操作系统需要把当前没有在用的那部分地址空间找个地方存储起来,这个地方一般是硬盘,取名叫交换空间。

多道程序和易用性都需要操作系统支持比物理内存更大的地址空间。

交换空间:硬盘上开辟一部分空间专门用于物理页的移入和移除。他的大小决定了操作系统在某一刻能够使用的最大内存页数。

为了支持交换空间,PTE需要增加位 记住 硬盘地址。

当内存紧张时,可以

  1. 置换内存页 到硬盘上的交换空间。
  2. 大的二进制程序,可以按页加载。当内存紧张时,可以释放内存页,需要时再从二进制程序中读取。

更新内存访问流程

  1. 从虚拟内存中提取VPN
  2. 在TLB中通过VPN查找
    1. 命中,获取tlbEntry。能否访问?
      1. 不能访问。排除 保护异常。进程中断。End
      2. 能访问。访问内存。End
    2. 未命中。通过VPN计算PTE的内存地址并访问。PTE是否有效?
      1. 无效。抛出异常。End
      2. 有效。是否能访问?
        1. 不能访问。排除 保护异常。进程中断。End
        2. 能访问。是否在内存中?
          1. 不在,抛出页错误。End. 交给操作系统,从硬盘上加载物理页。然后回来重试指令。
          2. 在。插入TLB, 重试。End

为了保证有少量的空闲内存,大多数操作系统会设置2个值:

  1. 高水位线。HW
  2. 低水位线。LW

当可以物理内存页 低于LW时,交换物理内存到硬盘上,知道有HW页可用。

这个后台进程叫 交换守护进程(swap daemon) 或 页守护进程(page daemon)。

替换策略

没有空闲内存页可用时,需要踢出某个页到硬盘上,以空出内存页。选择踢哪个就叫替换策略。

虚拟内存 -> 实际内存 -> 物理硬盘。

从这个视角看,空闲内存页其实就是一个缓存系统。

那么替换策略就是为了提高命中率。(由于硬盘的读写速度远低于内存,所以哪怕提升一点命中率,性能提升也很明显)

常用的有:

  • FIFO.实现最简单
  • 随机替换
  • LRU( 最近最少使用) 基本都是使用这个算法。

参考算法是 替换最远将来才会被访问的页。(最优策略,无法实现)

考虑到程序的局部性特征, LRU是最接近 最优策略的。但是LRU需要扫描所有页,这个消耗很大。

实际上 使用近似 LRU算法(又叫时钟算法:

  • 在内存页载入时,使用位设置为1(使用位又叫引用位,可能在进程的页表中,也可能在某个数组中)
  • 所有页组成一个环,然后操作系统扫描。遇到1时表示有使用,改成0.继续扫描。直到遇到第一个为0的页。选择他替换掉。

设置为0的目的是 避免重复扫描。这样最差的情况只需要扫描一次。

考虑到如果内存页在载入后如果被修改(脏位),那么替换出去时,就得写入硬盘,多了次I/O操作。

所以在扫描时优先扫描 没被访问且不脏的页替换掉。

在写入硬盘时,因为硬盘有磁头寻道的问题。。聚合写入性能更高,常常把多个写磁盘操作 聚合到一起写入。这样可以提高整体性能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值