MappedByteBuffer VS FileChannel:從核心層面對比兩者的效能差異

bin的技术小屋發表於2024-03-28

本文基於 Linux 核心 5.4 版本進行討論

自上篇文章《從 Linux 核心角度探秘 JDK MappedByteBuffer》 釋出之後,很多讀者朋友私信我說,文章的資訊量太大了,其中很多章節介紹的內容都是大家非常想要了解,並且是頻繁被搜尋的內容,所以根據讀者朋友的建議,筆者決定將一些重要的章節內容獨立出來,更好的方便大家檢索。

關於 MappedByteBuffer 和 FileChannel 的話題,網上有很多,但大部分都在討論 MappedByteBuffer 相較於傳統 FileChannel 的優勢,但好像很少有人來寫一寫 MappedByteBuffer 的劣勢,所以筆者這裡想寫一點不一樣的,來和大家討論討論 MappedByteBuffer 的劣勢有哪些。

但在開始討論這個話題之前,筆者想了想還是不能免俗,仍然需要把 MappedByteBuffer 和 FileChannel 放在一起從頭到尾對比一下,基於這個思路,我們先來重新簡要梳理一下 FileChannel 和 MappedByteBuffer 讀寫檔案的流程。

1. FileChannel 讀寫檔案過程

在之前的文章《從 Linux 核心角度探秘 JDK NIO 檔案讀寫本質》中,由於當時我們還未介紹 DirectByteBuffer 以及 MappedByteBuffer,所以筆者以 HeapByteBuffer 為例來介紹 FileChannel 讀寫檔案的整個原始碼實現邏輯。

當我們使用 HeapByteBuffer 傳入 FileChannel 的 read or write 方法對檔案進行讀寫時,JDK 會首先建立一個臨時的 DirectByteBuffer,對於 FileChannel#read 來說,JDK 在 native 層會將 read 系統呼叫從檔案中讀取的內容首先存放到這個臨時的 DirectByteBuffer 中,然後在複製到 HeapByteBuffer 中返回。

對於 FileChannel#write 來說,JDK 會首先將 HeapByteBuffer 中的待寫入資料複製到臨時的 DirectByteBuffer 中,然後在 native 層透過 write 系統呼叫將 DirectByteBuffer 中的資料寫入到檔案的 page cache 中。

public class IOUtil {

   static int read(FileDescriptor fd, ByteBuffer dst, long position,
                    NativeDispatcher nd)
        throws IOException
    {
        // 如果我們傳入的 dst 是 DirectBuffer,那麼直接進行檔案的讀取
        // 將檔案內容讀取到 dst 中
        if (dst instanceof DirectBuffer)
            return readIntoNativeBuffer(fd, dst, position, nd);
  
        // 如果我們傳入的 dst 是一個 HeapBuffer,那麼這裡就需要建立一個臨時的 DirectBuffer
        // 在呼叫 native 方法底層利用 read  or write 系統呼叫進行檔案讀寫的時候
        // 傳入的只能是 DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
        try {
            // 底層透過 read 系統呼叫將檔案內容複製到臨時 DirectBuffer 中
            int n = readIntoNativeBuffer(fd, bb, position, nd);    
            if (n > 0)
                // 將臨時 DirectBuffer 中的檔案內容在複製到 HeapBuffer 中返回
                dst.put(bb);
            return n;
        }
    }

    static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd) throws IOException
    {
        // 如果傳入的 src 是 DirectBuffer,那麼直接將 DirectBuffer 中的內容複製到檔案 page cache 中
        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd);
        // 如果傳入的 src 是 HeapBuffer,那麼這裡需要首先建立一個臨時的 DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            // 首先將 HeapBuffer 中的待寫入內容複製到臨時的 DirectBuffer 中
            // 隨後透過 write 系統呼叫將臨時 DirectBuffer 中的內容寫入到檔案 page cache 中
            int n = writeFromNativeBuffer(fd, bb, position, nd);     
            return n;
        } 
    }
}

當時有很多讀者朋友給我留言提問說,為什麼必須要在 DirectByteBuffer 中做一次中轉,直接將 HeapByteBuffer 傳給 native 層不行嗎 ?

答案是肯定不行的,在本文開頭筆者為大家介紹過 JVM 程序的虛擬記憶體空間佈局,如下圖所示:

image

