【死磕 Java 基础】 — 谈谈那个写时拷贝技术(copy-on-write)

copy-on-write,即写时复制技术,这是小编在学习 Redis 持久化时看到的一个概念,当然在这个概念很早就碰到过(Java 容器并发有这个概念),但是一直都没有深入研究过,所以趁着这次机会对这个概念深究下。所以写篇文章记录下。

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

Linux 中的 copy-on-write

要理解 Linux 的 COW,必须要清楚两个函数 fork()exec(),其中 exec() 是一组函数的统称,包括 execl()execlp()execv()execle()execve()execvp()

fork()

fork() 是什么?它是 UNIX 操作系统中派生新进程的唯一方法,用于创建子进程,该子进程等同于其父进程的副本,他们具有相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,注意是在执行 exec() 之前。

fork() 函数有一个特点就是,它是 调用一次,返回两次,调用是在父进程中调用创建子进程,返回有两个值,一个是返回给父进程,返回值为新子进程的进程 ID 号,一个返回给子进程,返回值为 0,所以我们基本上就可以根据返回值判断当前进程是子进程还是父进程。

因为任何子进程只有一个父进程,我们可以通过调用 getppid 获取父进程的进程 ID,而父进程可以拥有多个子进程,所以 fork() 之后返回的就是子进程的进程 ID,这样它才能识别它的子进程。

exec()

fork() 创建的子进程其实就是父进程的副本,如果仅仅只是 fork 一个父进程副本其实没有多大意义,我们肯定希望的子进程能够干一些活,一些与父进程不一样的活,这个时候函数 exec() 就派上用场了。它的作用是 装载一个新的程序,覆盖当前进程内存空间中的映像,从而执行不同的任务

比如父进程要打印 hello world ,fork 出来的子进程将也是打印 hello world的。但是当子进程执行 exec() 后,就不一定是打印 hello world 了,有可能是执行 1 + 1 = 2。如下图:

关于 fork()exec() 的文章推荐如下:

fork 会产生和父进程完全相同的子进程,如果采用传统的做法,会直接将父进程的数据复制到子进程中去,子进程创建完成后,父进程和子进程之间的数据段和堆栈就完成独立了,按照我们的惯例,子进程一般都会执行与父进程不一样的功能,exec() 后会将原有的数据清空,这样前面的复制过程就会变得无效了,这是一个非常浪费的过程,既然很多时间这种传统的复制方式是无效的,于是就有了 copy-on-write 技术的,原理也是非常简单的:

fork 的子进程与父进程共享内存空间,如果子进程不对内存空间进行修改的花,内存空间的数据并不会真实复制给子进程,这样的结果会让子进程创建的速度变得很快(不用复制,直接引用父进程的物理空间)。

fork 之后,子进程执行 exec() 也不会造成空间的浪费。

如下:

在网上看到还有个细节问题就是,fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。

Copy On Write技术实现原理:

fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

Redis 中的 copy-on-write

我们知道 Redis 是单线程的,然后 Redis 的数据不可能一直存在内存中,肯定需要定时刷入硬盘中去的,这个过程则是 Redis 的持久化过程,那么作为单线程的 Redis 是怎么实现一边响应客户端命令一边持久化的呢?答案就是依赖 COW,具体来说就是依赖系统的 fork 函数的 COW 实现的。

Redis 持久化有两种:RDB 快照 和 AOF 日志。

RDB 快照表示的是某一时刻 Redis 内存中所有数据的写照。在执行 RDB 持久化时,Redis 进程会 fork 一个子进程来执行持久化过程,该过程是阻塞的,当 fork 过程完成后父进程会继续接收客户端的命令。子进程与 Redis 进程共享内存中的数据,但是子进程并不会修改内存中的数据,而是不断的遍历读取写入文件中,但是 Redis 父进程则不一样,它需要响应客户端的命令对内存中数据不断地修改,这个时候就会使用操作系统的 COW 机制来进行数据段页面的分离,当 Redis 父进程对其中某一个页面的数据进行修改时,则会将页面的数据复制一份出来,然后对这个复制页进行修改,这个时候子进程相应的数据页并没有发生改变,依然是 fork 那一瞬间的数据。

AOF 日志则是将每个收到的写命令都写入到日志文件中来保证数据的不丢失。但是这样会产生一个问题,就是随着时间的推移,日志文件会越来越大,所以 Redis 提供了一个重写过程(bgrewriteaof)来对日志文件进行压缩。该重写过程也会调用 fork() 函数产生一个子进程来进行文件压缩。

关于 Redis 的持久化,请看这篇文章:【死磕 Redis】---- Redis 的持久化

Java 中的 copy-on-write

熟悉 Java 并发的同学一定知道 Java 中也有两个容器使用了 copy-on-write 机制,他们分别是 CopyOnWriteArrayList 和 CopyOnWriteArraySet,他在我们并发使用场景中用处还是挺多的。现在我们就 CopyOnWriteArrayList 来简单分析下 Java 中的 copy-on-write。

CopyOnWriteArrayList 实现 List 接口,底层的实现是采用数组来实现的。内部持有一个私有数组 array 用于存放各个元素。

private transient volatile Object[] array;

该数组不允许直接访问,只允许 getArray()setArray() 访问。

    final Object[] getArray() {
        return array;
    }

    final void setArray(Object[] a) {
        array = a;
    }

既然是 copy-on-write 机制,那么对于读肯定是直接访问该成员变量 array,如果是其他修改操作,则肯定是先复制一份新的数组出来,然后操作该新的数组,最后将指针指向新的数组即可,以 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();
        }
    }

添加的时候使用了锁,如果不使用锁的话,可能会出现多线程写的时候出现多个副本。

读操作如下:

    public E get(int index) {
        return get(getArray(), index);
    }
    
    private E get(Object[] a, int index) {
        return (E) a[index];
    }

读操作没有加锁,则可能会出现脏数据。

所以 Java 中的 COW 容器的原理如下:

当我们在修改一个容器中的元素时,并不是直接操作该容器,而是将当前容器进行 copy,复制出一个新的容器,然后在再对该新容器进行操作,操作完成后,将原容器的引用指向新容易,读操作直接读取老容器即可。

它体现的也是一种懒惰原则,也有点儿读写分离的意思(读和写操作的是不用的容器)

这两个容器适合读多写少的场景,毕竟每次写的时候都要获取锁和对数组进行复制处理,性能是大问题。

关于 Java 的 COW 更多资料,请看这篇文章:聊聊并发-Java中的Copy-On-Write容器

参考资料

posted @ 2021-08-15 10:33  chenssy  阅读(2126)  评论(0编辑  收藏  举报