4、内存管理

深入理解 Linux 虚拟内存管理
深入理解 Linux 物理内存管理

1、分段和分页

image

image

1.1、分段

问题:内存外碎片 + 内存交换的效率低

解决「内存外碎片」问题:内存交换
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里
不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面,这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换

对于多进程的系统来说,用分段的方式,「内存外碎片」是很容易产生的,那就需要重新 Swap 内存区域,这个过程会产生性能瓶颈
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上
所以如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿
image

1.2、分页

image

P 存在位:0 不存在,1 存在
R / W 位:0 只读只执行,1 读写执行
U / S 位:0 特权级 012 可以访问,1 都可以访问
A 位:已访问标志,OS 定期地复位来统计页面的使用情况
D 位:脏位,写操作后页面置脏
AVL 位:供程序员使用的位

因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,也只能分配一个页,所以页内会出现内存浪费,也叫「内存内碎片」
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出 Swap Out
一旦需要的时候,再加载进来,称为换入 Swap In
所以一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高

更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中
我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去

1.3、多级页表

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2 ^ 12),那么就需要大约 2 ^ 20 个页
每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表
这 4MB 大小的页表,看起来也不是很大,但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表
那么 100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间
但如果某个「一级页表的页表项」没有被用到,也就不需要创建这个「页表项对应的二级页表」了,即可以在需要时才创建二级页表
做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表)+ 20% * 4MB(二级页表)= 0.804MB

我们从页表的性质来看,保存在内存中的页表承担的职责是:将虚拟地址翻译成物理地址,假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了
所以页表一定要覆盖全部虚拟地址空间,即使页表项不用也要创建,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)

1.4、TLB

为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的页高速缓冲器件中
该缓冲器件被称为转换查找缓冲区 TLB(Translation Lookaside Buffer),只有当 TLB 中不包含要求的页表项时才会使用额外的总线周期从内 存中读取页表项
image

2、Linux 内存布局

Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的
这意味着 Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同,比如最常见的 32 位和 64 位系统
进程在用户态时,只能访问用户空间内存;只有进入内核态后,才可以访问内核空间的内存
image

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存,进程切换到内核态后,就可以很方便地访问内核空间内存
image

用户空间内存,从低到高分别是 6 种不同的内存段

  • 代码段:包括二进制可执行代码
  • 数据段:包括已初始化的静态常量和全局变量
  • BSS 段:包括未初始化的静态变量和全局变量
  • 堆段:包括动态分配的内存,从低地址开始向上增长
  • 文件映射段:包括动态库、共享内存等,从低地址开始向上增长,跟硬件和内核版本有关(opens new window)
  • 栈段:包括局部变量和函数调用的上下文等,栈的大小是固定的,一般是 8 MB,当然系统也提供了参数,以便我们自定义大小

代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址
我们通常在 C 的代码里会将无效的指针赋值为 NULL,因此这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞

在这 7 个内存段中,堆和文件映射段的内存是动态分配的,比如使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存

image

3、malloc

3.1、如何分配内存的

malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存,有两种方式向操作系统申请堆内存

  • 通过 brk() 系统调用从堆分配内存
  • 通过 mmap() 系统调用在文件映射区域分配内存

方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间

image

方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区 "偷" 了一块内存

image

什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?

malloc() 源码里默认定义了一个阈值(注意:不同的 glibc 版本定义的阈值也是不同的)

  • 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存
  • 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存

3.2、分配的是物理内存吗

不是的,malloc() 分配的是虚拟内存
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了
只有在访问已分配的虚拟地址空间的时候,OS 通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后 OS 会建立虚拟内存和物理内存之间的映射关系

3.3、会分配多大的虚拟内存

malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池
具体会预分配多大的空间,跟 malloc() 使用的内存管理器有关系,我们就以 malloc() 默认的内存管理器(Ptmalloc2)来分析
最终测试得到 malloc(1) 实际上预分配 132K 字节的内存(通过 brk() 系统调用向堆空间申请的内存)

#include <stdio.h>
#include <malloc.h>

int main() {
    printf("使用 cat /proc/%d/maps 查看内存分配\n", getpid());

    // 申请 1 字节的内存
    void *addr = malloc(1);
    printf("此 1 字节的内存起始地址:%x\n", addr);
    printf("使用 cat /proc/%d/maps 查看内存分配\n", getpid());

    // 将程序阻塞,当输入任意字符时才往下执行
    getchar();

    // 释放内存
    free(addr);
    printf("释放了 1 字节的内存,但 heap 堆并不会释放\n");

    getchar();
    return 0;
}

3.4、free 会归还给操作系统吗

我们在上面的进程往下执行,通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统
这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里
当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多(当进程退出后,操作系统就会回收进程的所有资源)

上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况,如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归还给操作系统

#include <stdio.h>
#include <malloc.h>

