真正的零拷貝有兩種方式:
- mmap+write
- Sendfile
mmap 是一種內存映射文件的方法,即將一個文件或者其他對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對應關系。
這樣就可以省掉原來內核 Read 緩沖區 Copy 數據到用戶緩沖區,但是還是需要內核 Read 緩沖區將數據 Copy 到內核 Socket 緩沖區。所以Copy次數減少到了三次。
Sendfile 系統調用在內核版本 2.1 中被引入,目的是簡化通過網絡在兩個通道之間進行的數據傳輸過程。
Sendfile 系統調用的引入,不僅減少了數據復制,還減少了上下文切換的次數,(mmap中還是要有用戶態的參與,參與虛擬地址映射,sendfile則徹底不需要用戶態的參與,所以減少了上下文切換的次數)
數據傳送只發生在內核空間,所以減少了一次上下文切換;但是還是存在一次 Copy,也可以把這一次 Copy 也省略掉,
Linux2.4 內核中做了改進,將 Kernel buffer 中對應的數據描述信息(內存地址,偏移量)記錄到相應的 Socket 緩沖區當中,這樣連內核空間中的一次 CPU Copy 也省掉了。
Java中提供了mmap+write的實現,
MappedByteBuffer
Java NIO 提供的 FileChannel 提供了 map() 方法,該方法可以在一個打開的文件和 MappedByteBuffer 之間建立一個虛擬內存映射。
MappedByteBuffer 繼承於 ByteBuffer,類似於一個基於內存的緩沖區,只不過該對象的數據元素存儲在磁盤的一個文件中。
調用 get() 方法會從磁盤中獲取數據,此數據反映該文件當前的內容,調用 put() 方法會更新磁盤上的文件,並且對文件做的修改對其他閱讀者也是可見的。
map()方法如下:
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
分別提供了三個參數,MapMode,Position 和 Size,分別表示:
- MapMode:映射的模式,可選項包括:READ_ONLY,READ_WRITE,PRIVATE。
- Position:從哪個位置開始映射,字節數的位置。
- Size:從 Position 開始向后多少個字節。
重點看一下 MapMode,前兩個分別表示只讀和可讀可寫,當然請求的映射模式受到 Filechannel 對象的訪問權限限制,如果在一個沒有讀權限的文件上啟用 READ_ONLY,將拋出 NonReadableChannelException。
PRIVATE 模式表示寫時拷貝的映射,意味着通過 put() 方法所做的任何修改都會導致產生一個私有的數據拷貝並且該拷貝中的數據只有 MappedByteBuffer 實例可以看到。
該過程不會對底層文件做任何修改,而且一旦緩沖區被施以垃圾收集動作(garbage collected),那些修改都會丟失。
大致瀏覽一下 map() 方法的源碼:
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException { ...... int pagePosition = (int)(position % allocationGranularity); long mapPosition = position - pagePosition; long mapSize = size + pagePosition; try { // If no exception was thrown from map0, the address is valid addr = map0(imode, mapPosition, mapSize); } catch (OutOfMemoryError x) { // An OutOfMemoryError may indicate that we've exhausted memory // so force gc and re-attempt map System.gc(); try { Thread.sleep(100); } catch (InterruptedException y) { Thread.currentThread().interrupt(); } try { addr = map0(imode, mapPosition, mapSize); } catch (OutOfMemoryError y) { // After a second OOME, fail throw new IOException("Map failed", y); } } // On Windows, and potentially other platforms, we need an open // file descriptor for some mapping operations. FileDescriptor mfd; try { mfd = nd.duplicateForMapping(fd); } catch (IOException ioe) { unmap0(addr, mapSize); throw ioe; } assert (IOStatus.checkAll(addr)); assert (addr % allocationGranularity == 0); int isize = (int)size; Unmapper um = new Unmapper(addr, mapSize, isize, mfd); if ((!writable) || (imode == MAP_RO)) { return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um); } else { return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um); } }
大致意思就是通過 Native 方法獲取內存映射的地址,如果失敗,手動 GC 再次映射。
最后通過內存映射的地址實例化出 MappedByteBuffer,MappedByteBuffer 本身是一個抽象類,其實這里真正實例化出來的是 DirectByteBuffer。
DirectByteBuffer 繼承於 MappedByteBuffer,從名字就可以猜測出開辟了一段直接的內存,並不會占用 JVM 的內存空間。所以別名叫堆外內存,這也是netty中的那個DirectByteBuffer。
Netty 提供了零拷貝的 Buffer,除了DirectByteBuffer這種mmap的方式,其它的宣稱提供的零拷貝操作不能算是真正意義上的零拷貝,叫”少拷貝“更為合適,比如因為在傳輸數據時,最終處理的數據會需要對單個傳輸的報文,進行組合和拆分,NIO 原生的 ByteBuffer 無法做到,Netty 通過提供的 Composite(組合)和 Slice(拆分)兩種 Buffer 來實現”少拷貝“。
如果在TCP 層 HTTP 報文被分成了兩個 ChannelBuffer(分別對應HTTP報文的Header和Body),這兩個 Buffer 對我們上層的邏輯(HTTP 處理)是沒有意義的。
但是兩個 ChannelBuffer 被組合起來,就成為了一個有意義的 HTTP 報文,這個報文對應的 ChannelBuffer,才是能稱之為“Message”的東西,這里用到了一個詞“Virtual Buffer”。
可以看一下 Netty 提供的 CompositeChannelBuffer 源碼:
public class CompositeChannelBuffer extends AbstractChannelBuffer { private final ByteOrder order; private ChannelBuffer[] components; private int[] indices; private int lastAccessedComponentId; private final boolean gathering; public byte getByte(int index) { int componentId = componentId(index); return components[componentId].getByte(index - indices[componentId]); } ......
Components 用來保存的就是所有接收到的 Buffer,Indices 記錄每個 buffer 的起始位置,lastAccessedComponentId 記錄上一次訪問的 ComponentId。
CompositeChannelBuffer 並不會開辟新的內存並直接復制所有 ChannelBuffer 內容,而是直接保存了所有 ChannelBuffer 的引用,並在子 ChannelBuffer 里進行讀寫,實現了零拷貝(少拷貝)。
其它零拷貝:
RocketMQ 的消息采用順序寫到 commitlog 文件,然后利用 consume queue 文件作為索引,RocketMQ 采用零拷貝 mmap+write 的方式來回應 Consumer 的請求。
同樣 Kafka 中存在大量的網絡數據持久化到磁盤和磁盤文件通過網絡發送的過程,Kafka使用了 Sendfile 零拷貝方式。