本文基於 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 進程的虛擬內存空間佈局,如下圖所示:
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 對文件的讀寫流程:
- 當 JVM 在 native 層使用 read 系統調用進行文件讀取的時候,JVM 進程會發生第一次上下文切換,從用户態轉為內核態。
- 隨後 JVM 進程進入虛擬文件系統層,在這一層內核首先會查看讀取文件對應的 page cache 中是否含有請求的文件數據,如果有,那麼直接將文件數據拷貝到 DirectByteBuffer 中返回,避免一次磁盤 IO。並根據內核預讀算法從磁盤中異步預讀若干文件數據到 page cache 中
- 如果請求的文件數據不在 page cache 中,則會進入具體的文件系統層,在這一層內核會啓動磁盤塊設備驅動觸發真正的磁盤 IO。並根據內核預讀算法同步預讀若干文件數據。請求的文件數據和預讀的文件數據將被一起填充到 page cache 中。
- 磁盤控制器 DMA 將從磁盤中讀取的數據拷貝到頁高速緩存 page cache 中。發生第一次數據拷貝。
- 由於 page cache 是屬於內核空間的,不能被 JVM 進程直接尋址,所以還需要 CPU 將 page cache 中的數據拷貝到位於用户空間的 DirectByteBuffer 中,發生第二次數據拷貝。
- 最後 JVM 進程從系統調用 read 中返回,並從內核態切換回用户態。發生第二次上下文切換。
從以上過程我們可以看到,當使用 FileChannel#read 對文件讀取的時候,如果文件數據在 page cache 中,涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換是主要的性能開銷點。
下面是通過 FileChannel#write 寫入文件的整個過程:
- 當 JVM 在 native 層使用 write 系統調用進行文件寫入的時候,JVM 進程會發生第一次上下文切換,從用户態轉為內核態。
- 進入內核態之後,JVM 進程在虛擬文件系統層調用 vfs_write 觸發對 page cache 寫入的操作。內核調用 iov_iter_copy_from_user_atomic 函數將 DirectByteBuffer 中的待寫入數據拷貝到 page cache 中。發生第一次拷貝動作( CPU 拷貝)。
- 當待寫入數據拷貝到 page cache 中時,內核會將對應的文件頁標記為髒頁,內核會根據一定的閾值判斷是否要對 page cache 中的髒頁進行回寫,如果不需要同步回寫,進程直接返回。這裏發生第二次上下文切換。
- 髒頁回寫又會根據髒頁數量在內存中的佔比分為:進程同步回寫和內核異步回寫。當髒頁太多了,進程自己都看不下去的時候,會同步回寫內存中的髒頁,直到回寫完畢才會返回。在回寫的過程中會發生第二次拷貝(DMA 拷貝)。
從以上過程我們可以看到,當使用 FileChannel#write 對文件寫入的時候,如果不考慮髒頁回寫的情況,單純對於 JVM 這個進程來説涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換仍然是主要的性能開銷點。
2. MappedByteBuffer 讀寫文件過程
下面我們來看下通過 MappedByteBuffer 對文件進行讀寫的過程:
首先我們需要通過 FileChannel#map 將文件的某個區域映射到 JVM 進程的虛擬內存空間中,從而獲得一段文件映射的虛擬內存區域 MappedByteBuffer。由於底層使用到了 mmap 系統調用,所以這個過程也涉及到了兩次上下文切換。
如上圖所示,當 MappedByteBuffer 在剛剛映射出來的時候,它只是進程地址空間中的一段虛擬內存,其對應在進程頁表中的頁表項還是空的,背後還沒有映射物理內存。此時映射文件對應的 page cache 也是空的,我們要映射的文件內容此時還靜靜地躺在磁盤中。
當 JVM 進程開始對 MappedByteBuffer 進行讀寫的時候,就會觸發缺頁中斷,內核會將映射的文件內容從磁盤中加載到 page cache 中,然後在進程頁表中建立 MappedByteBuffer 與 page cache 的映射關係。由於這裏涉及到了缺頁中斷的處理,因此也會有兩次上下文切換的開銷。
後面 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 大小的文件進行讀寫,從以上兩個方面對比兩者在不同讀寫單位下的性能表現。
需要提醒大家的是本小節中得出的讀寫性能具體數值是沒有參考價值的,因為不同軟硬件環境下測試得出的具體性能數值都不一樣,值得參考的是 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 的讀取性能測試:
運行結果如下:
為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的性能差異,筆者根據上面跑出的性能數據繪製成下面這幅柱狀圖,方便大家觀察兩者的性能趨勢走向。
這裏我們可以看出,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 以上。
下面是 MappedByteBuffer 和 FileChannel 在不同數據集下對 page cache 的寫入性能測試:
運行結果如下:
MappedByteBuffer 和 FileChannel 在不同數據集下對 page cache 的寫入性能的趨勢走向柱狀圖:
這裏我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優勢,它的寫入性能趨勢是在 [64B , 8K] 這個數據集方位內,寫入性能隨着數據量的增大而越來越快,直到在 8K 這個數據集下達到了最佳寫入性能。
而在 [32K, 32M] 這個數據集範圍內,MappedByteBuffer 仍然具有優勢,但已經不是十分明顯了,最終在 64M 這個數據集下被 FileChannel 反超。
和前面的讀取性能趨勢一樣,FileChannel 的寫入性能也是隨着數據量的增大反而越來越好,最佳的寫入性能是在 64K 處,僅消耗了 160 ms 。
4.2 文件數據不在 page cache 中
在這一小節中,我們將缺頁中斷和磁盤 IO 的影響加入進來,不添加任何的優化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對文件讀寫的性能。
為了避免被 page cache 影響,所以我們需要在每一個測試數據集下,單獨分別為 MappedByteBuffer 和 FileChannel 創建各自的測試文件。
下面是 MappedByteBuffer 和 FileChannel 在不同數據集下對文件的讀取性能測試:
運行結果:
從這裏我們可以看到,在加入了缺頁中斷和磁盤 IO 的影響之後,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁盤 IO 的影響下在 [64B , 512B] 這個數據集範圍內比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個數據集範圍內比之前平均多出了 100 ms 的開銷。
在 2K 之前, MappedByteBuffer 具有明顯的讀取性能優勢,最佳的讀取性能出現在 512B 這個數據集下,從 512B 往後,MappedByteBuffer 的讀取性能趨勢總體成下降趨勢,並在 4K 這個地方被 FileChannel 反超。
FileChannel 則是在 [64B, 1M] 這個數據集範圍內,讀取性能會隨着數據集的增大而提高,並在 1M 這個地方達到了 FileChannel 的最佳讀取性能,僅消耗了 258 ms,在 [32M , 512M] 這個範圍內 FileChannel 的讀取性能在逐漸下降,但是比 MappedByteBuffer 的性能高出了一倍。
讀到這裏大家不禁要問了,理論上來講 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 映射的。隨後內核就會做一些特殊處理:
- 通過 pte_wrprotect 對 MappedByteBuffer 在進程頁表中對應的頁表項 pte 進行寫保護,變為只讀權限。
- 通過 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 在不同數據集下對文件的寫入性能測試:
運行結果:
在筆者的測試環境中,我們看到 MappedByteBuffer 在對文件的寫入性能一路碾壓 FileChannel,並沒有出現被 FileChannel 反超的情況。但我們看到 MappedByteBuffer 從 4K 開始寫入性能是在逐漸下降的,而 FileChannel 的寫入性能卻在一路升高。
根據上面的分析,我們可以推斷出,後面隨着數據量的增大,由於 MappedByteBuffer 缺頁中斷瓶頸的影響,在 512M 後面某一個數據集下,FileChannel 的寫入性能最終是會超過 MappedByteBuffer 的。
在本小節的開頭,筆者就強調了,本小節值得參考的是 MappedByteBuffer 和 FileChannel 在不同數據集大小下的讀寫性能趨勢走向,而不是具體的性能數值。