HeapByteBuffer 和 DirectByteBuffer 從本質上來說均是 JVM 程序地址空間內的一段虛擬記憶體,對於 Java 程式來說 HeapByteBuffer 被用來特定表示 JVM 堆中的記憶體,而 DirectByteBuffer 就是一個普通的 C++ 程式透過 malloc 系統呼叫向作業系統申請的一段 Native Memory 位於 JVM 堆之外。

既然 HeapByteBuffer 是位於 JVM 堆中的記憶體,那麼它必然會受到 GC 的管理,當發生 GC 的時候,如果我們選擇的垃圾回收器採用的是 Mark-Copy 或者 Mark-Compact 演算法的時候(Mark-Swap 除外),GC 會來回移動存活的物件,這就導致了存活的 Java 物件比如這裡的 HeapByteBuffer 在 GC 之後它背後的記憶體地址可能已經發生了變化。

而 JVM 中的這些 native 方法是處於 safepoint 之下的,執行 native 方法的執行緒由於是處於 safepoint 中,所以在執行 native 方法的過程中可能會有 GC 的發生。

如果我們把一個 HeapByteBuffer 傳遞給 native 層進行檔案讀寫的時候不巧發生了 GC,那麼 HeapByteBuffer 背後的記憶體地址就會變化,這樣一來,如果我們在讀取檔案的話,核心將會把檔案內容複製到另一個記憶體地址中。如果我們在寫入檔案的話,核心將會把另一個記憶體地址中的記憶體寫入到檔案的 page cache 中。

所以我們在透過 native 方法執行相關係統呼叫的時候必須要保證傳入的記憶體地址是不會變化的,由於 DirectByteBuffer 背後所依賴的 Native Memory 位於 JVM 堆之外,是不會受到 GC 管理的,因此不管發不發生 GC,DirectByteBuffer 所引用的這些 Native Memory 地址是不會發生變化的。

所以我們在呼叫 native 方法進行檔案讀寫的時候需要傳入 DirectByteBuffer,如果我們用得是 HeapByteBuffer ,那麼就需要一個臨時的 DirectByteBuffer 作為中轉。

這時可能有讀者朋友又會問了,我們在使用 HeapByteBuffer 透過 FileChannel#write 對檔案進行寫入的時候,首先會將 HeapByteBuffer 中的內容複製到臨時的 DirectByteBuffer 中,那如果在這個複製的過程中發生了 GC,HeapByteBuffer 背後引用記憶體的地址發生了變化,那麼複製到 DirectByteBuffer 中的內容仍然是錯的啊。

事實上在這個複製的過程中是不會發生 GC 的,因為 JVM 這裡會使用 Unsafe#copyMemory 方法來實現 HeapByteBuffer 到 DirectByteBuffer 的複製操作,copyMemory 被 JVM 實現為一個 intrinsic 方法,中間是沒有 safepoint 的,執行 copyMemory 的執行緒由於不在 safepoint 中,所以在複製的過程中是不會發生 GC 的。

public final class Unsafe {
  // intrinsic 方法
  public native void copyMemory(Object srcBase, long srcOffset,
                                  Object destBase, long destOffset,
                                  long bytes);  
}

在交代完這個遺留的問題之後,下面我們就以 DirectByteBuffer 為例來重新簡要回顧下傳統 FileChannel 對檔案的讀寫流程:

image

  1. 當 JVM 在 native 層使用 read 系統呼叫進行檔案讀取的時候,JVM 程序會發生第一次上下文切換,從使用者態轉為核心態。

  2. 隨後 JVM 程序進入虛擬檔案系統層,在這一層核心首先會檢視讀取檔案對應的 page cache 中是否含有請求的檔案資料,如果有,那麼直接將檔案資料複製到 DirectByteBuffer 中返回,避免一次磁碟 IO。並根據核心預讀演算法從磁碟中非同步預讀若干檔案資料到 page cache 中

  3. 如果請求的檔案資料不在 page cache 中,則會進入具體的檔案系統層,在這一層核心會啟動磁碟塊裝置驅動觸發真正的磁碟 IO。並根據核心預讀演算法同步預讀若干檔案資料。請求的檔案資料和預讀的檔案資料將被一起填充到 page cache 中。

  4. 磁碟控制器 DMA 將從磁碟中讀取的資料複製到頁快取記憶體 page cache 中。發生第一次資料複製

  5. 由於 page cache 是屬於核心空間的,不能被 JVM 程序直接定址,所以還需要 CPU 將 page cache 中的資料複製到位於使用者空間的 DirectByteBuffer 中,發生第二次資料複製

  6. 最後 JVM 程序從系統呼叫 read 中返回,並從核心態切換回使用者態。發生第二次上下文切換

