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

chenssy發表於2021-08-15

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

參考資料

相關文章