【死磕 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容器

參考資料