写入时复制(英语:Copy-on-write,简称COW)是一种计算机程式设计领域的优化策略。
数据存储中的写时复制
OS 领域 copy-on-write 核心思想则是 lazy copy,从而节省物理内存,加快进程创建。
参考维基百科对 copy-on-write 的定义:
其核心思想是,如果有多个呼叫者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同取得相同的指标指向相同的资源,直到某个呼叫者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该呼叫者,而其他呼叫者所见到的最初的资源仍然保持不变。这过程对其他的呼叫者都是透明的。此作法主要的优点是如果呼叫者没有修改该资源,就不会有副本(private copy)被建立,因此多个呼叫者只是读取操作时可以共享同一份资源。
Linux系统中写时复制机制的原理大概如下:
- 调用 fork 系统调用创建子进程时,将父进程的 虚拟内存 与 物理内存 映射关系复制到子进程中,并将内存设置为只读(设置为只读是为了当对内存进行写操作时触发缺页异常),此时父子进程共享同一内存页。
- 当子进程或者父进程对内存数据进行修改时,便会触发缺页异常, 在异常处理函数中实现了 写时复制 机制:将原来的内存页复制一份新的,并重新设置其内存映射关系,将对应的内存读写权限设置为可读写,供其修改。
通过OS copy-on-write 的过程我们可以总结出两个重要的特性:
- 父子进程的内存共享的数据仅仅是fork那一时间点的数据,fork 后的数据不会有任何共享;
- 所谓 lazy copy,就是在需要修改的时候拷贝一个副本出来,如果没有任何改动,则不会占用额外的物理内存。
因此 copy-on-write 非常适合内存快照的 dump,例如 redis 的 rdb dump。至于为什么合适,我认为有如下几点:
- 考虑到 dump 的对象理应是某一时间点的内存快照信息,根据特性1,这里完美契合;
- dump 内存过程是耗时的,务必不能占用主线程资源,应当合理利用 CPU 多线程的优势,这里 fork 进程去处理 dump 任务本身就是理所应当的;
- 如果内存快照本身已经占用了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();
}
}
代码中可以看到三个重要的关键点:
- COWList 读操作是无锁的;
- COWList 写与写之间是互斥的;
- 底层持有的数组变量 array 是通过 volatile 修饰的。
JMM 的 happens-before 语义保证了 volatile 变量的内存可见性,这使得任意线程在任意时间点读取 array 变量的时候都能保证读到最新值;而写的过程中,除了互斥保证以外,还需要将 array 数组拷贝一个副本出来,对副本进行修改后再将该副本的引用赋值给 array 变量,以替换原先的引用,而这个替换过程是原子性的。
参考资料: