零拷貝(Zero-copy)及其應用詳解
點擊上方藍色字體,選擇“設為星標”

前言
零拷貝(Zero-copy)是一種高效的數(shù)據(jù)傳輸機制,在追求低延遲的傳輸場景中十分常用。本文先通過傳統(tǒng)方案引出零拷貝機制,然后分析其細節(jié),最后介紹它的部分應用。文中涉及到的操作系統(tǒng)理論知識都可以參考英文維基或者相關書籍,如Abraham Silberschatz著《操作系統(tǒng)概念》、Andrew S. Tanenbaum著《現(xiàn)代操作系統(tǒng)》等。
傳統(tǒng)的數(shù)據(jù)傳輸方法
在互聯(lián)網(wǎng)時代,從某臺機器將一份數(shù)據(jù)(比如一個文件)通過網(wǎng)絡傳輸?shù)搅硗庖慌_機器,是再平常不過的事情了。如果按照一般的思路,用Java語言來描述發(fā)送端的邏輯,大致如下。
Socket socket = new Socket(HOST, PORT);
InputStream inputStream = new FileInputStream(FILE_PATH);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
while (inputStream.read(buffer) >= 0) {
outputStream.write(buffer);
}
outputStream.close();
socket.close();
inputStream.close();看起來當然是很簡單的。但是如果我們深入到操作系統(tǒng)的層面,就會發(fā)現(xiàn)實際的微觀操作要更復雜,具體來說有以下步驟:
JVM向OS發(fā)出read()系統(tǒng)調(diào)用,觸發(fā)上下文切換,從用戶態(tài)切換到內(nèi)核態(tài)。
從外部存儲(如硬盤)讀取文件內(nèi)容,通過直接內(nèi)存訪問(DMA)存入內(nèi)核地址空間的緩沖區(qū)。
將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶空間緩沖區(qū),read()系統(tǒng)調(diào)用返回,并從內(nèi)核態(tài)切換回用戶態(tài)。
JVM向OS發(fā)出write()系統(tǒng)調(diào)用,觸發(fā)上下文切換,從用戶態(tài)切換到內(nèi)核態(tài)。
將數(shù)據(jù)從用戶緩沖區(qū)拷貝到內(nèi)核中與目的地Socket關聯(lián)的緩沖區(qū)。
數(shù)據(jù)最終經(jīng)由Socket通過DMA傳送到硬件(如網(wǎng)卡)緩沖區(qū),write()系統(tǒng)調(diào)用返回,并從內(nèi)核態(tài)切換回用戶態(tài)。
如果語言描述看起來有些亂的話,通過時序圖描述會更清楚一些。
傳統(tǒng)方法的時序圖
到了這一步,你是否覺得簡單的代碼邏輯下隱藏著很累贅的東西了?事實也確實如此,這個過程一共發(fā)生了4次上下文切換(嚴格來講是模式切換),并且數(shù)據(jù)也被來回拷貝了4次。如果忽略掉系統(tǒng)調(diào)用的細節(jié),整個過程可以用下面的兩張簡圖表示。
傳統(tǒng)方法的流程框圖
傳統(tǒng)方法的上下文切換過程
我們都知道,上下文切換是CPU密集型的工作,數(shù)據(jù)拷貝是I/O密集型的工作。如果一次簡單的傳輸就要像上面這樣復雜的話,效率是相當?shù)拖碌摹A憧截悪C制的終極目標,就是消除冗余的上下文切換和數(shù)據(jù)拷貝,提高效率。
零拷貝的數(shù)據(jù)傳輸方法
“基礎的”零拷貝機制
通過上面的分析可以看出,第2、3次拷貝(也就是從內(nèi)核空間到用戶空間的來回復制)是沒有意義的,數(shù)據(jù)應該可以直接從內(nèi)核緩沖區(qū)直接送入Socket緩沖區(qū)。零拷貝機制就實現(xiàn)了這一點。不過零拷貝需要由操作系統(tǒng)直接支持,不同OS有不同的實現(xiàn)方法。大多數(shù)Unix-like系統(tǒng)都是提供了一個名為sendfile()的系統(tǒng)調(diào)用,在其man page中,就有這樣的描述:
sendfile() copies data between one file descriptor and another.
Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.下面是零拷貝機制下,數(shù)據(jù)傳輸?shù)臅r序圖。
零拷貝方法的時序圖
可見確實是消除了從內(nèi)核空間到用戶空間的來回復制,因此“zero-copy”這個詞實際上是站在內(nèi)核的角度來說的,并不是完全不會發(fā)生任何拷貝。
在Java NIO包中提供了零拷貝機制對應的API,即FileChannel.transferTo()方法。不過FileChannel類是抽象類,transferTo()也是一個抽象方法,因此還要依賴于具體實現(xiàn)。FileChannel的實現(xiàn)類并不在JDK本身,而位于sun.nio.ch.FileChannelImpl類中,零拷貝的具體實現(xiàn)自然也都是native方法,看官如有興趣可以自行查找源碼來看,這里不再贅述。
將傳統(tǒng)方式的發(fā)送端邏輯改寫一下,大致如下。
SocketAddress socketAddress = new InetSocketAddress(HOST, PORT);
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(socketAddress);
File file = new File(FILE_PATH);
FileChannel fileChannel = new FileInputStream(file).getChannel();
fileChannel.transferTo(0, file.length(), socketChannel);
fileChannel.close();
socketChannel.close();借助transferTo()方法的話,整個過程就可以用下面的簡圖表示了。
零拷貝方法的流程框圖
零拷貝方法的上下文切換過程
可見,不僅拷貝的次數(shù)變成了3次,上下文切換的次數(shù)也減少到了2次,效率比傳統(tǒng)方式高了很多。但是它還并非完美狀態(tài),下面看一看讓它變得更優(yōu)化的方法。
對Scatter/Gather的支持
在“基礎”零拷貝方式的時序圖中,有一個“write data to target socket buffer”的回環(huán),在框圖中也有一個從“Read buffer”到“Socket buffer”的大箭頭。這是因為在一般的Block DMA方式中,源物理地址和目標物理地址都得是連續(xù)的,所以一次只能傳輸物理上連續(xù)的一塊數(shù)據(jù),每傳輸一個塊發(fā)起一次中斷,直到傳輸完成,所以必須要在兩個緩沖區(qū)之間拷貝數(shù)據(jù)。
而Scatter/Gather DMA方式則不同,會預先維護一個物理上不連續(xù)的塊描述符的鏈表,描述符中包含有數(shù)據(jù)的起始地址和長度。傳輸時只需要遍歷鏈表,按序傳輸數(shù)據(jù),全部完成后發(fā)起一次中斷即可,效率比Block DMA要高。也就是說,硬件可以通過Scatter/Gather DMA直接從內(nèi)核緩沖區(qū)中取得全部數(shù)據(jù),不需要再從內(nèi)核緩沖區(qū)向Socket緩沖區(qū)拷貝數(shù)據(jù)。因此上面的時序圖還可以進一步簡化。
支持Scatter/Gather的零拷貝時序圖
這就是完全體的零拷貝機制了,是不是清爽了很多?相對地,它的流程框圖如下。
支持Scatter/Gather的零拷貝流程框圖
對內(nèi)存映射(mmap)的支持
上面講的機制看起來一切都很好,但它還是有個缺點:如果我想在傳輸時修改數(shù)據(jù)本身,就無能為力了。不過,很多操作系統(tǒng)也提供了內(nèi)存映射機制,對應的系統(tǒng)調(diào)用為mmap()/munmap()。通過它可以將文件數(shù)據(jù)映射到內(nèi)核地址空間,直接進行操作,操作完之后再刷回去。其對應的簡要時序圖如下。
支持mmap的零拷貝時序圖
當然,天下沒有免費的午餐,上面的過程仍然會發(fā)生4次上下文切換。另外,它需要在快表(TLB)中始終維護著所有數(shù)據(jù)對應的地址空間,直到刷寫完成,因此處理缺頁的overhead也會更大。在使用該機制時,需要權衡效率。
NIO框架中提供了MappedByteBuffer用來支持mmap。它與常用的DirectByteBuffer一樣,都是在堆外內(nèi)存分配空間。相對地,HeapByteBuffer在堆內(nèi)內(nèi)存分配空間。
零拷貝機制的應用
零拷貝在很多框架中得到了廣泛應用,一般都以Netty為例來分析。但作為大數(shù)據(jù)工程師,我就以Kafka與Spark為例來簡單說兩句吧。
在Kafka中的應用
在使用Kafka時,我們經(jīng)常會想,為什么Kafka能夠達到如此巨大的數(shù)據(jù)吞吐量?這與Kafka的很多設計哲學是分不開的,比如分區(qū)并行、ISR機制、順序寫入、頁緩存、高效序列化等等,零拷貝當然也是其中之一。由于Kafka的消息存儲涉及到海量數(shù)據(jù)讀寫,所以利用零拷貝能夠顯著地降低延遲,提高效率。
在Kafka中,底層傳輸動作由TransportLayer接口來定義。它對SocketChannel進行了簡單的封裝,其中transferFrom()方法定義如下。(Kafka版本為0.10.2.2)
/**
* Transfers bytes from `fileChannel` to this `TransportLayer`.
*
* This method will delegate to {@link FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)},
* but it will unwrap the destination channel, if possible, in order to benefit from zero copy. This is required
* because the fast path of `transferTo` is only executed if the destination buffer inherits from an internal JDK
* class.
*
* @param fileChannel The source channel
* @param position The position within the file at which the transfer is to begin; must be non-negative
* @param count The maximum number of bytes to be transferred; must be non-negative
* @return The number of bytes, possibly zero, that were actually transferred
* @see FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)
*/
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException;該方法的功能是將FileChannel中的數(shù)據(jù)傳輸?shù)絋ransportLayer,也就是SocketChannel。在實現(xiàn)類PlaintextTransportLayer的對應方法中,就是直接調(diào)用了FileChannel.transferTo()方法。
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}對該方法的調(diào)用則位于FileRecords.writeTo()方法中,用于將Kafka收到的緩存數(shù)據(jù)零拷貝地寫入目的Channel。
@Override
public long writeTo(GatheringByteChannel destChannel, long offset, int length) throws IOException {
long newSize = Math.min(channel.size(), end) - start;
int oldSize = sizeInBytes();
if (newSize < oldSize)
throw new KafkaException(String.format(
"Size of FileRecords %s has been truncated during write: old size %d, new size %d",
file.getAbsolutePath(), oldSize, newSize));
long position = start + offset;
int count = Math.min(length, oldSize);
final long bytesTransferred;
if (destChannel instanceof TransportLayer) {
TransportLayer tl = (TransportLayer) destChannel;
bytesTransferred = tl.transferFrom(channel, position, count);
} else {
bytesTransferred = channel.transferTo(position, count, destChannel);
}
return bytesTransferred;
}在Spark中的應用 Spark雖然是一個高效的積極使用內(nèi)存的計算框架,但在需要使用磁盤時也會適當?shù)匾鐚憽A憧截悪C制在Spark Core中主要就被用來優(yōu)化Shuffle過程中的溢寫邏輯。由于Shuffle過程涉及大量的數(shù)據(jù)交換,因此效率當然是越高越好。
在啟用Bypass機制的Sort Shuffle以及Tungsten Sort Shuffle的shuffle write階段,都使用了零拷貝來快速合并溢寫文件的分片,有一個專門的配置項spark.file.transferTo來控制是否啟用零拷貝(默認當然是true)。以BypassMergeSortShuffleWriter為例,它最終是調(diào)用了通用工具類Utils中的copyFileStreamNIO()方法。
def copyFileStreamNIO(
input: FileChannel,
output: FileChannel,
startPosition: Long,
bytesToCopy: Long): Unit = {
val initialPos = output.position()
var count = 0L
// In case transferTo method transferred less data than we have required.
while (count < bytesToCopy) {
count += input.transferTo(count + startPosition, bytesToCopy - count, output)
}
assert(count == bytesToCopy,
s"request to copy $bytesToCopy bytes, but actually copied $count bytes.")
val finalPos = output.position()
val expectedPos = initialPos + bytesToCopy
assert(finalPos == expectedPos,
s"""
|Current position $finalPos do not equal to expected position $expectedPos
|after transferTo, please check your kernel version to see if it is 2.6.32,
|this is a kernel bug which will lead to unexpected behavior when using transferTo.
|You can set spark.file.transferTo = false to disable this NIO feature.
""".stripMargin)
}可見,該方法用于將數(shù)據(jù)從一個FileChannel零拷貝到另一個FileChannel。通過控制起始位置和長度參數(shù),就可以精確地將所有溢寫文件拼合在一起了。

版權聲明:
文章不錯?點個【在看】吧!??




