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

本文探讨了懒加载和写时复制两种内存管理技术。懒加载通过延迟内存分配直到真正需要时,有效减少了不必要的内存占用。写时复制则在进程复制时共享原有内存,仅在需要写入时才进行副本创建,显著提高了资源利用率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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。

<think>好的,我现在要回答用户关于Linux内核中sbrk系统调用的具体实现细节的问题。首先,我需要回忆一下sbrk系统调用的基本功能。sbrk用于调整程序的数据段大小,通过改变堆的结束位置来分配或释放内存。在Linux中,通常使用brksbrk来管理堆内存,但现代程序更多使用malloc等库函数,而底层还是依赖brksbrk。 接下来,我需要确定sbrk在内核中的实现位置。Linux内核的系统调用一般实现在kernel目录下的相关文件中,比如sys.c或者与内存管理相关的部分。brk系统调用的实现可能涉及到进程地址空间的管理,所以应该查找内存管理相关的代码。 根据之前的知识,brk系统调用的入口可能在mm/mmap.c文件中,因为内存映射相关的操作在这里处理。当用户调用brk时,内核会调整进程的堆区域,这涉及到虚拟内存区域的扩展或收缩。具体来说,do_brk函数可能负责实际的地址空间调整。这里需要确认do_brk是否处理了brk系统调用的请求。 另外,sbrk函数通常是对brk的封装,通过传递增量参数来调整堆的位置。例如,sbrk(n)会增加堆的末尾地址n个字节。在内核中,系统调用brk的参数是新堆的结束地址,而sbrk则通过当前brk值加上增量来计算新的地址。因此,sbrk的实现可能依赖brk系统调用。 还需要考虑权限边界条件。内核在调整堆的时候需要检查地址是否合法,是否与其他内存区域重叠,是否有足够的空间等。例如,如果新的brk值低于初始值或者超过进程的最大地址空间,应该返回错误。此外,扩展堆时可能需要分配物理页面,并更新页表。 参考资料方面,xv6的实现可能提供一些参考,比如在xv6中,sys_sbrk函数会调用growproc来调整进程的内存大小。不过xv6是教学用的简化内核,Linux的实现会更复杂。例如,Linux使用虚拟内存区域(VMA)来管理不同的内存段,调整堆时会修改对应的VMA。 另外,用户提供的引用中提到xv6的系统调用实现,比如通过中断int $0x80进入内核,以及处理系统调用时的错误情况,如访问非法地址导致内核panic。虽然这是xv6的情况,但Linux的处理机制可能类似,但更健壮,比如处理地址错误时不会panic,而是返回错误码。 总结一下,sbrkLinux内核中的实现步骤大致是:用户调用sbrk库函数,库函数调用brk系统调用,传递新的堆结束地址;内核的sys_brk函数验证地址的合法性,调整进程的VMA,必要时分配或释放物理内存,并返回新的堆结束地址。 可能需要注意的点包括:内存对齐、并发情况下的锁机制、地址空间的检查(比如是否在数据段之后、栈之前),以及如何处理失败情况,如内存不足。此外,现代Linux可能更倾向于使用mmap来分配大块内存,而brk用于小块的堆扩展,因此sbrk的实现可能mmap机制有所交互。 最后,生成相关问题时要围绕sbrkbrk的实现、内存管理、系统调用机制、错误处理等方面展开,帮助用户深入理解相关知识点。</think>sbrk系统调用Linux内核中通过调整进程的堆边界实现内存分配。其核心实现涉及以下步骤: 1. **系统调用入口** sbrk的功能通过brk系统调用实现,用户空间的`sbrk()`函数是对`brk()`的封装。系统调用号为$SYS_brk$,在内核中对应`sys_brk`函数。 2. **地址空间管理** 内核通过`mm->brk`记录当前堆顶地址。当调用`brk(new_brk)`时,内核会: - 验证地址合法性:确保$new\_brk$在数据段栈之间 - 调用`do_brk()`函数完成虚拟内存区域(VMA)的扩展/收缩[^3] 3. **物理内存分配** 扩展堆时通过缺页中断实现物理页的按需分配,收缩堆时通过`vm_munmap()`释放物理页。关键代码路径: ```c // mm/mmap.c unsigned long sys_brk(unsigned long brk) { struct mm_struct *mm = current->mm; if (brk < mm->start_brk || brk > mm->start_stack) return mm->brk; // 非法地址返回当前brk do_brk(mm->brk, brk - mm->brk); // 调整VMA return mm->brk = brk; } ``` 4. **并发控制** 使用`mm->mmap_lock`读写信号量保护地址空间修改操作,防止竞态条件[^1]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值