文件映射
文件映射通过映射虚拟内存的方式实现,在映射的时候进程访问的实际是文件对应的副本虚拟内存地址,既然访问虚拟内存位置可以完成文件的修改映射,那么直接访问物理内存也就是实际内存修改内容也是可行的。
通用:如果知道文件的具体地址,甚至可以直接定位到内存地址对于内容进行覆盖。
请求分页
进程向内核申请内存的通过请求分页的方式完成,之前提到过通过mmap的方式申请内存的方式虽然很方便,并且通常的内存分配方式有下面两种:
- 物理内存的直接申请和分配,高效
- 句柄分配的方式,也就是页表对于虚拟内存和实际内存映射之后再给进程
这两种分配方式都存在两个比较明显的问题,那就是分配的时候如果申请了却没有使用,会大量浪费,另外一次glibc一次需要超过进程的内存,可能出现一个很大的进程管理大量被申请未使用内存。
为了更好理解请求分页需要先理解分页的三种状态:
- 未分配页表和物理内存给进程。
- 已分配页表但是未分配物理内存。
- 已分配页表和物理内存。
为了解决分配浪费的问题,分配进程的内存仅使用一次分配方式,请求分页的核心是利用内核缺页中断的机制,当进程初次访问到已分配但是没有没有分配物理内存的空间,对于此时内核会进行缺页中断处理,同时给进程真正申请物理内存进行分配动作,这样可以保证每次分配内存的动作都是有效的。
这种方式也类似懒加载的方式,即可以保证分配动作运行,进程无感知缺页中断的情况,依然可以正常运行。
如果使用C语言按照请求分页的特点进行实验可以发现当内存没有使用的时候即使显示已经分配内存,但是实际可用物理内存没有变动。另外分配内存失败分为虚拟内存分配失败,物理内存分配失败,这是因为懒加载的设计导致的,另外虚拟内存不足不一定会导致物理内存不足,因为只要可用物理在分配时刻小于虚拟内存,那就是没法分配。
写时复制
写时复制是利用fork的函数提高虚拟内存分配效率,在文件系统的体现是update或者delete不会动原数据,而是用副本完成操作,当操作完成再更新引用,如果中间宕机断电,则用日志恢复状态即可。
在 Linux 系统的内存管理中调用 fork
系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是首先与父进程共用相同的页表,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制
机制。
写时复制的流程如下:
- 在父进程调用fork的时候,并不是把所有内存复制给子进程 而是递交自己的页表给子进程。完成这一步如果子父进程只进行只读操作双方都会共享页表,但是一旦一方要改变数据,就会立马解除共享。
- 在解除共享的时候会有如下操作
- 由于写入失去权限,所以会出发缺页中断
- 内核干预,执行缺页中断
- 写入方的数据复制到另一处,并且把写入方的页表全部更新为新复制的内存并且赋予写入方写入权限,同时把之前共享的页表更新。而另一方则把这个刷新之后的页表重新连接即可。
- 最后父子进程彻底写入权限和页表独立。但是之前解除共享的页表依然可以自由读写。
注意:fork调用的时候:并不会复制页表和内容,而是真正写入的时候会触发复制动作,这也是写时复制名字由来。
交换内存
交换内存是Linux内核一种oom情况下的补偿机制,作用也是为了缓解内存溢出和不足的问题,交换内存的实现依靠的是虚拟内存的机制。 简单理解就是在物理内存虽然不够,但是虚拟内存可以借用外部存储器也就是硬盘的一部分空间来充当物理内存使用,这一块分区叫做交换区,由于是借物理存储空间,这个操作也叫做换出。 另外如果借用的空间被释放则归还,这部分操作叫做换入,由于交换内存以页为单位,部分资料也叫页面调入和调出,都是一个意思。
成这一步如果只进行只读操作双方都会共享页表,但是一旦一方要改变数据,就会立马解除共享。
- 在解除共享的时候会有如下操作
- 由于写入失去权限,所以会出发缺页中断
- 内核干预,执行缺页中断
- 写入方的数据复制到另一处,并且把写入方的页表全部更新为新复制的内存并且赋予写入方写入权限,同时把之前共享的页表更新。而另一方则把这个刷新之后的页表重新连接即可。
- 最后父子进程彻底写入权限和页表独立。但是之前解除共享的页表依然可以自由读写。
注意:fork调用的时候:并不会复制页表和内容,而是真正写入的时候会触发复制动作,这也是写时复制名字由来。
另外交换内存很容易认为是一种扩充物理内存的美好方式,但是这里有一个本质的问题,那就是硬盘的访问速度和内存相比差的次方级别的差距。另外如果长期内存不足很容易导致交换内存不断的换入换出出现明显的性能抖动。
另外这类需要外部存储器的缺页中断在术语中被称之为硬性页缺失,相对的不需要外部存储器的页缺失是软性页缺失,虽然本质都是内核在触发和完整操作,但是硬性的缺失总归比软性缺失后果严重很多。
这里也要吐槽一下M1的各种偷硬盘缓存来提高性能的操作.....
多级页表
多级页表的设计核心是:避免把全部页表一直保存在内存中是多级页表的关键所在。特别是那些不需要的页表就不应该保留。
在X86-64架构当中,虚拟地址的空间大小约为128T,一个页的大小为4KB,页表的项目大小为8个字节。
所以一个进程的页表至少需要256GB内存!(8 * 128T / 4KB),但是我们都知道现在的电脑一般都是16GB内存为主,而32GB的内存虽然但是个人用的比较少。 那么系统应该如何维护页表?这就引入了多层页表来进行管理,多集页表可以从最简单的角度当作一个多级的指针看待,当然实际的多级页表一两句话是说不完的,这里我们可以大致理解多级页表是如何提升性能的?
首先我们可以思考,一个进程是否需要整个页表来管理内存?很显然是不需要的,这是引入多级页表的理由之一,可以发现绝大多数都不需要,比如一个进程需要12M空间,顶端需要4M,数据部分占用4M,底部又是一些堆栈内容和记录信息,在数据顶端上方和堆栈之间是大量根本没有使用的空闲区。
所以多级页表实际上就是大目录套小目录,和我们的一本书一样,小目录负责小的进程,而遇到比较大的进程就放到空闲页比较大的目录中完成分配操作,多级页表既可以高效的利用内存的同时,可以最大限度的减少页表本身的数据结构在内存的占用,同时上面的例子也可以发现绝大多数的进程其实根本不需要太大的页表进行维护和管理。
最后从网上的资料翻阅中发现一张下面的图,对于多级页表的理解有一定帮助:
最后X86_64 使用了4层的页表结构,直当理解就是四级指针,复杂程度可见一般。
标准大页
随着进程虚拟内存和页表的使用,进程使用的物理内存也会增加。
我们根据请求分页和写时复制的概念,可以发现当进程使用虚拟内存量增加会有一个明显的问题那就是在调用fork函数的时候会对于父子进程共享的页表进行拷贝,虽然这个拷贝动作不会占用物理内存,但是为进程复制操作需要拷贝一份完整的页表,所以当页表很大的时候,也会造成性能浪费。
为了解决这个问题,Linux提供了标准大页的机制,和他名字一样,就是比普通的页表更大的页,利用这种页表可以减小进程页表所需的内存使用量。
另外通过二级页表和标准大表可以有效的减少整个页表项的数量,最后对于标准大页我们只需要知道标准大页可以减少大量的虚拟内存进程的页表开支即可。
用法:
C语言中使用mmap函数参数赋予 MAP_HUGETLB 标志,表示可以获取大页,但是更加常用的方式是使用程序允许使用使用标准大爷而不是这种手动切换的方式。
标准大页对于虚拟机和数据库等需要使用大量内存的应用程序是很有必要的,根据实际情况决定是否使用标准大页,通过这种设置可以减少这一类软件内存占用,还能提高fork效率。
透明大页 透明大页是随着标准大页带来的附带特性,主要的作用是在连续的4KB页面如果符合指定条件就可以通过透明大页的机制转为一个标准大页,以及在不满足条件的时候会出现大页拆分为多个4KB页面的情况,所以这个机制
小结
这部分从文件映射的内容引申了Linux两个重要的机制:请求分页和写时复制,目的本质上都是尽量减少进程对于内存的浪费,但是需要注意的是这两种方式都是使用了内核模式的系统中断机制来进行处理的,所以对于内核的性能以及稳定性要求非常高。
在之后的内容介绍了交换内存以及多级页表和标准大页几个内容,其中多级页表内部的细节非常的复杂,通常需要对于操作系统底层有比较熟悉的认知才能完全的了解这个页表的细节。