傳統 Linux 中的零拷貝技術
在介紹 Netty 零拷貝特性之前,我們有必要學習下傳統 Linux 中零拷貝的工作原理。所謂零拷貝,就是在數據操作時,不需要將數據從一個內存位置拷貝到另外一個內存位置,這樣可以減少一次內存拷貝的損耗,從而節省了 CPU 時鍾周期和內存帶寬。
我們模擬一個場景,從文件中讀取數據,然后將數據傳輸到網絡上,那么傳統的數據拷貝過程會分為哪幾個階段呢?具體如下圖所示。
從上圖中可以看出,從數據讀取到發送一共經歷了四次數據拷貝,具體流程如下:
- 當用戶進程發起 read() 調用后,上下文從用戶態切換至內核態。DMA 引擎從文件中讀取數據,並存儲到內核態緩沖區,這里是第一次數據拷貝。
- 請求的數據從內核態緩沖區拷貝到用戶態緩沖區,然后返回給用戶進程。第二次數據拷貝的過程同時,會導致上下文從內核態再次切換到用戶態。
- 用戶進程調用 send() 方法期望將數據發送到網絡中,此時會觸發第三次線程切換,用戶態會再次切換到內核態,請求的數據從用戶態緩沖區被拷貝到 Socket 緩沖區。
- 最終 send() 系統調用結束返回給用戶進程,發生了第四次上下文切換。第四次拷貝會異步執行,從 Socket 緩沖區拷貝到協議引擎中。
說明:DMA(Direct Memory Access,直接內存存取)是現代大部分硬盤都支持的特性,DMA 接管了數據讀寫的工作,不需要 CPU 再參與 I/O 中斷的處理,從而減輕了 CPU 的負擔。
傳統的數據拷貝過程為什么不是將數據直接傳輸到用戶緩沖區呢?其實引入內核緩沖區可以充當緩存的作用,這樣就可以實現文件數據的預讀,提升 I/O 的性能。但是當請求數據量大於內核緩沖區大小時,在完成一次數據的讀取到發送可能要經歷數倍次數的數據拷貝,這就造成嚴重的性能損耗。
接下來我們介紹下使用零拷貝技術之后數據傳輸的流程。重新回顧一遍傳統數據拷貝的過程,可以發現第二次和第三次拷貝是可以去除的,DMA 引擎從文件讀取數據后放入到內核緩沖區,然后可以直接從內核緩沖區傳輸到 Socket 緩沖區,從而減少內存拷貝的次數。
在 Linux 中系統調用 sendfile() 可以實現將數據從一個文件描述符傳輸到另一個文件描述符,從而實現了零拷貝技術。在 Java 中也使用了零拷貝技術,它就是 NIO FileChannel 類中的 transferTo() 方法,transferTo() 底層就依賴了操作系統零拷貝的機制,它可以將數據從 FileChannel 直接傳輸到另外一個 Channel。transferTo() 方法的定義如下:
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
FileChannel#transferTo() 的使用也非常簡單,我們直接看如下的代碼示例,通過 transferTo() 將 from.data 傳輸到 to.data(),等於實現了文件拷貝的功能。
public void testTransferTo() throws IOException { RandomAccessFile fromFile = new RandomAccessFile("from.data", "rw"); FileChannel fromChannel = fromFile.getChannel(); RandomAccessFile toFile = new RandomAccessFile("to.data", "rw"); FileChannel toChannel = toFile.getChannel(); long position = 0; long count = fromChannel.size(); fromChannel.transferTo(position, count, toChannel); }
在使用了 FileChannel#transferTo() 傳輸數據之后,我們看下數據拷貝流程發生了哪些變化,如下圖所示:
比較大的一個變化是,DMA 引擎從文件中讀取數據拷貝到內核態緩沖區之后,由操作系統直接拷貝到 Socket 緩沖區,不再拷貝到用戶態緩沖區,所以數據拷貝的次數從之前的 4 次減少到 3 次。
但是上述的優化離達到零拷貝的要求還是有差距的,能否繼續減少內核中的數據拷貝次數呢?在 Linux 2.4 版本之后,開發者對 Socket Buffer 追加一些 Descriptor 信息來進一步減少內核數據的復制。如下圖所示,DMA 引擎讀取文件內容並拷貝到內核緩沖區,然后並沒有再拷貝到 Socket 緩沖區,只是將數據的長度以及位置信息被追加到 Socket 緩沖區,然后 DMA 引擎根據這些描述信息,直接從內核緩沖區讀取數據並傳輸到協議引擎中,從而消除最后一次 CPU 拷貝。
通過上述 Linux 零拷貝技術的介紹,你也許還會存在疑問,最終使用零拷貝之后,不是還存在着數據拷貝操作嗎?其實從 Linux 操作系統的角度來說,零拷貝就是為了避免用戶態和內存態之間的數據拷貝。無論是傳統的數據拷貝還是使用零拷貝技術,其中有 2 次 DMA 的數據拷貝必不可少,只是這 2 次 DMA 拷貝都是依賴硬件來完成,不需要 CPU 參與。所以,在這里我們討論的零拷貝是個廣義的概念,只要能夠減少不必要的 CPU 拷貝,都可以被稱為零拷貝。
Netty 的零拷貝技術
介紹完傳統 Linux 的零拷貝技術之后,我們再來學習下 Netty 中的零拷貝如何實現。Netty 中的零拷貝和傳統 Linux 的零拷貝不太一樣。Netty 中的零拷貝技術除了操作系統級別的功能封裝,更多的是面向用戶態的數據操作優化,主要體現在以下 5 個方面:
- 堆外內存,避免 JVM 堆內存到堆外內存的數據拷貝。
- CompositeByteBuf 類,可以組合多個 Buffer 對象合並成一個邏輯上的對象,避免通過傳統內存拷貝的方式將幾個 Buffer 合並成一個大的 Buffer。
- 通過 Unpooled.wrappedBuffer 可以將 byte 數組包裝成 ByteBuf 對象,包裝過程中不會產生內存拷貝。
- ByteBuf.slice 操作與 Unpooled.wrappedBuffer 相反,slice 操作可以將一個 ByteBuf 對象切分成多個 ByteBuf 對象,切分過程中不會產生內存拷貝,底層共享一個 byte 數組的存儲空間。
- Netty 使用 FileRegion 實現文件傳輸,FileRegion 底層封裝了 FileChannel#transferTo() 方法,可以將文件緩沖區的數據直接傳輸到目標 Channel,避免內核緩沖區和用戶態緩沖區之間的數據拷貝,這屬於操作系統級別的零拷貝。
1、堆外內存
如果在 JVM 內部執行 I/O 操作時,必須將數據拷貝到堆外內存,才能執行系統調用。這是所有 VM 語言都會存在的問題。那么為什么操作系統不能直接使用 JVM 堆內存進行 I/O 的讀寫呢?主要有兩點原因:第一,操作系統並不感知 JVM 的堆內存,而且 JVM 的內存布局與操作系統所分配的是不一樣的,操作系統並不會按照 JVM 的行為來讀寫數據。第二,同一個對象的內存地址隨着 JVM GC 的執行可能會隨時發生變化,例如 JVM GC 的過程中會通過壓縮來減少內存碎片,這就涉及對象移動的問題了。
Netty 在進行 I/O 操作時都是使用的堆外內存,可以避免數據從 JVM 堆內存到堆外內存的拷貝。
2、CompositeByteBuf
CompositeByteBuf 是 Netty 中實現零拷貝機制非常重要的一個數據結構,CompositeByteBuf 可以理解為一個虛擬的 Buffer 對象,它是由多個 ByteBuf 組合而成,但是在 CompositeByteBuf 內部保存着每個 ByteBuf 的引用關系,從邏輯上構成一個整體。比較常見的像 HTTP 協議數據可以分為頭部信息 header和消息體數據 body,分別存在兩個不同的 ByteBuf 中,通常我們需要將兩個 ByteBuf 合並成一個完整的協議數據進行發送,可以使用如下方式完成:
ByteBuf httpBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
httpBuf.writeBytes(header);
httpBuf.writeBytes(body);
可以看出,如果想實現 header 和 body 這兩個 ByteBuf 的合並,需要先初始化一個新的 httpBuf,然后再將 header 和 body 分別拷貝到新的 httpBuf。合並過程中涉及兩次 CPU 拷貝,這非常浪費性能。如果使用 CompositeByteBuf 如何實現類似的需求呢?如下所示:
CompositeByteBuf httpBuf = Unpooled.compositeBuffer(); httpBuf.addComponents(true, header, body);
CompositeByteBuf 通過調用 addComponents() 方法來添加多個 ByteBuf,但是底層的 byte 數組是復用的,不會發生內存拷貝。但對於用戶來說,它可以當作一個整體進行操作。那么 CompositeByteBuf 內部是如何存放這些 ByteBuf,並且如何進行合並的呢?我們先通過一張圖看下 CompositeByteBuf 的內部結構:
從圖上可以看出,CompositeByteBuf 內部維護了一個 Components 數組。在每個 Component 中存放着不同的 ByteBuf,各個 ByteBuf 獨立維護自己的讀寫索引,而 CompositeByteBuf 自身也會單獨維護一個讀寫索引。由此可見,Component 是實現 CompositeByteBuf 的關鍵所在,下面看下 Component 結構定義:
private static final class Component { final ByteBuf srcBuf; // 原始的 ByteBuf final ByteBuf buf; // srcBuf 去除包裝之后的 ByteBuf int srcAdjustment; // CompositeByteBuf 的起始索引相對於 srcBuf 讀索引的偏移 int adjustment; // CompositeByteBuf 的起始索引相對於 buf 的讀索引的偏移 int offset; // Component 相對於 CompositeByteBuf 的起始索引位置 int endOffset; // Component 相對於 CompositeByteBuf 的結束索引位置 // 省略其他代碼 }
為了方便理解上述 Component 中的屬性含義,我同樣以 HTTP 協議中 header 和 body 為示例,通過一張圖來描述 CompositeByteBuf 組合后其中 Component 的布局情況,如下所示:
從圖中可以看出,header 和 body 分別對應兩個 ByteBuf,假設 ByteBuf 的內容分別為 "header" 和 "body",那么 header ByteBuf 中 offset~endOffset 為 0~6,body ByteBuf 對應的 offset~endOffset 為 0~10。由此可見,Component 中的 offset 和 endOffset 可以表示當前 ByteBuf 可以讀取的范圍,通過 offset 和 endOffset 可以將每一個 Component 所對應的 ByteBuf 連接起來,形成一個邏輯整體。
此外 Component 中 srcAdjustment 和 adjustment 表示 CompositeByteBuf 起始索引相對於 ByteBuf 讀索引的偏移。初始 adjustment = readIndex - offset,這樣通過 CompositeByteBuf 的起始索引就可以直接定位到 Component 中 ByteBuf 的讀索引位置。當 header ByteBuf 讀取 1 個字節,body ByteBuf 讀取 2 個字節,此時每個 Component 的屬性又會發生什么變化呢?如下圖所示。
至此,CompositeByteBuf 的基本原理我們已經介紹完了。
3、Unpooled.wrappedBuffer 操作
介紹完 CompositeByteBuf 之后,再來理解 Unpooled.wrappedBuffer 操作就非常容易了,Unpooled.wrappedBuffer 同時也是創建 CompositeByteBuf 對象的另一種推薦做法。
Unpooled 提供了一系列用於包裝數據源的 wrappedBuffer 方法,如下所示:
Unpooled.wrappedBuffer 方法可以將不同的數據源的一個或者多個數據包裝成一個大的 ByteBuf 對象,其中數據源的類型包括 byte[]、ByteBuf、ByteBuffer。包裝的過程中不會發生數據拷貝操作,包裝后生成的 ByteBuf 對象和原始 ByteBuf 對象是共享底層的 byte 數組。
4、ByteBuf.slice 操作
ByteBuf.slice 和 Unpooled.wrappedBuffer 的邏輯正好相反,ByteBuf.slice 是將一個 ByteBuf 對象切分成多個共享同一個底層存儲的 ByteBuf 對象。
ByteBuf 提供了兩個 slice 切分方法:
public ByteBuf slice(); public ByteBuf slice(int index, int length);
假設我們已經有一份完整的 HTTP 數據,可以通過 slice 方法切分獲得 header 和 body 兩個 ByteBuf 對象,對應的內容分別為 "header" 和 "body",實現方式如下:
ByteBuf httpBuf = ... ByteBuf header = httpBuf.slice(0, 6); ByteBuf body = httpBuf.slice(6, 4);
通過 slice 切分后都會返回一個新的 ByteBuf 對象,而且新的對象有自己獨立的 readerIndex、writerIndex 索引,如下圖所示。由於新的 ByteBuf 對象與原始的 ByteBuf 對象數據是共享的,所以通過新的 ByteBuf 對象進行數據操作也會對原始 ByteBuf 對象生效。
5、文件傳輸 FileRegion
在 Netty 源碼的 example 包中,提供了 FileRegion 的使用示例,以下代碼片段摘自 FileServerHandler.java。
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception { RandomAccessFile raf = null; long length = -1; try { raf = new RandomAccessFile(msg, "r"); length = raf.length(); } catch (Exception e) { ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n'); return; } finally { if (length < 0 && raf != null) { raf.close(); } } ctx.write("OK: " + raf.length() + '\n'); if (ctx.pipeline().get(SslHandler.class) == null) { // SSL not enabled - can use zero-copy file transfer. ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length)); } else { // SSL enabled - cannot use zero-copy file transfer. ctx.write(new ChunkedFile(raf)); } ctx.writeAndFlush("\n"); }
從 FileRegion 的使用示例可以看出,Netty 使用 FileRegion 實現文件傳輸的零拷貝。FileRegion 的默認實現類是 DefaultFileRegion,通過 DefaultFileRegion 將文件內容寫入到 NioSocketChannel。那么 FileRegion 是如何實現零拷貝的呢?我們通過源碼看看 FileRegion 到底使用了什么黑科技。
private final File f; // 傳輸的文件 private final long position; // 文件的起始位置 private final long count; // 傳輸的字節數 private long transferred; // 已經寫入的字節數 private FileChannel file; // 文件對應的 FileChannel @Override public long transferTo(WritableByteChannel target, long position) throws IOException { long count = this.count - position; if (count < 0 || position < 0) { throw new IllegalArgumentException( "position out of range: " + position + " (expected: 0 - " + (this.count - 1) + ')'); } if (count == 0) { return 0L; } if (refCnt() == 0) { throw new IllegalReferenceCountException(0); } // Call open to make sure fc is initialized. This is a no-oop if we called it before. open(); long written = file.transferTo(this.position + position, count, target); if (written > 0) { transferred += written; } else if (written == 0) { // If the amount of written data is 0 we need to check if the requested count is bigger then the // actual file itself as it may have been truncated on disk. // // See https://github.com/netty/netty/issues/8868 validate(this, position); } return written; }
從源碼可以看出,FileRegion 其實就是對 FileChannel 的包裝,並沒有什么特殊操作,底層使用的是 JDK NIO 中的 FileChannel#transferTo() 方法實現文件傳輸,所以 FileRegion 是操作系統級別的零拷貝,對於傳輸大文件會很有幫助。
到此為止,Netty 相關的零拷貝技術都已經介紹完了。