從以上過程我們可以看到,當使用 FileChannel#read 對檔案讀取的時候,如果檔案資料在 page cache 中,涉及到的效能開銷點主要有兩次上下文切換,以及一次 CPU 複製。其中上下文切換是主要的效能開銷點。

下面是透過 FileChannel#write 寫入檔案的整個過程:

image

  1. 當 JVM 在 native 層使用 write 系統呼叫進行檔案寫入的時候,JVM 程序會發生第一次上下文切換,從使用者態轉為核心態。

  2. 進入核心態之後,JVM 程序在虛擬檔案系統層呼叫 vfs_write 觸發對 page cache 寫入的操作。核心呼叫 iov_iter_copy_from_user_atomic 函式將 DirectByteBuffer 中的待寫入資料複製到 page cache 中。發生第一次複製動作( CPU 複製)。

  3. 當待寫入資料複製到 page cache 中時,核心會將對應的檔案頁標記為髒頁,核心會根據一定的閾值判斷是否要對 page cache 中的髒頁進行回寫,如果不需要同步回寫,程序直接返回。這裡發生第二次上下文切換

  4. 髒頁回寫又會根據髒頁數量在記憶體中的佔比分為:程序同步回寫和核心非同步回寫。當髒頁太多了,程序自己都看不下去的時候,會同步回寫記憶體中的髒頁,直到回寫完畢才會返回。在回寫的過程中會發生第二次複製(DMA 複製)。

從以上過程我們可以看到,當使用 FileChannel#write 對檔案寫入的時候,如果不考慮髒頁回寫的情況,單純對於 JVM 這個程序來說涉及到的效能開銷點主要有兩次上下文切換,以及一次 CPU 複製。其中上下文切換仍然是主要的效能開銷點。

2. MappedByteBuffer 讀寫檔案過程

下面我們來看下透過 MappedByteBuffer 對檔案進行讀寫的過程:

image

首先我們需要透過 FileChannel#map 將檔案的某個區域對映到 JVM 程序的虛擬記憶體空間中,從而獲得一段檔案對映的虛擬記憶體區域 MappedByteBuffer。由於底層使用到了 mmap 系統呼叫,所以這個過程也涉及到了兩次上下文切換

如上圖所示,當 MappedByteBuffer 在剛剛對映出來的時候,它只是程序地址空間中的一段虛擬記憶體,其對應在程序頁表中的頁表項還是空的,背後還沒有對映實體記憶體。此時對映檔案對應的 page cache 也是空的,我們要對映的檔案內容此時還靜靜地躺在磁碟中。

當 JVM 程序開始對 MappedByteBuffer 進行讀寫的時候,就會觸發缺頁中斷,核心會將對映的檔案內容從磁碟中載入到 page cache 中,然後在程序頁表中建立 MappedByteBuffer 與 page cache 的對映關係。由於這裡涉及到了缺頁中斷的處理,因此也會有兩次上下文切換的開銷。

image

後面 JVM 程序對 MappedByteBuffer 的讀寫就相當於是直接讀寫 page cache 了,關於這一點,很多讀者朋友會有這樣的疑問:page cache 是核心態的部分,為什麼我們透過使用者態的 MappedByteBuffer 就可以直接訪問核心態的東西了?

這裡大家不要被核心態這三個字給唬住了,雖然 page cache 是屬於核心部分的,但其本質上還是一塊普通的實體記憶體,想想我們是怎麼訪問記憶體的 ? 不就是先有一段虛擬記憶體,然後在申請一段實體記憶體,最後透過程序頁表將虛擬記憶體和實體記憶體對映起來麼,程序在訪問虛擬記憶體的時候,透過頁表找到其對映的實體記憶體地址,然後直接透過實體記憶體地址訪問實體記憶體。

回到我們討論的內容中,這段虛擬記憶體不就是 MappedByteBuffer 嗎,實體記憶體就是 page cache 啊,在透過頁表對映起來之後,程序在透過 MappedByteBuffer 訪問 page cache 的過程就和訪問普通記憶體的過程是一模一樣的。

