不会产奶的COW(Copy-On-Write)

COW基本定义

写入时复制(Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时要求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

COW的处理过程中需要维持一个为读请求使用的“指针”,并在新数据写入完成后更新这个指针以提升读写并发能力。因此,COW也间接提供了数据更新过程中的原子性。在保证数据的完整性同时还保证了一定的读写效率,下面让我们针对一些采用COW的场景看看处理流程上的差异。

使用场景

在文件系统中的应用

数据的备份或者复制至少处于硬件(硬盘)和操作系统两个层级。硬件备份一般使用RAID0-10的方案,保护硬盘中的数据安全;操作系统中则使用Snapshot(快照)技术来维持数据的安全和有效。人们一直采用数据复制、备份、恢复等技术来保护重要的数据信 息,定期对数据进行备份或复制。由于数据备份过程会影响应用性能,并 且非常耗时,因此数据备份通常被安排在系统负载较轻时进行(如夜 间)。另外,为了节省存储空间,通常结合全量和增量备份技术。为了解决性能和持续运行的问题引入了Snapshot,Snapshot的实现又主要分为split mirror、changed block和current三大类,具体实现方式中会有部分依赖COW技术。

COW快照写操作基本流程:
1. 读取原始数据
2. 把原始数据复制到一块新的未被使用的快照空间
3. 把新数据写入到原始位置

《不会产奶的COW(Copy-On-Write)》 写时复制快照

基本的处理流程比较简单,由于只复制了被更新部分数据,整个流程处理十分快速。在写操作的同时所有的读请求都是通过逻辑访问所有原始数据,包括被拷贝到新空间的部分,而不会读到正在写入的数据。

尽管COW简单高效,但是也存在一些问题,比如:
1. 数据复制过程带来了更多得io消耗(实际应用中可能更倾向于使用Redirect-on-write);
2. 快照只保存了被修改部分的数据,并没有完整的数据备份;
3. 如果新写入的数据超过旧数据占用的空间也会导致一系列复杂处理,最终导致快照失效;
4. 只能应对单一的数据处理,多重复杂结构数据的修改需要特殊处理

除了COW,常见的快照技术还有Log-structuredfilearchitecture(日志文件架构)、Copyonwritewithbackgroundcopy(克隆快照)、Continuous data protection(持续数据保护)Split Mirror(镜像分离)Pointer Remapping(指针重映射)等等,特性对比参见下表

镜像分离指针重映射写时复制日志文件克隆快照持续数据保护
快照是否依赖源数据卷NO 镜像包含完整的数据副本YES 未变化的数据从源数据卷访问YES 未变化数据从源数据卷访问YES 未变化数据从源数据卷访问ONLY 仅在后台拷贝未被完成时YES 除了包含源数 据副本实现外
空间效率NO 要求源数据卷相同容量存储空间YES 大多数据情况下要求变化数据存储空间YES 大多数据情况下要求变化数据存储空间YES 要求变化数据存储空间NO 要求源数据卷相同容量存储空间YES 存储空间需求取决于保存变化数据的数据和频率
源数据卷系统CPU和I/O负载LOW/HIGH 镜像分离后低,分离前数据同步高HIGH/NONE软件型快照高,硬件型快照无HIGH/NONE软件型快照高,硬件型快照无HIGH对写操作进行日志时高LOW一般由存储子硬件执行具体实现相关
源数据卷写负载NONE写负载发生在分离前NONE写直接重定向至新块HIGH首次写产生额外写负载HIGH写操作必须进行日志HIGH拷贝完成前的首次写产生写负载HIGH每次写操作导致相应的写操作
逻辑数据错误保护机制YES数据必须从镜像卷拷贝,变化没有记录,速度较慢YES数据变化可以回滚或者同步至源数据源YES数据变化可以回滚或者同步至源数据源YES数据变化可以回滚YES可以反向建立快照,由于仅复制变化数据块,速度较快YES数据变化可以同步至原始数据副本
源数据卷物理介质故障保护机制YES镜像卷是完整副本NONE有效源数据卷必须存在NONE有效源数据卷必须存在NONE 有效源数据卷必须存在YES后台复制完成后完全保护具体实现相关

软件和web系统中也经常出现COW的使用场景,比如数据备份和并发环境下的读写性能力提升。

软件和web系统中的应用

linux中创建轻量级的子进程

我们都知道,进程是操作系统中比较昂贵的资源,它具有自己的数据和程序。

传统方式下,fork()函数在创建子进程时直接把所有资源复制给子进程,即:正文段块,数据段块,堆块,栈块。这种实现方式简单,但是效率低下,而且复制的资源可能对子进程毫无用处。linux为了降低创建子进程的成本,改进fork()实现方式使用COW技术创建子进程。当父进程创建子进程时,内核只为子进程创建虚拟空间,父子两个进程使用的是相同的物理空间。只有父子进程发生更改时才会为子进程分配独立的物理空间

《不会产奶的COW(Copy-On-Write)》 fork()函数

上图展示从P1进程创建子进程P2时的物理空间使用状况。通过COW技术,fork()延迟了数据拷贝,根据子进程的实际操作最终可能完全避免数据复制,如:子进程创建后运行一个与当前数据无关的可执行文件。

redis中的COW

某些情况下,我们希望保存redis中的数据,那么只用一个主线程处理请求的redis是怎么在处理请求的同时进行数据持久化的呢?答案当然是依赖COW,具体来说就是依赖系统的fork()函数的COW实现。

redis有两种数据持久化策略:RDB快照和AOF日志。

RDB快照复制某一时刻redis内存中的所有数据保存在文件中,在数据备份程序被触发后redis会调用fork(),这样在轻松获取一份该时刻数据副本的同时,还可以允许主进程继续接受其它请求,读请求不会增加负担,只有写请求到达时才需要真正复制一份数据。RDB快照的一大缺点是可能丢失两次备份时刻之间产生的数据。

AOF日志弥补了RDB快照的不足,将每个收到的写命令都写入日志文件保证数据不会丢失。日志文件会随时间不停增长,为了解决过大的日志文件,redis提供了bgrewriteaof命令对日志进行压缩。bgrewriteaof命令也会调用fork()函数,利用子进程中的数据状态转化成redis命令并保存到新的aof日志中,之后同步父进程中缓存的新写命令,最后用新日志文件替换旧日志文件

可见,两种数据持久化策略都使用了COW。

jdk中的COW集合

java的基础api中也提供了基于COW的数据集合,但是与前面进行数据备份和降低数据复制开销的目的不同,java中的COW集合更偏向于提供并发能力。

CopyOnWriteArrayListCopyOnWriteArraySet都是使用在读多写少且数据总量不大的场景下,在保证多线程写的原子性的同时又避免了读的冲突和竞争,使用迭代器的时候也绝对不会抛出ConcurrentModificationException。

在集合内部,利用ReentrantLock同步多个写操作,锁竞争胜利进入同步代码段的程序经过已下几步完成数据修改:

1.复制原始数据
2.修改原始数据
3.将修改后的数据赋给集合数据引用
4.退出同步锁定区域

在写方法执行前和执行中的读操作都是直接取得旧数据的引用,由于迭代器的修改方法被禁用,所以迭代遍历数据的程序总是在使用一份不会改变的数据引用。因为写操作最后修改数据引用的操作是原子的,所以读操作不会错误的得到“部分修改”的数据。

linux内核中有一种叫做RCU(Read-Copy Update)数据共享策略跟COW十分相似,唯一不同在于RCU的新旧数据替换同过特定的回调(callback)机制被动执行,而不是由写操作进程自助完成,有兴趣的同学可以自己研究一下。

总结

copy-on-write是个古老,容易理解且比较高效的策略,可以在数据备份或者读多写少的场景下选择使用。同时它也存在一些先天缺点,数据复制过程中需要双倍的存储空间,如果涉及到IO读取那还会成倍消耗IO资源,使用过程中需要注意控制copy的范围。

有兴趣的同学可以自行阅读相关技术,扩展学习一下优化策略。如:Redirect-On-Write、Copy-On-First-Write、Allocate-On-Flush(also called delayed allocation)、RCU。

    原文作者:此时彼地
    原文地址: https://www.jianshu.com/p/b2fb2ee5e3a0
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