int main() {
    // 申请 1 字节的内存
    void *addr = malloc(128*1024);
    printf("此 128KB 字节的内存起始地址:%x\n", addr);
    printf("使用 cat /proc/%d/maps 查看内存分配\n", getpid());

    // 将程序阻塞,当输入任意字符时才往下执行
    getchar();

    // 释放内存
    free(addr);
    printf("释放了 128KB 字节的内存,内存也归还给了操作系统\n");

    getchar();
    return 0;
}

对于 「malloc 申请的内存,free 释放内存会归还给操作系统吗」这个问题,我们可以做个总结了

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用
  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

3.5、mmap 与 brk

为什么不全部使用 mmap 来分配内存

  • 因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后再回到用户态,运行态的切换会耗费不少时间
    所以申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用
  • 因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断
    也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大

为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候
由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中,等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了
而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗

既然 brk 那么牛逼,为什么不全部使用 brk 来分配

前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间
image

但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大
因此随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致 "内存泄露",而这种 "泄露" 现象使用 valgrind 是无法检测出来的
所以 malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存(128KB)才使用 mmap 分配内存空间

3.6、free

free() 函数只传入一个内存地址,为什么能知道要释放多大的内存
还记得我前面提到, malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗:保存了该内存块的描述信息,比如有该内存块的大小
这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了
image

4、内存满了会发生什么

4.1、虚拟内存有什么用

  • 虚拟内存可以使得进程的运行内存超过物理内存大小
    程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域
  • 由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的
    进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题
  • 页表里的页表项中除了物理地址之外,还有一些标记属性的比特
    比如控制一个页的读写权限,标记该页是否存在等,在内存访问方面,操作系统提供了更好的安全性

4.2、内存分配的过程

应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存
CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler(缺页中断函数)处理

  • 缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系
  • 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:后台内存回收和直接内存回收
    后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行
    直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行
  • 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了:触发 OOM(Out of Memory)机制
    OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源
    如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置

image

4.3、哪些内存可以被回收

系统内存紧张的时候,就会进行回收内存的工作,主要有两类内存可以被回收,而且它们的回收方式也不同

  • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页
    大部分文件页都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了
    而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放
    所以回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
  • 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等
    这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制
    Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用,再次访问这些内存时,重新从磁盘读入内存就可以了

文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存,LRU 回收算法实际上维护着 active 和 inactive 两个双向链表

  • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页
  • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页

越接近链表尾部,就表示内存页越不常访问,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存
活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页,可以从 /proc/meminfo 中查询它们的大小

# grep 表示只保留包含 active 的指标(忽略大小写)
# sort 表示按照字母顺序排序
# cat /proc/meminfo | grep -i active | sort
Active:          1371064 kB
Active(anon):     259876 kB
Active(file):    1111188 kB
Inactive:        1567584 kB
Inactive(anon):   674744 kB
Inactive(file):   892840 kB

4.4、回收内存带来的性能影响

在前面我们知道了回收内存有两种方式

  • 后台内存回收:唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程
  • 直接内存回收:这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高

可被回收的内存类型有文件页和匿名页

  • 文件页:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I / O 的,这个操作是会影响系统性能的
  • 匿名页:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的

回收内存的操作基本都会发生磁盘 I / O 的,如果回收内存的操作很频繁,意味着磁盘 I / O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡

5、在 4G 机器上申请 8G 内存

在 4GB 物理内存的机器上,申请 8GB 内存会怎么样

  • 在 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,在申请虚拟内存阶段就会失败
    我手上没有 32 位操作系统测试,我估计失败的错误是 cannot allocate memory,也就是无法申请内存失败
  • 在 64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8GB 内存是没问题的
    因为进程申请内存是申请虚拟内存,只要不读写这个虚拟内存,操作系统就不会分配物理内存,如果这块虚拟内存被访问了,要看系统有没有 Swap 分区
    如果无 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出)
    如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行

6、内存页面置换算法

6.1、缺页中断

在了解内存页面置换算法前,我们得先谈一下缺页异常(缺页中断)
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存,它与一般中断的主要区别在于

  • 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号
  • 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行

缺页中断过程

  1. 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项
  2. 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求
  3. 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置
  4. 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中
  5. 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」
  6. CPU 重新执行导致缺页异常的指令

image

上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢

找不到空闲页的话,就说明此时内存已满了,这时候就需要「页面置换算法」选择一个物理页
如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的「页表项的状态」改成「无效的」,最后把正在访问的页面装入到这个物理页中
参考链接:系统寄存器 CR2 和 CR3分页机制中断 - 页面故障

页面置换算法:当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页
算法目标则是:尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种

  • 最佳页面置换算法(OPT)
  • 先进先出置换算法(FIFO)
  • 最近最久未使用的置换算法(LRU)
  • 时钟页面置换算法(Lock)
  • 最不常用置换算法(LFU)