也正因為 MappedByteBuffer 背後對映的實體記憶體是核心空間的 page cache,所以它不會消耗任何使用者空間的實體記憶體(JVM 的堆外記憶體),因此也不會受到 -XX:MaxDirectMemorySize 引數的限制。

3. MappedByteBuffer VS FileChannel

現在我們已經清楚了 FileChannel 以及 MappedByteBuffer 進行檔案讀寫的整個過程,下面我們就來把兩種檔案讀寫方式放在一起來對比一下,但這裡有一個對比的前提:

  • 對於 MappedByteBuffer 來說,我們對比的是其在缺頁處理之後,讀寫檔案的開銷。

  • 對於 FileChannel 來說,我們對比的是檔案資料已經存在於 page cache 中的情況下讀寫檔案的開銷。

因為筆者認為只有基於這個前提來對比兩者的效能差異才有意義。

  • 對於 FileChannel 來說,無論是透過 read 方法對檔案的讀取,還是透過 write 方法對檔案的寫入,它們都需要兩次上下文切換,以及一次 CPU 複製,其中上下文切換是其主要的效能開銷點。

  • 對於 MappedByteBuffer 來說,由於其背後直接對映的就是 page cache,讀寫 MappedByteBuffer 本質上就是讀寫 page cache,整個讀寫過程和讀寫普通的記憶體沒有任何區別,因此沒有上下文切換的開銷,不會切態,更沒有任何複製

從上面的對比我們可以看出使用 MappedByteBuffer 來讀寫檔案既沒有上下文切換的開銷,也沒有資料複製的開銷(可忽略),簡直是完爆 FileChannel。

既然 MappedByteBuffer 這麼屌,那我們何不乾脆在所有檔案的讀寫場景中全部使用 MappedByteBuffer,這樣豈不省事 ?JDK 為何還保留了 FileChannel 的 read , write 方法呢 ?讓我們來帶著這個疑問繼續下面的內容~~

4. 透過 Benchmark 從核心層面對比兩者的效能差異

到現在為止,筆者已經帶著大家完整的剖析了 mmap,read,write 這些系統呼叫在核心中的原始碼實現,並基於原始碼對 MappedByteBuffer 和 FileChannel 兩者進行了效能開銷上的對比。

雖然祭出了原始碼,但畢竟還是 talk is cheap,本小節我們就來對兩者進行一次 Benchmark,來看一下 MappedByteBuffer 與 FileChannel 對檔案讀寫的實際效能表現如何 ? 是否和我們從原始碼中分析的結果一致。

我們從兩個方面來對比 MappedByteBuffer 和 FileChannel 的檔案讀寫效能:

  • 檔案資料完全載入到 page cache 中,並且將 page cache 鎖定在記憶體中,不允許 swap,MappedByteBuffer 不會有缺頁中斷,FileChannel 不會觸發磁碟 IO 都是直接對 page cache 進行讀寫。

  • 檔案資料不在 page cache 中,我們加上了 缺頁中斷,磁碟IO,以及 swap 對檔案讀寫的影響。

具體的測試思路是,用 MappedByteBuffer 和 FileChannel 分別以
64B ,128B ,512B ,1K ,2K ,4K ,8K ,32K ,64K ,1M ,32M ,64M ,512M 為單位依次對 1G 大小的檔案進行讀寫,從以上兩個方面對比兩者在不同讀寫單位下的效能表現。

image

需要提醒大家的是本小節中得出的讀寫效能具體數值是沒有參考價值的,因為不同軟硬體環境下測試得出的具體效能數值都不一樣,值得參考的是 MappedByteBuffer 和 FileChannel 在不同資料集大小下的讀寫效能趨勢走向。筆者的軟硬體測試環境如下:

  • 處理器:2.5 GHz 四核Intel Core i7
  • 記憶體:16 GB 1600 MHz DDR3
  • SSD:APPLE SSD SM0512F
  • 作業系統:macOS
  • JVM:OpenJDK 17

測試程式碼:https://github.com/huibinliupush/benchmark , 大家也可以在自己的測試環境中執行一下,然後將跑出的結果提交到這個倉庫中。這樣方便大家在不同的測試環境下對比兩者的檔案讀寫效能差異 —— 眾人拾柴火焰高。

4.1 檔案資料在 page cache 中

由於這裡我們要測試 MappedByteBuffer 和 FileChannel 直接對 page cache 的讀寫效能,所以筆者讓 MappedByteBuffer ,FileChannel 只針對同一個檔案進行讀寫測試。

