写时复制


写入时复制(英语:Copy-on-write,简称COW)是一种计算机程式设计领域的优化策略。

数据存储中的写时复制

OS 领域 copy-on-write 核心思想则是 lazy copy,从而节省物理内存,加快进程创建。

参考维基百科对 copy-on-write 的定义:

其核心思想是,如果有多个呼叫者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同取得相同的指标指向相同的资源,直到某个呼叫者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该呼叫者,而其他呼叫者所见到的最初的资源仍然保持不变。这过程对其他的呼叫者都是透明的。此作法主要的优点是如果呼叫者没有修改该资源,就不会有副本(private copy)被建立,因此多个呼叫者只是读取操作时可以共享同一份资源。

Linux系统中写时复制机制的原理大概如下:

  • 调用 fork 系统调用创建子进程时,将父进程的 虚拟内存 与 物理内存 映射关系复制到子进程中,并将内存设置为只读(设置为只读是为了当对内存进行写操作时触发缺页异常),此时父子进程共享同一内存页。
  • 当子进程或者父进程对内存数据进行修改时,便会触发缺页异常, 在异常处理函数中实现了 写时复制 机制:将原来的内存页复制一份新的,并重新设置其内存映射关系,将对应的内存读写权限设置为可读写,供其修改。

写时复制机制

通过OS copy-on-write 的过程我们可以总结出两个重要的特性:

  1. 父子进程的内存共享的数据仅仅是fork那一时间点的数据,fork 后的数据不会有任何共享;
  2. 所谓 lazy copy,就是在需要修改的时候拷贝一个副本出来,如果没有任何改动,则不会占用额外的物理内存。

因此 copy-on-write 非常适合内存快照的 dump,例如 redis 的 rdb dump。至于为什么合适,我认为有如下几点:

  1. 考虑到 dump 的对象理应是某一时间点的内存快照信息,根据特性1,这里完美契合;
  2. dump 内存过程是耗时的,务必不能占用主线程资源,应当合理利用 CPU 多线程的优势,这里 fork 进程去处理 dump 任务本身就是理所应当的;
  3. 如果内存快照本身已经占用了50%以上的内存资源,如果不采用 copy-on-write 策略,显然无法 fork 出任何进程,因为没有足够的物理内存可以分配。
软件应用中的写时复制

在Java领域,copy-on-write只要解决读多写少场景下的并发问题。

多线程环境下非常容易出现 data race 的问题,我们以 Java 中的 ArrayList 为例,ArrayList 本身是不保证线程安全的,通常情况,要保证多线程环境下不出问题,就要给 ArrayList 加上读写锁,读要读锁,写要写锁,读与读之间不互斥,读与写之间要互斥,写与写之间也要互斥。

然而,对于读多写少的场景来说,频繁地读取必然导致频繁地加锁,而与写互斥的情况却很少出现,这似乎有点不『经济』,这时候 CopyOnWriteArrayList(以下简称 COWList) 就登场了。COWList 的总体设计思想是在读的过程中去掉了锁,而在写的过程中则需要引入互斥锁,但是这个锁不会影响到读本身,也就进一步释放了读的性能瓶颈

(只截取了部分 CopyOnWriteArrayList 代码片段 )

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

/**
 * {@inheritDoc}
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E get(int index) {
    return get(getArray(), index);
}
/**
 * Appends the specified element to the end of this list.
 *
 * @param e element to be appended to this list
 * @return {@code true} (as specified by {@link Collection#add})
 */
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

代码中可以看到三个重要的关键点:

  1. COWList 读操作是无锁的;
  2. COWList 写与写之间是互斥的;
  3. 底层持有的数组变量 array 是通过 volatile 修饰的。

JMM 的 happens-before 语义保证了 volatile 变量的内存可见性,这使得任意线程在任意时间点读取 array 变量的时候都能保证读到最新值;而写的过程中,除了互斥保证以外,还需要将 array 数组拷贝一个副本出来,对副本进行修改后再将该副本的引用赋值给 array 变量,以替换原先的引用,而这个替换过程是原子性的。

参考资料:

  1. 再谈 copy-on-write
  2. 写入时复制
  3. Linux 写时复制机制原理

文章作者: liuzhangjie
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 liuzhangjie !
  目录