背景
零拷貝(Zero Copy)是一個耳熟能詳的術語,衆多高性能的網絡框架如Netty,Kafka,Rocket MQ都將零拷貝標榜爲其特性。那麼究竟什麼是零拷貝?
零拷貝
Wikipedia上對零拷貝的解釋如下:
“Zero-copy” describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
零拷貝防止了數據在內存中的複製,可以提升網絡傳輸的性能。由此產生兩個疑問:
- 爲什麼會出現數據的複製?
- 零拷貝真的是0次數據複製嗎?
Linux系統中的零拷貝
繼續往下之前,需要了解幾個OS
的概念:
- 內核空間:計算機內存被分爲用戶空間和內核空間。內核空間運行OS內核代碼,並可以訪問所有內存,機器指令和硬件資源,具有最高的權限。
- 用戶空間:即內核以外的所有空間,用於正常用戶進程運行。用戶空間的進程無權訪問內核空間,只能通過內核暴露的接口----系統調用(
system calls
)去訪問內核的一小部分。如果用戶進程請求執行系統調用,需要給內核發送系統中斷(software interrupt),內核會分派相應的中斷處理器處理請求。 - DMA:Direct Memory Access(
DMA
)是來應對CPU
與硬盤之間速度量級不匹配的問題的,它允許某些硬件子系統訪問獨立於CPU
的主內存。如果沒有DMA
,CPU
進行IO操作的整個過程都是阻塞的,無法執行其他工作,這會使計算機陷入假死狀態。如果有DMA
介入,IO過程變成這樣:CPU
啓動DMA
傳輸,期間它可以執行其他操作;DMA控制器(DMAC
)在傳輸完成後,會給CPU
發送中斷信號,這時CPU
便可以處理傳輸好的數據。
傳統的網絡傳輸
網絡IO的一個常見場景是,將文件從硬盤讀取出來,並通過網卡發送至網絡。以下是簡單的僞代碼:
// 從硬盤讀取數據
File.read(fileDesc, buf, len);
// 發送數據到網絡
Socket.write(socket, buf, len);
代碼層面,這是一個非常簡單的操作,但是深入到系統層面,我們來看看背後發生了什麼:
由於用戶空間無法直接訪問文件系統,所以,這個場景涉及到了三個模塊的交互:用戶空間,內核空間和硬件。
- 用戶發起
read()
系統調用(syscall
),請求硬盤數據。此時,會發生一次上下文切換(context switch)。 DMA
從硬盤讀取文件,這時,產生一次複製:硬盤–>DMA緩衝區。DMA
將數據複製到用戶空間,read()
調用返回。此時,發生一次上下文切換以及一次數據複製:DMA緩衝區–>用戶空間。- 用戶發起
write()
系統調用,請求發送數據。此時發生一次上下文切換和一次數據複製:用戶空間–>DMA緩衝區。 DMA
將數據複製到網卡,以備網絡發送。此時發生第四次數據複製:DMA緩衝區–>套接字緩衝區write()
調用返回,再次發生上下文切換。
數據流如下:
可以發現,其中共涉及到了4次上下文切換以及4次數據複製。對於單純的網絡文件發送,有很多不必要的開銷。
sendfile
傳輸
對於上述場景,我們發現從DMA緩衝到用戶空間,和從用戶空間到套接字緩衝的兩次CPU
複製是完全沒必要的,零拷貝由此而生。針對這種情況,Linux
內核提供了sendfile系統調用。如果用sendfile()
執行上述請求,系統流程可以簡化如下:
sendfile()
系統調用,可以實現數據在DMA
內部的複製,而不需要將數據copy到用戶空間。由此,上下文切換次數減少爲了2次,數據複製次數減少爲了3次。這已經實現了用戶空間的零拷貝。
這裏有一個問題:爲什麼DMA
內部會出現一次複製(此次複製需要CPU
參與)?這是因爲,早期的網卡,要求被髮送的數據在物理空間上是連續的,所以,需要有Socket Buffer
。但是如果網卡本身支持收集操作(scatter-gather),即可以從不連續的內存地址聚集併發送數據,那麼還可以進一步優化。
網卡支持scatter-gather
的sendfile
傳輸
在Linux
內核版本2.4之後對此做了優化,如果計算機網卡支持收集操作,sendfile()
操作可以省去到Socket Buffer
的數據複製,取而代之的是,直接將數據位置和長度的描述符(descriptors),傳遞給Socket Buffer
:
藉由網卡的支持,上下文切換的次數爲2次,數據複製的次數也降低爲2次。而這兩次的數據複製是必須的,也就是說,數據在內存中的複製已經完全避免。
對於從硬盤向網絡發送文件的場景,如果網卡支持收集操作,那麼sendfile()
系統調用,真正意義上的做到了零拷貝
內存映射(mmap)
對於“網絡發送文件”的情況,用sendfile()
系統調用可以極大地提高性能(據測試吞吐量可達傳統方式的三倍)。但有一點不足的是,它只支持“讀取->發送”這一“連貫操作”,所以,sendfile()
一般用於處理一些靜態網絡資源,如果要對數據進行額外的操作,它無能爲力。
內存映射(Memory mapping–mmap
)對此提供瞭解決方案。mmap
是一種內存映射文件的方法,它可以將一個文件映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中的虛擬地址的對應。如此一來,用戶進程可以採用指針讀寫操作這一段內存,而內核空間對這段區域的修改也直接反映到用戶空間。簡而言之,mmap
實現了用戶空間和內核空間數據的共享。可以猜到,如果使用mmap
系統調用,上文中所述場景的步驟如下:
用戶發起mmap()
系統調用,DMA
直接將數據複製到用戶空間和內核空間的共享虛擬內存,之後,用戶便可以正常操作數據。期間進行了2次上下文切換,1次數據複製。接下來往網卡發送數據的流程,與前面一樣採用write()
系統調用。
數據流如下:
可以看到,相比於傳統的方式,mmap
省去了一次數據的複製,廣義上也可以稱之爲零拷貝。與此同時,它還使得用戶可以自定義地操作數據,這是相較於sendfile
的優勢所在。
不過,如果數據量很小(比如KB
級別),使用mmap
的效率反而不如單純的read
系統調用高。這是因爲mmap
雖然避免了多餘的複製,但是增加了OS
維護此共享內存的成本。
NIO中的零拷貝
從1.4版本開始,JDK
引入了NIO
,提供了對零拷貝的支持。由於JVM
是運行在OS
之上的,其功能只是對系統底層api的封裝,如果OS
本身不支持零拷貝(mmap
/sendfile
),那JVM
對此也無能爲力。JDK
對零拷貝的封裝,主要體現在FileChannel這個類上。
map()
方法
map()
的簽名如下:
public abstract class FileChannel
extends AbstractInterruptibleChannel
implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel {
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
}
以下引自方法註釋:
Maps a region of this channel’s file directly into memory…For most operating systems, mapping a file into memory is more expensive than reading or writing a few tens of kilobytes of data via the usual read and write methods. From the standpoint of performance it is generally only worth mapping relatively large files into memory.
map()
方法可以直接將一個文件映射到內存中。來簡單看看FileChannelImpl
中方法的具體實現:
public class FileChannelImpl extends FileChannel {
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
...
synchronized (positionLock) {
...
try {
// 實際調用的是調用map0方法
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();
...
}
}
...
}
// Creates a new mapping
private native long map0(int prot, long position, long length) throws IOException;
}
最終調用的是一個native的map0()
方法。solaris
版的方法的源碼在FileChannelImpl.c中:
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len)
{
...
// 發現,內部果然是通過mmap系統調用來實現的
mapAddress = mmap64(
0, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */
if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}
return ((jlong) (unsigned long) mapAddress);
}
最終map()
方法會返回一個MappedByteBuffer
,熟悉NIO
的同學估計對這個類不會陌生,大名鼎鼎的DirectByteBuffer
便是它的子類。它引用了一塊獨立於JVM
之外的內存,不受GC
機制所管制,需要自己來管理創建與銷燬的操作。
transferTo()
方法
mmap
系統調用有了Java
版的馬甲,那sendfile
呢?來看看FileChannel
的transferTo()
方法,簽名如下:
public abstract class FileChannel
extends AbstractInterruptibleChannel
implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel {
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
}
以下引自方法註釋:
Transfers bytes from this channel’s file to the given writable byte channel…
This method is potentially much more efficient than a simple loop that reads from this channel and writes to the target channel. Many operating systems can transfer bytes directly from the filesystem cache to the target channel without actually copying them.
後半句其實隱式地說明了,如果操作系統支持“transfer without copying”,transferTo()
方法就能做到相應的支持。來看看FileChannelImpl
中方法的實現:
public long transferTo(long position, long count, WritableByteChannel target) throws IOException {
...
// Attempt a direct transfer, if the kernel supports it
// 如果內核支持,採用直接傳送的方式
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
// Attempt a mapped transfer, but only to trusted channel types
// 嘗試使用mmap傳送方式
// 其實這裏也用到了mmap,由於上面已經簡要介紹過,故不再展開
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
// Slow path for untrusted targets
// 傳統的傳送方式
return transferToArbitraryChannel(position, icount, target);
}
由註釋可以看出來,sendfile()
調用應該就發生在transferToDirectly()
方法中,我們進去看看:
private long transferToDirectly(long position, int icount, WritableByteChannel target) throws IOException {
if (!transferSupported)
return IOStatus.UNSUPPORTED;
// 一系列檢查判斷
...
if (nd.transferToDirectlyNeedsPositionLock()) {
synchronized (positionLock) {
long pos = position();
try {
// 調用的是transferToDirectlyInternal()方法
return transferToDirectlyInternal(position, icount, target, targetFD);
} finally {
position(pos);
}
}
} else {
// 調用的是transferToDirectlyInternal()方法
return transferToDirectlyInternal(position, icount, target, targetFD);
}
}
private long transferToDirectlyInternal(long position, int icount, WritableByteChannel target, FileDescriptor targetFD) throws IOException {
try {
begin();
ti = threads.add();
if (!isOpen())
return -1;
do {
// 轉到native方法transferTo0()
n = transferTo0(fd, position, icount, targetFD);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
...
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end (n > -1);
}
}
// Transfers from src to dst, or returns -2 if kernel can't do that
private native long transferTo0(FileDescriptor src, long position, long count, FileDescriptor dst);
可見,最終transferTo()
方法還是需要委託給native
的方法transferTo0()
來完成調用,此方法的源碼依然在FileChannelImpl.c中:
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jobject srcFDO,
jlong position, jlong count,
jobject dstFDO)
{
jint srcFD = fdval(env, srcFDO);
jint dstFD = fdval(env, dstFDO);
#if defined(__linux__)
off64_t offset = (off64_t)position;
// 果然,內部確實是sendfile()系統調用
jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
...
return n;
#elif defined (__solaris__)
sendfilevec64_t sfv;
size_t numBytes = 0;
jlong result;
sfv.sfv_fd = srcFD;
sfv.sfv_flag = 0;
sfv.sfv_off = (off64_t)position;
sfv.sfv_len = count;
// 果然,內部確實是sendfile()系統調用
result = sendfilev64(dstFD, &sfv, 1, &numBytes);
/* Solaris sendfilev() will return -1 even if some bytes have been
* transferred, so we check numBytes first.
*/
...
return result;
...
果不其然,最終方法還是通過sendfile()
系統調用來達到傳輸的目的。注意,由於sendfile()
只適用於往Socket Buffer
發送數據,所以,通過零拷貝技術來提升性能,只能用於網絡發送數據的場景。什麼意思呢?如果單純的用transferTo()
把數據從硬盤上的一個文件寫入到另一個文件中,是沒有性能提升效果的,詳見SendFile and transferTo in Java和Most efficient way to copy a file in Linux。
Netty中的零拷貝
分析完了Linux
內核和JVM
層面的零拷貝,再來看看Netty
中的零拷貝又是怎麼回事。
類似的,由於Netty
是構建在NIO
之上的一個高性能網絡IO框架,它也支持系統層面的零拷貝。舉一個簡單的例子,DefaultFileRegion
類可以進行高效的網絡文件傳輸,因爲它封裝了NIO
中FileChannel
的transferTo()
方法:
public class DefaultFileRegion extends AbstractReferenceCounted implements FileRegion {
private FileChannel file;
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);
}
open();
// 方法內部調用的是FileChannel的transferTo方法,
// 可以得到系統層面零拷貝的支持
long written = file.transferTo(this.position + position, count, target);
if (written > 0) {
transferred += written;
}
return written;
}
}
那是不是Netty
中所謂的零拷貝,完全依賴於系統支持呢?其實,零拷貝在Netty
中還有另外一層意義:防止JVM
中不必要的內存複製。
Netty in Action第5.1節是這麼介紹ByteBuf API的:
Transparent zero-copy is achieved by a built-in composite buffer type.
通過內置的composite buffer實現了透明的零拷貝,什麼意思呢?Netty
將物理上的多個Buffer
組合成了一個邏輯上完整的CompositeByteBuf
,它一般用在需要合成多個Buffer
的場景。這在網絡編程中很常見,如一個完整的http
請求常常會被分散到多個Buffer
中。用CompositeByteBuf
很容易將多個分散的Buffer
組裝到一起,而無需額外的複製:
ByteBuf header = Unpooled.buffer();// 模擬http請求頭
ByteBuf body = Unpooled.buffer();// 模擬http請求主體
CompositeByteBuf httpBuf = Unpooled.compositeBuffer();
// 這一步,不需要進行header和body的額外複製,httpBuf只是持有了header和body的引用
// 接下來就可以正常操作完整httpBuf了
httpBuf.addComponents(header, body);
反觀JDK
的實現ByteBuffer
是如何完成這一需求的:
ByteBuffer header = ByteBuffer.allocate(1024);// 模擬http請求頭
ByteBuffer body = ByteBuffer.allocate(1024);// 模擬http請求主體
// 需要創建一個新的ByteBuffer來存放合併後的buffer信息,這涉及到複製操作
ByteBuffer httpBuffer = ByteBuffer.allocate(header.remaining() + body.remaining());
// 將header和body放入新創建的Buffer中
httpBuffer.put(header);
httpBuffer.put(body);
httpBuffer.flip();
相比於JDK
,Netty
的實現更合理,省去了不必要的內存複製,可以稱得上是JVM
層面的零拷貝。除此之外,整個ByteBuf
的API都貫穿了零拷貝的設計理念:儘量避免Buffer
複製帶來的開銷。比如關於派生緩衝區(Derived buffers)的操作,duplicate()
(複製),slice()
(切分),order()
(排序)等,雖然都會返回一個新的ByteBuf
實例,但它們只是具有自己獨立的讀索引、寫索引和標記索引而已,內部存儲(Buffer
數據)是共享的,也就是過程中並沒有複製操作。由此帶來的一個負面影響是,使用這些操作的時候需要注意:修改原對象會影響派生對象,修改派生對象也會影響原對象。
總結
- 由於
Linux
系統中內核空間和用戶空間的區別,數據的讀取和發送需要有內存中的複製。mmap
系統調採用內存映射的方式,讓內核空間和用戶控件共享同一塊內存,省去了從內核空間往用戶空間複製的開銷。sendfile
系統調用可以將文件直接從硬盤經由DMA
傳輸到套接字緩衝區,而無需經過用戶空間。如果網卡支持收集操作(scatter-gather),那麼可以做到真正意義上的零拷貝。 NIO
中FileChannel
的map()
和transferTo()
方法封裝了底層的mmap
和sendfile
系統調用,從而在Java
語言上提供了系統層面零拷貝的支持。Netty
通過封裝,也可以支持系統級別的零拷貝。此外,Netty
中的零拷貝有另一層應用層面的含義:設計良好的ByteBuf
API,防止了JVM
內部不必要的Buffer
複製。