在對檔案進行讀寫之前,首先透過 mlock 系統呼叫將檔案資料提前載入到 page cache 中並主動觸發缺頁處理,在程序頁表中建立好 MappedByteBuffer 和 page cache 的對映關係。最後將 page cache 鎖定在記憶體中不允許 swap。

下面是 MappedByteBuffer 和 FileChannel 在不同資料集下對 page cache 的讀取效能測試:

image

執行結果如下:

image

為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的效能差異,筆者根據上面跑出的效能資料繪製成下面這幅柱狀圖,方便大家觀察兩者的效能趨勢走向。

image

這裡我們可以看出,MappedByteBuffer 在 4K 之前具有明顯的壓倒性優勢,在 [8K , 32M] 這個區間內,MappedByteBuffer 依然具有優勢但已經不是十分明顯了,從 64M 開始 FileChannel 實現了一點點反超。

我們可以得到的效能趨勢是,在 [64B, 2K] 這個單次讀取資料量級範圍內,MappedByteBuffer 讀取的效能越來越快,並在 2K 這個資料量級下達到了效能最高值,僅消耗了 73 ms。從 4K 開始讀取效能在一點一點的逐漸下降,並在 64M 這個資料量級下被 FileChannel 反超。

而 FileChannel 的讀取效能會隨著資料量的增大反而越來越好,並在某一個資料量級下效能會反超 MappedByteBuffer。FileChannel 的最佳讀取效能點是在 64K 處,消耗了 167ms 。

因此 MappedByteBuffer 適合頻繁讀取小資料量的場景,具體多小,需要大家根據自己的環境進行測試,本小節我們得出的資料是 4K 以下。

FileChannel 適合大資料量的批次讀取場景,具體多大,還是需要大家根據自己的環境進行測試,本小節我們得出的資料是 64M 以上。

image

下面是 MappedByteBuffer 和 FileChannel 在不同資料集下對 page cache 的寫入效能測試:

image

執行結果如下:

image

MappedByteBuffer 和 FileChannel 在不同資料集下對 page cache 的寫入效能的趨勢走向柱狀圖:

image

這裡我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優勢,它的寫入效能趨勢是在 [64B , 8K] 這個資料集方位內,寫入效能隨著資料量的增大而越來越快,直到在 8K 這個資料集下達到了最佳寫入效能。

而在 [32K, 32M] 這個資料集範圍內,MappedByteBuffer 仍然具有優勢,但已經不是十分明顯了,最終在 64M 這個資料集下被 FileChannel 反超。

和前面的讀取效能趨勢一樣,FileChannel 的寫入效能也是隨著資料量的增大反而越來越好,最佳的寫入效能是在 64K 處,僅消耗了 160 ms 。

image

4.2 檔案資料不在 page cache 中

在這一小節中,我們將缺頁中斷和磁碟 IO 的影響加入進來,不新增任何的最佳化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對檔案讀寫的效能。

為了避免被 page cache 影響,所以我們需要在每一個測試資料集下,單獨分別為 MappedByteBuffer 和 FileChannel 建立各自的測試檔案。

下面是 MappedByteBuffer 和 FileChannel 在不同資料集下對檔案的讀取效能測試:

image

執行結果:

image

從這裡我們可以看到,在加入了缺頁中斷和磁碟 IO 的影響之後,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁碟 IO 的影響下在 [64B , 512B] 這個資料集範圍內比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個資料集範圍內比之前平均多出了 100 ms 的開銷。

image

在 2K 之前, MappedByteBuffer 具有明顯的讀取效能優勢,最佳的讀取效能出現在 512B 這個資料集下,從 512B 往後,MappedByteBuffer 的讀取效能趨勢總體成下降趨勢,並在 4K 這個地方被 FileChannel 反超。

FileChannel 則是在 [64B, 1M] 這個資料集範圍內,讀取效能會隨著資料集的增大而提高,並在 1M 這個地方達到了 FileChannel 的最佳讀取效能,僅消耗了 258 ms,在 [32M , 512M] 這個範圍內 FileChannel 的讀取效能在逐漸下降,但是比 MappedByteBuffer 的效能高出了一倍。

image

讀到這裡大家不禁要問了,理論上來講 MappedByteBuffer 應該是完爆 FileChannel 才對啊,因為 MappedByteBuffer 沒有系統呼叫的開銷,為什麼效能在後面反而被 FileChannel 超越了近一倍之多呢 ?

