零拷貝(Zero-copy)及其應用詳解


傳統的數據傳輸方法

在互聯網時代,從某台機器將一份數據(比如一個文件)通過網絡傳輸到另外一台機器,是再平常不過的事情了。如果按照一般的思路,用Java語言來描述發送端的邏輯,大致如下:

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();

看起來當然是很簡單的。但是如果我們深入到操作系統的層面,就會發現實際的微觀操作要更復雜,具體來說有以下步驟:

  1. JVM向OS發出read()系統調用,觸發上下文切換,從用戶態切換到內核態。

  2. 從外部存儲(如硬盤)讀取文件內容,通過直接內存訪問(DMA)存入內核地址空間的緩沖區。

  3. 將數據從內核緩沖區拷貝到用戶空間緩沖區,read()系統調用返回,並從內核態切換回用戶態。

  4. JVM向OS發出write()系統調用,觸發上下文切換,從用戶態切換到內核態。

  5. 將數據從用戶緩沖區拷貝到內核中與目的地Socket關聯的緩沖區。

  6. 數據最終經由Socket通過DMA傳送到硬件(如網卡)緩沖區,write()系統調用返回,並從內核態切換回用戶態。

如果語言描述看起來有些亂的話,通過時序圖描述會更清楚一些。

 

到了這一步,你是否覺得簡單的代碼邏輯下隱藏着很累贅的東西了?事實也確實如此,這個過程一共發生了4次上下文切換(嚴格來講是模式切換),並且數據也被來回拷貝了4次。如果忽略掉系統調用的細節,整個過程可以用下面的兩張簡圖表示。

 

 

 

 

傳統方法的流程框圖

 

 

 

傳統方法的上下文切換過程

我們都知道,上下文切換是CPU密集型的工作,數據拷貝是I/O密集型的工作。如果一次簡單的傳輸就要像上面這樣復雜的話,效率是相當低下的。零拷貝機制的終極目標,就是消除冗余的上下文切換和數據拷貝,提高效率。

零拷貝的數據傳輸方法

“基礎的”零拷貝機制

通過上面的分析可以看出,第2、3次拷貝(也就是從內核空間到用戶空間的來回復制)是沒有意義的,數據應該可以直接從內核緩沖區直接送入Socket緩沖區。零拷貝機制就實現了這一點。不過零拷貝需要由操作系統直接支持,不同OS有不同的實現方法。大多數Unix-like系統都是提供了一個名為sendfile()的系統調用,在其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.

下面是零拷貝機制下,數據傳輸的時序圖。

 

 

 

可見確實是消除了從內核空間到用戶空間的來回復制,因此“zero-copy”這個詞實際上是站在內核的角度來說的,並不是完全不會發生任何拷貝。

在Java NIO包中提供了零拷貝機制對應的API,即FileChannel.transferTo()方法。不過FileChannel類是抽象類,transferTo()也是一個抽象方法,因此還要依賴於具體實現。FileChannel的實現類並不在JDK本身,而位於sun.nio.ch.FileChannelImpl類中,零拷貝的具體實現自然也都是native方法,看官如有興趣可以自行查找源碼來看,這里不再贅述。

將傳統方式的發送端邏輯改寫一下,大致如下:

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()方法的話,整個過程就可以用下面的簡圖表示了。

 

 零拷貝方法的流程框圖

零拷貝方法的上下文切換過程 

可見,不僅拷貝的次數變成了3次,上下文切換的次數也減少到了2次,效率比傳統方式高了很多。但是它還並非完美狀態,下面看一看讓它變得更優化的方法。

對Scatter/Gather的支持

在“基礎”零拷貝方式的時序圖中,有一個“write data to target socket buffer”的回環,在框圖中也有一個從“Read buffer”到“Socket buffer”的大箭頭。這是因為在一般的Block DMA方式中,源物理地址和目標物理地址都得是連續的,所以一次只能傳輸物理上連續的一塊數據,每傳輸一個塊發起一次中斷,直到傳輸完成,所以必須要在兩個緩沖區之間拷貝數據。

