写时拷贝技术
写时拷贝技术
在操作系统中,写时拷贝(Copy-On-Write,COW)是一种 优化内存使用 的策略,它延迟了数据的复制操作,直到 实际需要写入数据时才进行复制 。常用于需要 大量数据拷贝 的场景,以提高程序效率和降低资源消耗。
写时拷贝
核心思想
在多个调用者请求 相同资源 时,它们会共同获取 相同的指针 指向相同的资源。直到某个调用者试图 修改资源的内容 时,系统才会真正 复制一份专用副本 给该调用者,而其他 调用者所见到的资源 仍然 保持不变 。这个过程对其他调用者是透明的。
基本原理
基本过程
内存管理单元 (MMU)是实现写时拷贝的关键。当两个或多个进程 共享同一块内存区域 时,MMU 会记录该区域的 引用计数 ,并将其标记为 只读 。
只要没有进程对共享内存区域进行 写入操作 ,就可以一直共享这块内存,避免了不必要的数据拷贝,从而节省内存空间和数据复制的时间成本。
一旦有进程试图对共享内存区域进行 写入操作 ,MMU 会发现该区域被标记为只读,从而触发一个 页面错误异常 (page fault)。
操作系统内核会捕获这个异常,并根据 写时拷贝 策略进行处理。内核会为进行写入操作的进程分配新的 物理内存页 ,并 将原有共享内存页的内容复制到新的内存页 中。这样,进行写入操作的进程就可以在 新的内存页 上安全地进行 修改 ,而 不会影响其他共享该内存区域的进程 。
一个例子深入理解
fork创建子进程后,父子进程在内存层面是如何运转的呢???
当父进程调用fork()创建子进程时,内核会将父进程的所有内存页都标记为只读(即共享页面),并 增加每个页面的引用计数 。在这个过程中,父子进程共享同一份内存页面,可以大幅减少内存占用。
一旦其中一个进程(父进程或子进程)尝试写入某个内存页,就会触发一个保护故障(缺页异常),此时会陷入内核,内核将拦截这个写入操作,检查该页面的引用数:如果引用数 大于 1 ,则会 创建该页面的副本 ,并将引用数减 1,同时恢复这个页面的 可写权限 ,然后重新执行这个 写操作 ;如果页面的引用数只有 1,也就是说该页面只被当前进程引用,那么内核就可以跳过分配新页面的步骤, 直接修改该页面 ,将其标记为 可写 。
在一般情况下,当子进程通过写时复制机制创建了自己的内存页面副本后,这个副本 会一直与父进程的页面保持不一致,直到该子进程退出或被杀死 。
补充知识–引用计数
引用计数
在开辟的空间中 多维护四个字节 来存储引用计数。
两种方法:
①:多开辟四个字节(pCount)的空间,用来 记录有多少个指针指向这片空间 。
②:在开辟空间的头部 预留四个字节的空间 来记录有多少个指针指向这片空间。
当我们多开辟一份空间时,让引用计数+1,如果有释放空间,那就让计数-1 ,但是此时不是真正的释放,是 假释放 ,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数-1,并且真正开辟新的空间。
小总结
写实拷贝本质就是 等到修改数据时才真正分配内存空间 。这是对程序性能的优化,可以延迟甚至是避免内存拷贝,目的就是避免不必要的内存拷贝。
写时拷贝实现
宏观理解(进程、线程角度)
流程:资源共享->只读访问->写操作触发拷贝->独立修改
资源共享
当多个 进程或线程 需要 访问相同的数据 时,操作系统会将数据的 内存页标记为只读 ,并让这些进程或线程 共享同一块内存 。
只读访问
在没有进程或线程修改数据的情况下, 只有一份数据存储在内存中 ,所有共享者都只能进行 只读访问 。
写操作触发拷贝
当某个进程或线程尝试修改这块 共享数据 时,会触发一个 页面错误 (Page Fault),因为内存页被标记为只读。操作系统捕捉到这个错误后,会为该进程或线程 分配一块新的内存 ,并将原始数据拷贝到这块新内存中。
独立修改
经过拷贝操作后,修改数据的进程或线程拥有了数据的 独立副本 ,可以自由地对其进行修改,而 不会影响到其他共享者 。
拷贝过程如图所示:
微观理解(fork系统调用角度)
进程创建(fork)时的内存映射
虚拟内存映射:在 Linux 中,fork 系统调用创建子进程时,内核为子进程创建与父进程相同的 虚拟内存映射 。 虚拟内存是进程对内存的一种逻辑视图 , 每个进程都有自己独立的虚拟地址空间 。父子进程的虚拟地址空间布局相似,但并不直接对应到 相同的物理内存 。
页表设置:内核为子进程 构建页表 ,这些页表最初指向与父进程相同的 物理内存页面 。页表是一种数据结构,用于将 虚拟地址映射到物理地址 。此时,父子进程 共享 这些物理内存页面,但这些页面被标记为 只读 (read - only)。
补充知识–虚拟内存与物理内存
写操作触发拷贝
写保护异常:当父子进程中的任何一个试图对共享的只读物理内存页面进行 写操作 时,CPU 会检测到写保护异常(因为页面被标记为只读),将控制权交给操作系统内核。
内核处理:内核 捕获 到写保护异常后,识别出该页面是由于写时拷贝机制而被共享的。内核会为执行写操作的进程分配一个新的 物理内存页面 。这个新页面是原共享页面的 副本 。
更新页表: 内核更新执行写操作进程的页表 ,使其指向 新分配的物理内存页面 ,并将该页面标记为 可读写 (read - write)。这样,该进程就可以在新的页面上进行 写操作 ,而不会影响其他进程(如父子进程中的另一方)对原共享页面的访问。
内存管理优化
减少内存占用:通过写时拷贝技术,在 fork后多个进程可以 共享大量的物理内存页面 ,只有在真正需要 修改 数据时才会为进程分配额外的 物理内存 。这大大减少了内存占用,提高了内存利用率。
提高性能:由于减少了 fork时的内存复制操作,fork的执行效率得到提高。同时,写时拷贝技术也减少了 内存碎片 的产生,因为只有在必要时才分配新的物理内存页面,而不是在进程创建时就分配大量内存。
小总结
1、 在Linux系统中,调用fork()系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共享相同的内存页,但当子进程或父进程对内存页进行修改时才会进行复制。(换而言是只有进程空间的某页内存的内容发生修改时,才会将父进程的该页内存复制一份给子进程)
2、 fork之后,子进程的页表也指向父进程所指向的物理内存页面,并标记为只读!!!
这些共享页标记为写时拷贝,这意味着如果任何一个进程写入共享页面,那么就创建共享页面的副本。
原理如图所示:
虚拟内存管理中的写时拷贝
内存映射文件
当多个进程需要访问同一个文件时,可以通过内存映射技术将文件映射到 进程的地址空间 ,这些进程在 没有修改文件 的情况下 共享 相同的内存区域。例如,当进程A和进程B都映射同一个文件到它们的地址空间时,它们共享同一物理内存页面。如果进程A修改该文件的某个页面,操作系统会 触发页错误 ,将 该页面拷贝给进程A ,而进程B继续使用原始的共享内存页。
页面交换和调度
在虚拟内存管理中,COW可以用于 优化页面的交换和调度 。当多个进程共享相同的页面时,操作系统可以 推迟复制 ,直到有进程修改页面为止。比如 当进程需要访问的页面不在物理内存中时,操作系统会触发 缺页中断 ,此时如果该页面是共享的且未被修改过,操作系统可以从磁盘交换区/文件系统中重新加载该页面,而不会触发复制操作。
对象拷贝
当需要拷贝一个较大的对象时,如果直接进行深拷贝,可能会导致性能下降和内存占用增加。使用写时拷贝策略,可以在对象拷贝时,只是简单地 将指针指向相同的内存区域,并增加引用计数 。只有当对对象进行修改时,才会触发拷贝操作,真正分配新的内存并复制数据。
容器共享
多个容器(如 vector)可能共享同一块数据内存。在未进行写入操作之前,它们只是 共享同一数据,不产生实际的拷贝 。一旦对数据进行修改,则会触发拷贝,确保每个容器拥有自己的数据副本。
写时拷贝优点
节省内存 :在不需要对共享数据进行修改的情况下,多个进程或对象可以共享同一内存区域,大大减少了内存的使用量。
提高效率 :避免了在不需要修改数据时的不必要的数据拷贝操作,提高了程序的运行效率,尤其在涉及大量数据拷贝的场景中,效果更为明显。
总结
实际上写时拷贝技术十分应用广泛,Linux下fork()系统调用,string数据结构,智能指针shared_ptr都使用写时拷贝技术解决拷贝问题。
写时拷贝拷贝机制如图所示