要明白這個問題,我們就需要分別把 MappedByteBuffer 和 FileChannel 在讀寫檔案時候所涉及到的效能開銷點一一列舉出來,並對這些效能開銷點進行詳細對比,這樣答案就有了。

首先 MappedByteBuffer 的主要效能開銷是在缺頁中斷,而 FileChannel 的主要開銷是在系統呼叫,兩者都會涉及上下文的切換。

FileChannel 在讀寫檔案的時候有磁碟IO,有預讀。同樣 MappedByteBuffer 的缺頁中斷也有磁碟IO 也有預讀。目前來看他倆一比一打平。

但別忘了 MappedByteBuffer 是需要程序頁表支援的,在實際訪問記憶體的過程中會遇到頁表競爭以及 TLB shootdown 等問題。還有就是 MappedByteBuffer 剛剛被對映出來的時候,其在程序頁表中對應的各級頁表以及頁目錄可能都是空的。所以缺頁中斷這裡需要做的一件非常重要的事情就是補齊完善 MappedByteBuffer 在程序頁表中對應的各級頁目錄表和頁表,並在頁表項中將 page cache 對映起來,最後還要重新整理 TLB 等硬體快取。

想更多瞭解缺頁中斷細節的讀者可以看下之前的文章——
《一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults》

而 FileChannel 並不會涉及上面的這些開銷,所以 MappedByteBuffer 的缺頁中斷要比 FileChannel 的系統呼叫開銷要大,這一點我們可以在上小節和本小節的讀寫效能對比中看得出來。

檔案資料在 page cache 中與不在 page cache 中,MappedByteBuffer 前後的讀取效能平均差了 500 ms,而 FileChannel 前後卻只平均差了 100 ms。

MappedByteBuffer 的缺頁中斷是平均每 4K 觸發一次,而 FileChannel 的系統呼叫開銷則是每次都會觸發。當兩者單次按照小資料量讀取 1G 檔案的時候,MappedByteBuffer 的缺頁中斷較少觸發,而 FileChannel 的系統呼叫卻在頻繁觸發,所以在這種情況下,FileChannel 的系統呼叫是主要的效能瓶頸。

這也就解釋了當我們在頻繁讀寫小資料量的時候,MappedByteBuffer 的效能具有壓倒性優勢。當單次讀寫的資料量越來越大的時候,FileChannel 呼叫的次數就會越來越少,這時候缺頁中斷就會成為 MappedByteBuffer 的效能瓶頸,到某一個點之後,FileChannel 就會反超 MappedByteBuffer。因此當我們需要高吞吐量讀寫檔案的時候 FileChannel 反而是最合適的

除此之外,核心的髒頁回寫也會對 MappedByteBuffer 以及 FileChannel 的檔案寫入效能有非常大的影響,無論是我們在使用者態中呼叫 fsync 或者 msync 主動觸發髒頁回寫還是核心透過 pdflush 執行緒非同步髒頁回寫,當我們使用 MappedByteBuffer 或者 FileChannel 寫入 page cache 的時候,如果恰巧遇到檔案頁的回寫,那麼寫入操作都會有非常大的延遲,這個在 MappedByteBuffer 身上體現的更為明顯。

為什麼這麼說呢 ? 我們還是到核心原始碼中去探尋原因,先來看髒頁回寫對 FileChannel 的寫入影響。下面是 FileChannel 檔案寫入在核心中的核心實現:

ssize_t generic_perform_write(struct file *file,
    struct iov_iter *i, loff_t pos)
{
   // 從 page cache 中獲取要寫入的檔案頁並準備記錄檔案後設資料日誌工作
  status = a_ops->write_begin(file, mapping, pos, bytes, flags,
      &page, &fsdata);
   // 將使用者空間緩衝區 DirectByteBuffer 中的資料複製到 page cache 中的檔案頁中
  copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
  // 將寫入的檔案頁標記為髒頁並完成檔案後設資料日誌的寫入
  status = a_ops->write_end(file, mapping, pos, bytes, copied,
      page, fsdata);
  // 判斷是否需要同步回寫髒頁
  balance_dirty_pages_ratelimited(mapping);
}

首先核心會在 write_begin 函式中透過 grab_cache_page_write_begin 從檔案 page cache 中獲取要寫入的檔案頁。

