写时复制(Copy-on-write)

  1. 需求和目标
  2. 几个应用场景
    1. 进程和虚拟内存
      1. 原理
    2. c++98的string类
    3. Qt隐式共享
    4. 多线程对只读对象的访问

最近面试被问到了写时复制(cow)的概念,顺便在这里整理一下,简单说说写时复制的设计理念和使用场景,暂时不会太深入技术实现,技术部分的介绍有机会再去单开一章。

需求和目标

本质上写时复制是一个针对内存资源管理的技术,用以提高内存的使用效率和响应速度。当一个或多个程序单元使用另一个程序单元进行初始化时,一个简单的方式就是对每个单元都做一份完全拷贝,保证内存的互相独立,使用起来互不干扰。但是某些情况下,新的单元可能不会对内存进行修改,又或者需要重新分配,这时这种完全的拷贝策略就展现出弊端了:针对第一种情况,当数据规模变大时,内存中会存在大量的重复的数据,造成内存资源的浪费;而第二种情况,则使得最初的分配资源和拷贝操作完全没有意义,还拖慢了初始化速度。

写时复制策略的出现刚好解决了上面的问题:初始化时可以简单的让他们共享同一个内存资源,只有当其中一个单元对内容进行修改时,才会触发对内容的复制。优势也是显而易见的:

  • 有效避免内存资源的浪费,减少了一部分复制的时间开销
  • 提高初始化速度

几个应用场景

进程和虚拟内存

在linux系统中,fork(以及一些它的变体)是创建新进程的唯一方式。当父进程调用fork()创建子进程时会默认创建当前进程的副本,此时子进程可以选择与父进程执行同样的程序,也可以选择调用exec()去执行其他的程序。

在这个场景中,写时复制是应用在创建进程副本的阶段,新创建的子进程最初会共享父进程的所有内存,直到两者之一想要修改部分内存页,则这块内存页就会被复制并重新映射给当前的修改进程,以确保修改的内存不会互相影响。

在这里应用写时复制主要是有两个方面的考虑:

  • 当子进程选择执行与父进程相同的程序时,内存中有很多数据是可以共享的,部分内存可能整个程序周期中都不会被修改,这部分内存的共享可以提高内存资源的利用率。而针对可能被修改的内存,将复制的操作移动到对内存修改时进行,也可以有效的加快创建子进程的速度。

  • 当子进程选择执行新的程序时,对父进程的复制那就是妥妥的浪费了,既浪费了时间又浪费了空间。
    所以写时复制带来的好处就是:

  • 加快了子进程的创建。

  • 减少进程对物理内存的使用。

原理

我们知道进程利用页表记录虚拟内存与物理内存的映射,同时页表中还记录了物理内存页的被进程映射的次数,以及访问标志。当创建新的进程时,子进程使用与父进程一样的映射,但是同时会将可修改的内存页标记为只读。当任一进程尝试修改这部分内存时,操作系统会重新分配一个新的物理内存页,拷贝后将其映射到当前进程的虚拟内存页上,将原物理内存页的引用计数减一(如果只有一个引用的话就不需要重新分配内存页和更新计数),页面标记为可修改,并继续执行写入操作。

根据写时复制的原理可以看到,并不一定是子进程得到复制的内存页,也有可能是父进程。
只有可修改的数据段会用到写时复制,代码段和只读数据段不存在修改的可能。

c++98的string类

一个c++标准库中被遗弃了的实现,但是依然可以拿出来分析一下。

早期的c++ string的初始化使用的就是写时复制的设计,内部维护一个指针和引用计数,引用计数为零时表示只有当前变量引用了这部分内存。在进行string初始化操作时,只是复制了指向原字符串内存的指针,并增加引用计数,只有当调用可能修改字符串的操作时(如[]操作)才会复制。

对于const变量,通过[]进行数据读取是不会触发内存分配和复制的。而对于非const变量,[]操作就会触发内存分配和复制,编译器无法判断这种情况下[]是准备读还是写,只能同等处理。

cow实现的string可以解决的一个问题就是局部变量返回值传递,即对资源进行了转移,避免了内存的分配、复制和销毁操作。但是在c++11推出右值引用和移动语义后,这个问题就不再是问题了。

Qt隐式共享

​Qt中的许多C++类使用隐式数据共享来提高资源使用并减少数据复制。当这些类作为参数传递时,因为只传递一个指向数据的指针,并且只有当函数写入数据时数据才会被复制,即copy -on-write,隐式共享类是安全、高效的。

  • 共享类由一个指向包含引用计数和数据的共享数据块的指针组成。

  • 当创建共享对象时,它将引用计数设置为1。每当有新对象引用共享数据时,引用计数就递增,当对象解引用共享数据时,引用计数就递减,当引用计数变为零时,将删除共享数据。

​ 在处理共享对象时,有两种方法复制对象。也就是经常谈到的:深度拷贝和浅拷贝。深度拷贝意味着复制一个对象,浅拷贝是一个引用拷贝,也就是一个指向共享数据块的指针。站在内存和CPU角度,执行一个深度拷贝可能是昂贵的操作,执行浅拷贝则非常快,因为浅拷贝只涉及设置指针和增加引用计数。

注意:隐式共享对象的对象赋值(operator=())是使用浅拷贝实现的。

多线程对只读对象的访问

另一个合适的场景就是多线程对只读对象的访问,多个线程共享,且单个线程中销毁对象并不会对其他线程产生影响。当然在c++11中也有更好用的工具,shared_ptr。

对于其他的使用场景,cow基本上就没有优势了。在常规的初始化+修改的场景中,将复制操作移动到修改时刻进行并没有带来多少性能和内存效率上的提升,同时引用计数的存在也增加了一些开销。而对于一些只读的场景,比如在函数中对参数进行const访问,使用const引用的效率更高。

所以总结下来,cow的实现在现代c++中已经无法提供效率上的提升,自然而然被新的标准遗弃了。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1430797759@qq.com

文章标题:写时复制(Copy-on-write)

字数:1.9k

本文作者:花落阁

发布时间:2024-05-21, 14:20:06

最后更新:2024-05-21, 14:26:45

原始链接:https://hualog.dns.navy/2024/05/21/%E5%86%99%E6%97%B6%E5%A4%8D%E5%88%B6%EF%BC%88Copy-on-write%EF%BC%89/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。