image

6.2、最佳

最佳页面置换算法基本思路是:置换在「未来」最长时间不访问的页面
该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面

我们举个例子,假设一开始有 3 个空闲的物理页,然后有请求的页面序列,那它的置换过程如下图
image
在这个请求的页面序列中,缺页共发生了 7 次(空闲页换入 3 次 + 最优页面置换 4 次),页面置换共发生了 4 次
这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间
所以最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的

6.3、先进先出

既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想

还是以前面的请求的页面序列作为例子,假设使用先进先出置换算法,则过程如下图
在这个请求的页面序列中,缺页共发生了 10 次,页面置换共发生了 7 次,跟最佳页面置换算法比较起来,性能明显差了很多
image

6.4、最近最久未使用

最近最久未使用(LRU)的置换算法:发生缺页时,选择最长时间没有被访问的页面进行置换
也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用
这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面

还是以前面的请求的页面序列作为例子,假设使用最近最久未使用的置换算法,则过程如下图
在这个请求的页面序列中,缺页共发生了 9 次,页面置换共发生了 6 次,跟先进先出置换算法比较起来,性能提高了一些
image
虽然 LRU 在理论上是可以实现的,但代价很高
为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾,困难的是,在每次访问内存时都必须要更新「整个链表」
在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作,所以 LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用

6.5、时钟页面

那有没有一种即能优化置换的次数,也能方便实现的算法呢
时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进

该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面
当发生缺页中断时,算法首先检查表针指向的页面

  • 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置
  • 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止

我画了一副时钟页面置换算法的工作流程图,你可以在下方看到,了解了这个算法的工作方式,就明白为什么它被称为时钟(Clock)算法了
image

6.6、最不常用

最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰
它的实现方式是:对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1,在发生缺页中断时,淘汰计数器值最小的那个页面

  • 看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的
    要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高
  • 还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题
    比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了
    而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面
    这个问题的解决的办法还是有的,可以定期减少访问的次数
    比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率

7、写时复制

Linux 写时复制机制原理

在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份
而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制,这就是著名的 "写时复制" 机制

7.1、共享内存

如果不同进程的「虚拟内存地址」映射到相同的「物理内存地址」,那么就实现了「共享内存」的机制
由于进程 A 的「虚拟内存 M」与进程 B 的「虚拟内存 M'」映射到相同的「物理内存 G」,所以当修改进程 A 虚拟内存 M 的数据时,进程 B 虚拟内存 M' 的数据也会跟着改变
image

7.2、写时复制

Linux 为了加速创建子进程过程与节省内存使用的原因,实现了「写时复制」的机制

  • 创建子进程时,将父进程的「虚拟内存」与「物理内存」映射关系复制到子进程中,并将「页表项」设置为只读(当对内存进行写操作时触发缺页异常)
  • 当子进程或者父进程对内存数据进行修改时:将原来的「物理页」复制一份,并重新设置其内存映射关系,将父子进程的「页表项」设置为可读可写

当创建子进程时,父子进程指向相同的「物理内存」,而不是将父进程所占用的「物理内存」复制一份,这样做的好处有两个

  • 加速创建子进程的速度
  • 减少进程对物理内存的使用

image

8、mmap

8.1、什么是 mmap

mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系
实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上
即完成了对文件的操作而不必再调用 read、write 等系统调用函数
相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享
image

多个进程同时映射到一个文件如何保证写安全呢

  • 在多个进程的虚拟内存区域和同 1 个共享对象建立映射关系的前提下
  • 若其中 1 个进程对该虚拟区域进行写操作
  • 对于 "把该共享对象映射到其自身虚拟内存区域的进程" 也是可见的

8.2、mmap 映射原理过程

mmap 内存映射的实现过程,总的来说可以分为三个阶段

一、进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

  • 进程在用户空间调用库函数 mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
  • 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
  • 为此虚拟区分配一个 vm_area_struct 结构,接着对这个结构的各个域进行了初始化
  • 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

二、调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

  • 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符
    通过文件描述符,链接到内核 "已打开文件集" 中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息
  • 通过该文件的文件结构体,链接到 file_operations 模块,调用内核函数 mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma)
  • 内核 mmap 函数通过虚拟文件系统 inode 模块定位到文件磁盘物理地址
  • 通过 remap_pfn_range 函数建立页表,即实现了文件地址和虚拟地址区域的映射关系,此时这片虚拟地址并没有任何数据关联到主存中

三、进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存,真正的文件读取是当进程发起读或写操作时

  • 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上
    因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常
  • 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程
  • 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用 nopage 函数把所缺的页从磁盘装入到主存中
  • 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程
    注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用 msync() 来强制同步,这样所写的内容就能立即保存到文件里了
posted @ 2023-08-25 16:52  lidongdongdong~  阅读(26)  评论(0编辑  收藏  举报