struct page *grab_cache_page_write_begin(struct address_space *mapping,
          pgoff_t index, unsigned flags)
{
  struct page *page;
  // 在 page cache 中查詢寫入資料的快取頁
  page = pagecache_get_page(mapping, index, fgp_flags,
      mapping_gfp_mask(mapping));
  if (page)
    wait_for_stable_page(page);
  return page;
}

在這裡會呼叫一個非常重要的函式 wait_for_stable_page,這個函式的作用就是判斷當前 page cache 中的這個檔案頁是否正在被回寫,如果正在回寫到磁碟,那麼當前程序就會阻塞直到髒頁回寫完畢。

/**
 * wait_for_stable_page() - wait for writeback to finish, if necessary.
 * @page:	The page to wait on.
 *
 * This function determines if the given page is related to a backing device
 * that requires page contents to be held stable during writeback.  If so, then
 * it will wait for any pending writeback to complete.
 */
void wait_for_stable_page(struct page *page)
{
	if (bdi_cap_stable_pages_required(inode_to_bdi(page->mapping->host)))
		wait_on_page_writeback(page);
}
EXPORT_SYMBOL_GPL(wait_for_stable_page);

等到髒頁回寫完畢之後,程序才會呼叫 iov_iter_copy_from_user_atomic 將待寫入資料複製到 page cache 中,最後在 write_end 中呼叫 mark_buffer_dirty 將寫入的檔案頁標記為髒頁。

除了正在回寫的髒頁會阻塞 FileChannel 的寫入過程之外,如果此時系統中的髒頁太多了,超過了 dirty_ratio 或者 dirty_bytes 等核心引數配置的髒頁比例,那麼程序就會同步去回寫髒頁,這也對寫入效能有非常大的影響。

我們接著再來看髒頁回寫對 MappedByteBuffer 的寫入影響,在開始分析之前,筆者先問大家一個問題:透過 MappedByteBuffer 寫入 page cache 之後,page cache 中的相應檔案頁是怎麼變髒的

FileChannel 很好理解,因為 FileChannel 走的是系統呼叫,會進入到檔案系統由核心進行處理,如果寫入檔案頁恰好正在回寫時,核心會呼叫 wait_for_stable_page 阻塞當前程序。在將資料寫入檔案頁之後,核心又會呼叫 mark_buffer_dirty 將頁面變髒。

MappedByteBuffer 就很難理解了,因為 MappedByteBuffer 不會走系統呼叫,直接讀寫的就是 page cache,而 page cache 也只是核心在軟體層面上的定義,它的本質還是實體記憶體。另外髒頁以及髒頁的回寫都是核心在軟體層面上定義的概念和行為。

MappedByteBuffer 直接寫入的是硬體層面的實體記憶體(page cache),硬體哪管你軟體上定義的髒頁以及髒頁回寫啊,沒有核心的參與,那麼在透過 MappedByteBuffer 寫入檔案頁之後,檔案頁是如何變髒的呢 ?還有就是 MappedByteBuffer 如何探測到對應檔案頁正在回寫並阻塞等待呢 ?

既然我們涉及到了軟體的概念和行為,那麼一定就會有核心的參與,我們回想一下整個 MappedByteBuffer 的生命週期,唯一一次和核心打交道的機會就是缺頁中斷,我們看看能不能在缺頁中斷中發現點什麼~

當 MappedByteBuffer 剛剛被 mmap 對映出來的時候它還只是一段普通的虛擬記憶體,背後什麼都沒有,其在程序頁表中的各級頁目錄項以及頁表項都還是空的。

當我們立即對 MappedByteBuffer 進行寫入的時候就會發生缺頁中斷,在缺頁中斷的處理中,核心會在程序頁表中補齊與 MappedByteBuffer 對映相關的各級頁目錄並在頁表項中與 page cache 進行對映。

static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
    // 從 page cache 中讀取檔案頁
    ret = __do_fault(vmf);   
    if (vma->vm_ops->page_mkwrite) {
        unlock_page(vmf->page);
        // 將檔案頁變為可寫狀態,並設定檔案頁為髒頁
        // 如果檔案頁正在回寫,那麼阻塞等待
        tmp = do_page_mkwrite(vmf);
    }
}

除此之外,核心還會呼叫 do_page_mkwrite 方法將 MappedByteBuffer 對應的頁表項變成可寫狀態,並將與其對映的檔案頁立即設定位髒頁,如果此時檔案頁正在回寫,那麼 MappedByteBuffer 在缺頁中斷中也會阻塞。

