brk系统调用和copy-on-write机制

Lazy page allocation

看一下内存allocation,或者更具体的说brk系统调用,brk系统调用会调整一个指针,该指针指向堆的最顶端。
在这里插入图片描述
brk()系统调用会拓展heap的上界,也就是扩大heap的容量。
这意味着,当brk实际发生或者被调用的时候,内核会分配一些物理内存,并将这些内存映射到用户应用程序的地址空间,然后将内存内容初始化为0,再返回brk系统调用。这样,应用程序可以通过多次brk系统调用来增加它所需要的内存。类似的,应用程序还可以通过给brk传入负数作为参数,来减少或者压缩它的地址空间。

eager allocation 和lazy allocation

在最初brk的实现默认是eager allocation。这表示了,一旦调用了brk,内核会立即分配应用程序所需要的物理内存。但是实际上,对于应用程序来说很难预测自己需要多少内存,所以通常来说,应用程序倾向于申请多于自己所需要的内存。这意味着,进程的内存消耗会增加许多,但是有部分内存永远也不会被应用程序所使用到。你或许会认为这里很蠢,怎么可以这样呢?你可以设想自己写了一个应用程序,读取了一些输入然后通过一个矩阵进行一些运算。你需要为最坏的情况做准备,比如说为最大可能的矩阵分配内存,但是应用程序可能永远也用不上这些内存,通常情况下,应用程序会在一个小得多的矩阵上进行运算。所以,程序员过多的申请内存但是过少的使用内存,这种情况还挺常见的。原则上来说,这不是一个大问题。

使用虚拟内存和page fault handler,我们完全可以用某种更聪明的方法来解决这里的问题,这里就是利用lazy allocation。核心思想非常简单,sbrk系统调基本上不做任何事情,唯一需要做的事情就是提升heap的顶部,将heap顶部增加n,其中n是需要新分配的内存page数量。但是内核在这个时间点并不会分配任何物理内存。之后在某个时间点,应用程序使用到了新申请的那部分内存,这时会触发page fault,因为我们还没有将新的内存映射到page table。所以,如果我们解析一个大于旧的堆顶,但是又小于新的堆顶(注,也就是旧的堆顶 + n)的虚拟地址,我们希望内核能够分配一个内存page,并且重新执行指令。
所以,当我们看到了一个page fault,相应的虚拟地址小于当前堆顶,那么我们就知道这是一个来自于heap的地址,但是内核还没有分配任何物理内存。所以对于这个page fault的响应也理所当然的直接明了:在page fault handler中,通过kalloc函数分配一个内存page;初始化这个page内容为0;将这个内存page映射到user page table中;最后重新执行指令。比方说,如果是load指令,或者store指令要访问属于当前进程但是还未被分配的内存,在我们映射完新申请的物理内存page之后,重新执行指令应该就能通过了。

Copy-On-Write Fork

copy-on-write 以下简称(cow) 是一种常用的内存分配优化方式,许多操作系统都实现了它。

cow的目标问题

当Shell处理指令时,它会通过fork创建一个子进程。fork会创建一个Shell进程的拷贝,所以这时我们有一个父进程(原来的Shell)和一个子进程。fork创建了Shell地址空间的一个完整的拷贝,fork会对父进程的地址空间进行完整拷贝。但是随后shell子进程会执行exec,而exec所做的第一件事儿就是放弃该进程的地址空间,这样对原来fork拷贝的地址空间造成了不少浪费。

具体一点:假如我们最开始有了一个父进程的虚拟地址空间,然后我们有了子进程的虚拟地址空间。在物理内存中,假设Shell有4个page,当调用fork时,基本上就是创建了4个新的page,并将父进程page的内容拷贝到4个新的子进程的page中。一旦调用了exec,我们又会释放这些page,并分配新的page来包含echo相关的内容。造成了十足的浪费。

下图左边为父进程子进程的页表,右边为物理地址空间。

在这里插入图片描述

cow核心原理

所以
对于这个特定场景有一个非常有效的优化:**当我们创建子进程时,与其创建,分配并拷贝内容到新的物理内存,其实我们可以直接共享父进程的物理内存page。**所以这里,我们可以设置子进程的PTE指向父进程对应的物理内存page。

在这里插入图片描述
但是如果新创建的子进程指向父进程的相应物理地址空间,那么就会出现写冲突的情况,如果子进程对存储数据进行修改,那么父进程读取到的数据就会是被修改过后的数据。
再次要提及的是,我们这里需要非常小心。因为一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因为我们希望在父进程和子进程之间有强隔离性,所以这里我们需要更加小心一些。为了确保进程间的隔离性,我们可以将这里的父进程和子进程的PTE(page table entry)的标志位都设置成只读的。

在某个时间点,当我们需要更改内存的内容时,我们会得到page fault。因为父进程和子进程都会继续运行,而父进程或者子进程都可能会执行store指令来更新一些全局变量,这时就会触发page fault,因为现在在向一个只读的PTE写数据。

在得到page fault之后,我们需要拷贝相应的物理page。假设现在是子进程在执行store指令,那么我们会分配一个新的物理内存page,然后将page fault相关的物理内存page拷贝到新分配的物理内存page中,并将新分配的物理内存page映射到子进程。这时,新分配的物理内存page只对子进程的地址空间可见,所以我们可以将相应的PTE设置成可读写,并且我们可以重新执行store指令。实际上,对于触发刚刚page fault的物理page,因为现在只对父进程可见,相应的PTE对于父进程也变成可读写的了。

在这里插入图片描述
在发生page fault的时候会发生Trap,然后由相应的内核来进行处理。

cow在进程释放中的处理

似乎这样就很完美了,即处理了fork中造成的资源浪费,又解决了写冲突的问题。但是我们还是考虑漏了一点。
假如说运行在一个有着cow机制的机器上,并且我也通过父进程fork了一个新的子进程。如果此时我们释放掉父进程,此时的释放地址空间必须格外小心,现在有多个用户进程或者说多个地址空间都指向了相同的物理内存page,举个例子,当父进程退出时我们需要更加的小心,因为我们要判断是否能立即释放相应的物理page。如果有子进程还在使用这些物理page,而内核又释放了这些物理page,我们将会出问题。那么现在释放内存page的依据是什么呢?

我们需要对于每一个物理内存page的引用进行计数,当我们释放虚拟page时,我们将物理内存page的引用数减1,如果引用数等于0,那么我们就能释放物理内存page。所以在copy-on-write 中,需要引入一些额外的数据结构或者元数据信息来完成引用计数。

cow的PTE标志

在这里插入图片描述
当发生page fault时,我们其实是在向一个只读的地址执行写操作。内核如何能分辨现在是一个copy-on-write fork的场景,而不是应用程序在向一个正常的只读地址写数据。是不是说默认情况下,用户程序的PTE都是可读写的,除非在copy-on-write fork的场景下才可能出现只读的PTE?

内核必须要能够识别这是一个copy-on-write场景。几乎所有的page table硬件都支持了这一点。我们之前并没有提到相关的内容,下图是一个常见的多级page table。最后两位RSW。这两位保留给supervisor software使用,supervisor softeware指的就是内核。内核可以随意使用这两个bit位。所以可以做的一件事情就是,将bit8标识为当前是一个copy-on-write page。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值