而Scatter/Gather DMA方式則不同,會預先維護一個物理上不連續的塊描述符的鏈表,描述符中包含有數據的起始地址和長度。傳輸時只需要遍歷鏈表,按序傳輸數據,全部完成后發起一次中斷即可,效率比Block DMA要高。也就是說,硬件可以通過Scatter/Gather DMA直接從內核緩沖區中取得全部數據,不需要再從內核緩沖區向Socket緩沖區拷貝數據。因此上面的時序圖還可以進一步簡化。

 

支持Scatter/Gather的零拷貝時序圖

這就是完全體的零拷貝機制了,是不是清爽了很多?相對地,它的流程框圖如下。

 

 支持Scatter/Gather的零拷貝流程框圖

對內存映射(mmap)的支持

上面講的機制看起來一切都很好,但它還是有個缺點:如果我想在傳輸時修改數據本身,就無能為力了。不過,很多操作系統也提供了內存映射機制,對應的系統調用為mmap()/munmap()。通過它可以將文件數據映射到內核地址空間,直接進行操作,操作完之后再刷回去。其對應的簡要時序圖如下。

 

支持mmap的零拷貝時序圖

 當然,天下沒有免費的午餐,上面的過程仍然會發生4次上下文切換。另外,它需要在快表(TLB)中始終維護着所有數據對應的地址空間,直到刷寫完成,因此處理缺頁的overhead也會更大。在使用該機制時,需要權衡效率。

NIO框架中提供了MappedByteBuffer用來支持mmap。它與常用的DirectByteBuffer一樣,都是在堆外內存分配空間。相對地,HeapByteBuffer在堆內內存分配空間。

零拷貝機制的應用

零拷貝在很多框架中得到了廣泛應用,一般都以Netty為例來分析。但作為大數據工程師,我就以Kafka與Spark為例來簡單說兩句吧。

在Kafka中的應用

在使用Kafka時,我們經常會想,為什么Kafka能夠達到如此巨大的數據吞吐量?這與Kafka的很多設計哲學是分不開的,比如分區並行、ISR機制、順序寫入、頁緩存、高效序列化等等,零拷貝當然也是其中之一。由於Kafka的消息存儲涉及到海量數據讀寫,所以利用零拷貝能夠顯著地降低延遲,提高效率。

在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中的數據傳輸到TransportLayer,也就是SocketChannel。在實現類PlaintextTransportLayer的對應方法中,就是直接調用了FileChannel.transferTo()方法。

    @Override
    public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
        return fileChannel.transferTo(position, count, socketChannel);
    }

 對該方法的調用則位於FileRecords.writeTo()方法中,用於將Kafka收到的緩存數據零拷貝地寫入目的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雖然是一個高效的積極使用內存的計算框架,但在需要使用磁盤時也會適當地溢寫。零拷貝機制在Spark Core中主要就被用來優化Shuffle過程中的溢寫邏輯。由於Shuffle過程涉及大量的數據交換,因此效率當然是越高越好。

在啟用Bypass機制的Sort Shuffle(參見https://www.jianshu.com/p/aba0f35fa2a0)以及Tungsten Sort Shuffle的shuffle write階段(參見https://www.jianshu.com/p/1d714f0c5e07),都使用了零拷貝來快速合並溢寫文件的分片,有一個專門的配置項spark.file.transferTo來控制是否啟用零拷貝(默認當然是true)。以BypassMergeSortShuffleWriter為例,它最終是調用了通用工具類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)
  }

可見,該方法用於將數據從一個FileChannel零拷貝到另一個FileChannel。通過控制起始位置和長度參數,就可以精確地將所有溢寫文件拼合在一起了。



這是一個多月之前留下的爛尾文,今天終於想起來了,就補全並且發出來吧。

零拷貝(Zero-copy)是一種高效的數據傳輸機制,在追求低延遲的傳輸場景中十分常用。本文先通過傳統方案引出零拷貝機制,然后分析其細節,最后介紹它的部分應用。

文中涉及到的操作系統理論知識都可以參考英文維基或者相關書籍,如Abraham Silberschatz著《操作系統概念》、Andrew S. Tanenbaum著《現代操作系統》等。



作者:LittleMagic
鏈接:https://www.jianshu.com/p/193cae9cbf07


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM