Linux深入理解内存管理35(基于Linux6.6)---写时复制介绍
一、概述
缺页中断是内核在访问一个虚拟地址时,发现该虚拟地址所在的页没有一个物理页框与之相对应,就会触发缺页异常进入物理页面分配的异常函数。而写时复制是指,父进程在fork出子进程的时候,并不会为子进程马上分配物理页框,而是当复进程或者子进程需要对内存进行写操作时,才会分配物理页框,分配时,先将内容完全复制到新页框,然后再进行改写,称为写时复制。
写时复制(COW)是一种优化内存使用的技术,在计算机系统中广泛应用,尤其是在操作系统的内存管理中。COW 主要用于在进程创建时延迟数据的复制操作,只有在数据真正被修改时才会进行复制。这种方式能够显著提高效率,避免不必要的内存开销。以下是对 COW 技术的深入理解。
1. 写时复制的基本概念
COW 的基本思想是:当一个进程需要创建其地址空间的副本(例如通过 fork()
系统调用创建一个子进程)时,并不直接复制内存中的数据,而是让父子进程共享相同的内存页面。当其中一个进程尝试修改这些共享的页面时,操作系统才会真正地进行数据复制,使得每个进程拥有该页面的独立副本。这样,只有在进程修改内存内容时,才会发生实际的内存复制操作,因此可以显著节省内存资源。
2. COW 的应用场景
COW 技术在以下几种情境中非常有用:
-
进程创建:使用
fork()
系统调用创建子进程时,父子进程共享相同的内存页。子进程可以读取父进程的数据,但是只有在子进程修改数据时,才会触发写时复制,从而为子进程分配新的内存页。 -
内存映射文件:在文件映射(例如
mmap()
)时,如果文件以MAP_PRIVATE
模式映射到内存中,COW 可以用于将文件的内容加载到内存中而不立即复制。当进程尝试修改映射的文件内容时,操作系统会将文件的内存页复制到进程的私有区域,以实现修改不会影响其他进程。 -
虚拟内存管理:COW 还广泛应用于操作系统的虚拟内存管理中,特别是在分配内存的过程中。COW 可以帮助操作系统更高效地管理内存页面,避免不必要的复制操作。
3. COW 在 fork()
系统调用中的应用
在 Linux 系统中,fork()
调用用于创建一个子进程。传统的方式是父进程的内存页面会被直接复制到子进程中。然而,复制整个进程的内存空间是非常耗费资源的,尤其是当大部分内存内容在子进程中不被修改时。为了优化这个过程,Linux 内核使用了写时复制(COW)技术。
进程间内存共享:
- 在
fork()
调用后,父子进程的内存页面会被标记为只读,并且被父子进程共享。此时,父子进程对这些页面的任何访问(读操作)都不会发生任何实际的内存复制。
修改触发 COW:
- 当父进程或子进程尝试修改共享的内存页面时,由于这些页面是只读的,操作系统会检测到写操作并触发页面异常。此时,内核会为正在修改的进程分配该页面的独立副本,保证该进程拥有该页面的私有副本,且不会影响其他进程。
结果:
- 内存节省:父子进程在没有进行修改操作时,共享相同的内存页面。只有在修改时才会发生实际的复制,这样可以节省内存开销。
- 高效的进程创建:由于避免了不必要的内存复制,
fork()
系统调用的性能大幅提升。
4. COW 在 mmap()
文件映射中的应用
mmap()
系统调用允许进程将文件映射到内存空间。当使用 MAP_PRIVATE
标志时,操作系统会将文件内容映射到进程的虚拟内存空间,并采用写时复制技术。具体来说,内存中的文件数据最初是共享的,只有当进程修改映射的内存时,操作系统才会为进程复制该页面,修改的内容仅对当前进程可见。
过程示例:
- 进程使用
mmap()
映射文件。 - 文件的内容被加载到内存中,进程可以读取数据,但不会影响磁盘上的文件。
- 如果进程修改了映射的内存(例如,修改文件内容),操作系统会为该页面创建一个私有副本。
- 进程继续修改该副本,而原始文件内容保持不变。
二、写时复制原理
在传统的unix操作系统中,当执行fork系统调用时,内核复制父进程的整个用户空间并把复制得到的那一份分配给子进程,这种行为就非常耗时,这种子进程复制父进程所有资源的方法,有一些很明显的弊端:
- 大量使用内存。
- 复制操作耗费大量时间。
- 通常情况下子进程不需要读或者写父进程拥有的所有资源,复制的资源都没有得以有效的使用。
因此现代操作系统采用写时复制技术(Copy On Write, COW)进行优化,避免fork时将父进程所有资源都复制到子进程中,该技术的核心思想是:
只有在不得不复制的内容时才会去复制内容,其遵循原则为:
- 只需要复制父进程的进程地址空间页表到子进程,那么父子进程就共享相同的物理内存。
- 当父子进程中有一方需要修改某个物理页面的内容时,触发写保护的缺页中断,然后才复制共享页面的内容,从而使父子进程拥有各自的副本。
- 写时复制是一种可以推迟甚至避免复制数据的技术,在现代操作系统中广泛使用,节省了巨大的拷贝开销。
三、写时复制应用实例
**fork()**函数是一个神奇的函数,调用一次,会返回两次,在这个过程中子进程和父进程是共享一个内存空间的
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
char c = 'a';
int a = 2;
int pid = fork();
if(pid == -1){
return (-1);
}
if(pid > 0){
c = 'v';
printf("Hi,Father Pid:%d &c:%p c:%c\n",getpid(),&c,c);
printf("Hi,Father Pid:%d &a:%p a:%d\n",getpid(),&a,a);
return (0);
} else {
printf("Hi,Child Pid:%d &c:%p c:%c\n",getpid(),&c,c);
printf("Hi,Father Pid:%d &a:%p a:%d\n",getpid(),&a,a);
return (0);
}
}
可以看到,父进程中对资源 c 进行了修改,并打印了资源的地址和值,然后在子进程中也打印资源的值。对于父子进程,其对应的虚拟地址空间都没有变化,只是其对应的物理地址空间发生了变化,其变化为:
当变量c发生写改变的时候,其虚拟地址对应的物理页面重新申请了一个页面发生映射关系。下面我们来看看内核通过fork创建子进程的时候,是如何与父进程共享内存资源的,其入口为kernel/fork.c
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif
对于copy_page_range函数拷贝父进程页表,那需要拷贝那些页表呢?
- copy_pud_range,页表上级目录
- copy_pmd_range,页中间目录
- copy_pte_range,拷贝页表
对于拷贝页表条目这个函数,其中主要的是为父子进程设置为只读的页项,ptep_set_wrprotect(src_mm, addr, src_pte)将父进程的页表项修改为只读, pte = pte_wrprotect(pte)将子进程的即将写入的页表项值修改为只读(注意:修改之前pte为父进程原来的pte值,修改之后子进程pte还没有写入到对应的页表项条目中!)
/*
* If it's a COW mapping, write protect it both
* in the parent and the child
*/
if (is_cow_mapping(vm_flags)) { //vma为私有可写 而且pte有可写属性
ptep_set_wrprotect(src_mm, addr, src_pte);//设置父进程页表项为只读
pte = pte_wrprotect(pte);//为子进程设置只读的页表项值
}
上面的代码块是判断当前页所在的vma是否是私有可写的属性而且父进程页表项是可写
static inline bool is_cow_mapping(vm_flags_t flags)
{
return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}
以上fork就完成了对于需要写时复制的页,将父进程的页表设置为只读,并共享相同的物理页,为后面的COW缺页做好页表级别的准备工作。
四、写时复制流程
对于fork仅仅是对COW共享的页面做了只读访问,父子进程可以通过各自的页表就能直接访问到对应的数据,看似一切正常。但是一旦双方有一方需要去写,就会触发处理器异常,处理器就会走到COW异常处理中。之前在handle_pte_fault中有相关的触发条件:
if (fe->flags & FAULT_FLAG_WRITE) { //vma可写
if (!pte_write(entry)) //页表项属性只读
return do_wp_page(fe, entry); //处理cow
entry = pte_mkdirty(entry);
}
程序走到上面的判断说明:页表项存在,物理页存在内存,但是vma是可写,pte页表项是只读属性(这就是fork的时候所作的准备),这些条件也是COW缺页异常判断的条件。
当触发COW的时候,就会调用do_wp_page,其处理流程为:
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct folio *folio;
VM_BUG_ON(unshare && (vmf->flags & FAULT_FLAG_WRITE));
VM_BUG_ON(!unshare && !(vmf->flags & FAULT_FLAG_WRITE));
if (likely(!unshare)) {
if (userfaultfd_pte_wp(vma, *vmf->pte)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
return handle_userfault(vmf, VM_UFFD_WP);
}
/*
* Userfaultfd write-protect can defer flushes. Ensure the TLB
* is flushed in this case before copying.
*/
if (unlikely(userfaultfd_wp(vmf->vma) &&
mm_tlb_flush_pending(vmf->vma->vm_mm)))
flush_tlb_page(vmf->vma, vmf->address);
}
vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);
if (!vmf->page) {
if (unlikely(unshare)) {
/* No anonymous page -> nothing to do. */
pte_unmap_unlock(vmf->pte, vmf->ptl);
return 0;
}
/*
* VM_MIXEDMAP !pfn_valid() case, or VM_SOFTDIRTY clear on a
* VM_PFNMAP VMA.
*
* We should not cow pages in a shared writeable mapping.
* Just mark the pages writable and/or call ops->pfn_mkwrite.
*/
if ((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
(VM_WRITE|VM_SHARED))
return wp_pfn_shared(vmf);
pte_unmap_unlock(vmf->pte, vmf->ptl);
return wp_page_copy(vmf);
}
/*
* Take out anonymous pages first, anonymous shared vmas are
* not dirty accountable.
*/
folio = page_folio(vmf->page);
if (folio_test_anon(folio)) {
/*
* If the page is exclusive to this process we must reuse the
* page without further checks.
*/
if (PageAnonExclusive(vmf->page))
goto reuse;
/*
* We have to verify under folio lock: these early checks are
* just an optimization to avoid locking the folio and freeing
* the swapcache if there is little hope that we can reuse.
*
* KSM doesn't necessarily raise the folio refcount.
*/
if (folio_test_ksm(folio) || folio_ref_count(folio) > 3)
goto copy;
if (!folio_test_lru(folio))
/*
* Note: We cannot easily detect+handle references from
* remote LRU pagevecs or references to LRU folios.
*/
lru_add_drain();
if (folio_ref_count(folio) > 1 + folio_test_swapcache(folio))
goto copy;
if (!folio_trylock(folio))
goto copy;
if (folio_test_swapcache(folio))
folio_free_swap(folio);
if (folio_test_ksm(folio) || folio_ref_count(folio) != 1) {
folio_unlock(folio);
goto copy;
}
/*
* Ok, we've got the only folio reference from our mapping
* and the folio is locked, it's dark out, and we're wearing
* sunglasses. Hit it.
*/
page_move_anon_rmap(vmf->page, vma);
folio_unlock(folio);
reuse:
if (unlikely(unshare)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
return 0;
}
wp_page_reuse(vmf);
return VM_FAULT_WRITE;
} else if (unshare) {
/* No anonymous page -> nothing to do. */
pte_unmap_unlock(vmf->pte, vmf->ptl);
return 0;
} else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
(VM_WRITE|VM_SHARED))) {
return wp_page_shared(vmf);
}
copy:
/*
* Ok, we need to copy. Oh, well..
*/
get_page(vmf->page);
pte_unmap_unlock(vmf->pte, vmf->ptl);
#ifdef CONFIG_KSM
if (PageKsm(vmf->page))
count_vm_event(COW_KSM);
#endif
return wp_page_copy(vmf);
}
通过vm_normal_page函数查找缺页异常地址(addr)对应页面的page数据结构,如果返回的page时一个NULL指针,说明这是一个特殊映射的页面。
- 若发生缺页异常的页面是一个特殊页面,即vm_normal_page返回位NULL,如果VMA的属性是可写并且是共享的,那么就调用wp_pfn_shared。
- 若发生的缺页异常的页面是一个不可写的共享页面,那么跳转到wp_page_copy。
- 使用PageAnon宏来判断匿名页面,判断当前页面是否位KSM的匿名页面,如果是就执行1的内容,
- trylock_page判断当前的是否已经加锁,如果返回false,说明这个页面已经被别的进程加锁,就会释放锁,然后调用pte_offset_map_lock获取PTE,然后判断PTE是否发生变化,若发生变化,就退出异常处理
- 如果没有加锁,就调用reuse_swap_page函数判断页面是否只有一个进程映射的匿名页面,如果是,就跳转到wp_page_reuse,继续使用这个页面并且不需要写时复制
- 如果处理的是可写的共享页面,就使用wp_page_shared
- c处理写时复制的情况,就调用wp_page_copy
对于do_wp_page函数处理非常多的情况,其总的处理流程如下图所示:
五、总结
写时复制机制的整个过程如下:
- 首先发生在父进程fork子进程的时候,父子进程会共享所有的物理页面(此时通过将页映射到每个进程页表形成共享)
- 将父子进程对应的页表项修改为只读,当有一方试图写共享物理页的时候,由于页表项属性是只读,所以会发生COW缺页异常
- 缺页异常会为写操作的一方重新分配一个新的物理页,并将原来共享的物理页内容拷贝到新页,然后建立新页的页表映射关系
- 最终写进程就可以继续执行,并不会影响另外一方,父子进程对共享的私有页面访问就分道扬镳
梳理之前的例子,得到COW的整个过程如下:
写时复制(Copy-on-Write,COW)是一种内存管理优化技术,其核心思想是推迟内存的复制操作,只有在数据发生修改时才进行复制。以下是 COW 的流程总结:
1. 初始状态:共享内存页面
- 进程创建:当一个新进程通过
fork()
创建时,操作系统会让父进程和子进程共享相同的内存页面。这些页面通常是只读的,意味着它们不能被修改。 - 内存映射:在
mmap()
文件映射操作中,文件的内容也会被映射到内存。初始时,映射的内存页面是共享的。
2. 读取操作:无复制
- 当父进程或子进程读取共享的内存页面时,不会发生任何复制操作。
- 操作系统允许父子进程共享同一页面,读取操作不会引起任何变化。
3. 写入操作:触发复制
- 写入尝试:当父进程或子进程尝试修改共享的内存页面时,操作系统会发现写操作违反了页面的只读限制,触发页面故障(Page Fault)。
- 复制操作:操作系统会为正在写入的进程创建该页面的独立副本。只有此时,内存页面才会被复制。此后,该进程对该页面的所有修改都不会影响到另一个进程。
- 写时复制:复制操作是“懒复制”或“延迟复制”的过程,只有在必要时才进行复制,减少了不必要的资源浪费。
4. 修改后的状态:私有内存页面
- 独立副本:一旦内存页面被复制,每个进程将持有该页面的独立副本,之后对该副本的修改不会影响其他进程的内存。
- 共享与私有:原来共享的内存页面不再共享,改为进程各自持有的私有页面。
5. 总结:优化内存使用
- 在未修改的情况下,COW 允许多个进程共享同一内存页面,从而节省内存。
- 只有在实际修改时,才会发生内存复制,因此提升了内存使用效率和进程创建性能。
示例流程
假设有一个父进程 P1 和子进程 P2:
- 父进程 P1 调用
fork()
创建子进程 P2,操作系统为父子进程分配相同的内存页面,且这些页面是只读的。 - 父子进程都可以读取这些共享的内存页面,但不会进行复制。
- 当父进程或子进程尝试写入这些共享页面时,会触发写时复制机制:
- 假设父进程 P1 尝试写入该页面,操作系统会为 P1 创建该页面的副本,并将写操作应用到副本上。
- 之后,P1 对该页面的修改仅对 P1 可见,P2 仍然访问原来的内存(如果未进行写操作)。
- 若子进程 P2 也进行写操作,操作系统会为 P2 创建该页面的另一个副本,并进行修改,避免影响 P1。