Linux IO結構
在開始之前,先介紹一下Linux的IO結構。
VFS(Virtual FileSystem) 虛擬文件系統
文件系統是內核的功能,是一種工作在內核空間的軟件,訪問一個文件必須要需要文件系統的存在纔可以。Linux 可以支持多達數十種不同的文件系統,它們的實現各不相同,因此 Linux 內核向用戶空間提供了虛擬文件系統這個統一的接口用來對文件系統進行操作。
虛擬文件系統是位於用戶空間進程和內核空間中多種不同的底層文件系統的實現之間的一個抽象的接口層,它提供了常見的文件系統對象模型(如 i-node, file object, page cache, directory entry, etc.)和訪問這些對象的方法(如 open, close, delete, write, read, create, fstat, etc.),並將它們統一輸出,類似於庫的作用。從而向用戶進程隱藏了各種不同的文件系統的具體實現,這樣上層軟件只需要和 VFS 進行交互而不必關係底層的文件系統,簡化了軟件的開發,也使得 linux 可以支持多種不同的文件系統。
I/O子系統架構
上圖概括了一次磁盤 write 操作的過程,假設文件已經被從磁盤中讀入了 page cache 中。
- 一個用戶進程通過 write() 系統調用發起寫請求
- 內核更新對應的 page cache
- pdflush 內核線程將 page cache 寫入至磁盤中
- 文件系統層將每一個 block buffer 存放爲一個 bio 結構體,並向塊設備層提交一個寫請求
- 塊設備層(block device)從上層接受到請求,執行 IO 調度操作,並將請求放入IO 請求隊列中
- 設備驅動(如 SCSI 或其他設備驅動)完成寫操作
- 磁盤設備固件執行對應的硬件操作,如磁盤的旋轉,尋道等,數據被寫入到磁盤扇區中
Block Layer
Block layer 處理所有和塊設備相關的操作。block layer 最關鍵是數據結構是 bio 結構體。bio 結構體是 file system layer 到 block layer 的接口。 當執行一個寫操作時,文件系統層將數據寫入 page cache(由 block buffer 組成),將連續的塊放到一起,組成 bio 結構體,然後將 bio 送至 block layer。
block layer 處理 bio 請求,並將這些請求鏈接成一個隊列,稱作 IO 請求隊列,這個連接的操作就稱作 IO 調度(也叫 IO elevator 即電梯算法).
Buffer IO
Buffer I/O 又被稱作Standard I/O,大多數文件系統的默認 I/O 操作都是Buffer I/O。在 Linux 的Buffer I/O 機制中,操作系統會將 I/O 的數據緩存在文件系統的頁緩存(page cache)中,也就是說,數據會先被拷貝到操作系統內核的緩衝區中,然後纔會從操作系統內核的緩衝區拷貝到應用程序的地址空間。Buffer I/O 有以下這些優點:
- 緩存 I/O 使用了操作系統內核緩衝區,在一定程度上分離了應用程序空間和實際的物理設備。
- 緩存 I/O 可以減少讀盤的次數,從而提高性能。
Java中的IO也是Buffer IO。
常見的FileInputStream/FileOutPutStream/RandomAccessFile/FileChannel
,都是Buffer IO。
Page Cache
在 Linux 的實現中,文件 Cache 分爲兩個層面,一是 Page Cache,另一個 Buffer Cache,每一個 Page Cache 包含若干 Buffer Cache。內存管理系統和 VFS 只與 Page Cache 交互,內存管理系統負責維護每項 Page Cache 的分配和回收,同時在使用 memory map 方式訪問時負責建立映射;VFS 負責 Page Cache 與用戶空間的數據交換。而具體文件系統則一般只與 Buffer Cache 交互,它們負責在外圍存儲設備和 Buffer Cache 之間交換數據。Page Cache、Buffer Cache、文件以及磁盤之間的關係如下圖所示
MMAP
mmap是一種內存映射文件的方法,即將一個文件或者其它對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對映關係。實現這樣的映射關係後,進程就可以採用指針的方式讀寫操作這一段內存,而系統會自動回寫髒頁面到對應的文件磁盤上,即完成了對文件的操作而不必再調用read,write等系統調用函數。相反,內核空間對這段區域的修改也直接反映用戶空間,從而可以實現不同進程間的文件共享。如下圖所示:
MMAP的讀寫實際是上也是會經過Cache層的,那麼MMAP方式與普通方式(Buffer IO)操作文件的區別是什麼呢?
上面已經介紹了Buffer IO的操作方式,MMAP和Buffer IO的區別在於Buffer IO需要現在用戶空間下維護一個Buffer區(例如FileChannel讀寫時使用的Buffer);以寫入文件爲例,先向用戶空間下的Buffer寫入數據,然後再拷貝到內核緩衝(page cache),而MMAP直接將文件(確切的說應該是文件對應的Page Cache)映射到進程的地址空間,進程就可以直接以內存的操作方式來操作文件了,不需要用戶緩衝到內核緩衝的拷貝。進程對mmap的操作相當於直接操作了cache,讀取mmap時等於直接讀取cache,寫入mmap時等於直接寫cache,然後操作系統異步刷盤,當然也可以手動調用sync強制刷盤。少了一次拷貝,速度上自然有提升,所以MMAP又成爲零拷貝(ZERO COPY)。
MMAP的優缺點
優點
- 小數據量的讀寫性能極高
缺點
- 映射的大小最好4k對齊
- 釋放麻煩
- 只能定長
- 隨機寫頻繁的場景下,性能不一定比Buffer IO快
雖然缺點很多,但是如果需要超高性能時還是需要考慮使用mmap的。
JAVA中的MMAP使用
通過FileChannel創建mmap
FileChannel channel = FileChannel.open(new File("your file path").toPath(),
StandardOpenOption.CREATE,
StandardOpenOption.READ,
StandardOpenOption.WRITE);
//FileChannel.MapMode.READ_WRITE爲映射的模式,READ_WRITE代表可讀寫;0,10爲映射的文件偏移,單位字節
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 10);
//MappedByteBuffer繼承於NIO的ByteBuffer,讀寫數據的接口和ByteBuffer一致,注意:MappedByteBuffer實際上屬於堆外內存(Direct Buffer)
mappedByteBuffer.putInt(1);
mappedByteBuffer.put((byte) 0x01);
mappedByteBuffer.putLong(1l);
//對於mmap的寫入,都是寫入在cache中的,操作系統會異步刷盤,當然如果對數據一致性有嚴格要求,可以手動調用force強制刷盤,但是這樣性能就非常差了。
mappedByteBuffer.force();
Java中對於MMAP的釋放沒有一個優雅的方式,釋放起來比較麻煩,下面貼一個釋放的工具類:
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
public final class ByteBufferSupport
{
private static final MethodHandle INVOKE_CLEANER;
static {
MethodHandle invoker;
try {
// Java 9 added an invokeCleaner method to Unsafe to work around
// module visibility issues for code that used to rely on DirectByteBuffer's cleaner()
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
invoker = MethodHandles.lookup()
.findVirtual(unsafeClass, "invokeCleaner", MethodType.methodType(void.class, ByteBuffer.class))
.bindTo(theUnsafe.get(null));
}
catch (Exception e) {
// fall back to pre-java 9 compatible behavior
try {
Class<?> directByteBufferClass = Class.forName("java.nio.DirectByteBuffer");
Class<?> cleanerClass = Class.forName("sun.misc.Cleaner");
Method cleanerMethod = directByteBufferClass.getDeclaredMethod("cleaner");
cleanerMethod.setAccessible(true);
MethodHandle getCleaner = MethodHandles.lookup().unreflect(cleanerMethod);
Method cleanMethod = cleanerClass.getDeclaredMethod("clean");
cleanerMethod.setAccessible(true);
MethodHandle clean = MethodHandles.lookup().unreflect(cleanMethod);
clean = MethodHandles.dropArguments(clean, 1, directByteBufferClass);
invoker = MethodHandles.foldArguments(clean, getCleaner);
}
catch (Exception e1) {
throw new AssertionError(e1);
}
}
INVOKE_CLEANER = invoker;
}
private ByteBufferSupport()
{
}
public static void unmap(MappedByteBuffer buffer)
{
try {
INVOKE_CLEANER.invoke(buffer);
}
catch (Throwable ignored) {
throw Throwables.propagate(ignored);
}
}
}
Direct IO
通過Direct I/O 方式進行數據傳輸,數據均直接在用戶地址空間的緩衝區和磁盤之間直接進行傳輸,完全不需要頁緩存的支持。操作系統層提供的緩存往往會使應用程序在讀寫數據的時候獲得更好的性能,但是對於某些特殊的應用程序,比如說數據庫管理系統這類應用,他們更傾向於選擇他們自己的緩存機制,因爲數據庫管理系統往往比操作系統更瞭解數據庫中存放的數據,數據庫管理系統可以提供一種更加有效的緩存機制來提高數據庫中數據的存取性能。下圖是Direct IO的路徑:
Java中的Direct IO
JDK並沒有提供對Direct IO的支持(但C++使用很簡單),需要通過JNA的方式來調用,這裏推薦兩個DIO庫
IO方式的選擇
Buffer IO
適用於普通類型的文件讀寫,性能尚可,操作簡單,無注意事項。
MMAP
小數據量讀寫性能高,但不靈活。
Direct IO
需要自己控制Cache時,可以適用Direct IO,例如數據庫/中間件應用,可以避免文件的讀寫還經過一層Page Cache,造成額外開銷。