【死磕 Java Core】 — 談談那個寫時拷貝技術(copy-on-write)

語言: CN / TW / HK

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容器

參考資料