int block_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf,
			 get_block_t get_block)
{
	set_page_dirty(page);
	wait_for_stable_page(page);
}

這裡我們可以看到 MappedByteBuffer 在核心中是先變髒然後在對 page cache 進行寫入,而 FileChannel 是先寫入 page cache 後在變髒。

從此之後,透過 MappedByteBuffer 對 page cache 的寫入就會變得非常絲滑,那麼問題來了,當 page cache 中的髒頁被核心非同步回寫之後,核心會把檔案頁中的髒頁標記清除掉,那麼這時如果 MappedByteBuffer 對 page cache 寫入,由於不會發生缺頁中斷,那麼 page cache 中的檔案頁如何再次變髒呢 ?

核心這裡的設計非常巧妙,當核心回寫完髒頁之後,會呼叫 page_mkclean_one 函式清除檔案頁的髒頁標記,在這裡會首先透過 page_vma_mapped_walk 判斷該檔案頁是不是被 mmap 對映到程序地址空間的,如果是,那麼說明該檔案頁是被 MappedByteBuffer 對映的。隨後核心就會做一些特殊處理:

  1. 透過 pte_wrprotect 對 MappedByteBuffer 在程序頁表中對應的頁表項 pte 進行防寫,變為只讀許可權。

  2. 透過 pte_mkclean 清除頁表項上的髒頁標記。

static bool page_mkclean_one(struct page *page, struct vm_area_struct *vma,
			    unsigned long address, void *arg)
{

	while (page_vma_mapped_walk(&pvmw)) {
		int ret = 0;

		address = pvmw.address;
		if (pvmw.pte) {
			pte_t entry;
			entry = ptep_clear_flush(vma, address, pte);
			entry = pte_wrprotect(entry);
			entry = pte_mkclean(entry);
			set_pte_at(vma->vm_mm, address, pte, entry);
		}
	return true;
}

這樣一來,在髒頁回寫完畢之後,MappedByteBuffer 在頁表中就變成只讀的了,這一切對使用者態的我們都是透明的,當再次對 MappedByteBuffer 寫入的時候就不是那麼絲滑了,會觸發防寫缺頁中斷(我們以為不會有缺頁中斷,其實是有的),在防寫中斷的處理中,核心會重新將頁表項 pte 變為可寫,檔案頁標記為髒頁。如果檔案頁正在回寫,缺頁中斷會阻塞。如果髒頁積累的太多,這裡也會同步回寫髒頁。

static vm_fault_t wp_page_shared(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    if (vma->vm_ops && vma->vm_ops->page_mkwrite) {
        // 設定頁表項為可寫
        // 標記檔案頁為髒頁
        // 如果檔案頁正在回寫則阻塞等待
        tmp = do_page_mkwrite(vmf);
    } 
    // 判斷是否需要同步回寫髒頁,
    fault_dirty_shared_page(vma, vmf->page);
    return VM_FAULT_WRITE;
}

所以並不是對 MappedByteBuffer 呼叫 mlock 之後就萬事大吉了,在遇到髒頁回寫的時候,MappedByteBuffer 依然會發生防寫型別的缺頁中斷。在缺頁中斷處理中會等待髒頁的回寫,並且還可能會發生髒頁的同步回寫。這對 MappedByteBuffer 的寫入效能會有非常大的影響。

在明白這些問題之後,下面我們繼續來看 MappedByteBuffer 和 FileChannel 在不同資料集下對檔案的寫入效能測試:

image

執行結果:

image

image

在筆者的測試環境中,我們看到 MappedByteBuffer 在對檔案的寫入效能一路碾壓 FileChannel,並沒有出現被 FileChannel 反超的情況。但我們看到 MappedByteBuffer 從 4K 開始寫入效能是在逐漸下降的,而 FileChannel 的寫入效能卻在一路升高。

根據上面的分析,我們可以推斷出,後面隨著資料量的增大,由於 MappedByteBuffer 缺頁中斷瓶頸的影響,在 512M 後面某一個資料集下,FileChannel 的寫入效能最終是會超過 MappedByteBuffer 的。

在本小節的開頭,筆者就強調了,本小節值得參考的是 MappedByteBuffer 和 FileChannel 在不同資料集大小下的讀寫效能趨勢走向,而不是具體的效能數值。

image

相關文章