深入理解Linux, NIO和Netty中的零拷貝(Zero-Copy)

背景

零拷貝(Zero Copy)是一個耳熟能詳的術語,衆多高性能的網絡框架如NettyKafkaRocket 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.

零拷貝防止了數據在內存中的複製,可以提升網絡傳輸的性能。由此產生兩個疑問:

  1. 爲什麼會出現數據的複製?
  2. 零拷貝真的是0次數據複製嗎?

Linux系統中的零拷貝

繼續往下之前,需要了解幾個OS的概念:

  1. 內核空間:計算機內存被分爲用戶空間內核空間內核空間運行OS內核代碼,並可以訪問所有內存,機器指令和硬件資源,具有最高的權限。
  2. 用戶空間:即內核以外的所有空間,用於正常用戶進程運行。用戶空間的進程無權訪問內核空間,只能通過內核暴露的接口----系統調用(system calls)去訪問內核的一小部分。如果用戶進程請求執行系統調用,需要給內核發送系統中斷(software interrupt),內核會分派相應的中斷處理器處理請求。
  3. DMA:Direct Memory Access(DMA)是來應對CPU硬盤之間速度量級不匹配的問題的,它允許某些硬件子系統訪問獨立於CPU的主內存。如果沒有DMACPU進行IO操作的整個過程都是阻塞的,無法執行其他工作,這會使計算機陷入假死狀態。如果有DMA介入,IO過程變成這樣:CPU啓動DMA傳輸,期間它可以執行其他操作;DMA控制器(DMAC)在傳輸完成後,會給CPU發送中斷信號,這時CPU便可以處理傳輸好的數據。

傳統的網絡傳輸

網絡IO的一個常見場景是,將文件從硬盤讀取出來,並通過網卡發送至網絡。以下是簡單的僞代碼:

// 從硬盤讀取數據
File.read(fileDesc, buf, len);
// 發送數據到網絡
Socket.write(socket, buf, len);

代碼層面,這是一個非常簡單的操作,但是深入到系統層面,我們來看看背後發生了什麼:
traditional flow

由於用戶空間無法直接訪問文件系統,所以,這個場景涉及到了三個模塊的交互:用戶空間內核空間硬件

  1. 用戶發起read()系統調用(syscall),請求硬盤數據。此時,會發生一次上下文切換(context switch)。
  2. DMA從硬盤讀取文件,這時,產生一次複製:硬盤–>DMA緩衝區
  3. DMA將數據複製到用戶空間read()調用返回。此時,發生一次上下文切換以及一次數據複製:DMA緩衝區–>用戶空間
  4. 用戶發起write()系統調用,請求發送數據。此時發生一次上下文切換和一次數據複製:用戶空間–>DMA緩衝區
  5. DMA將數據複製到網卡,以備網絡發送。此時發生第四次數據複製:DMA緩衝區–>套接字緩衝區
  6. write()調用返回,再次發生上下文切換

數據流如下:
traditional data flow

可以發現,其中共涉及到了4次上下文切換以及4次數據複製。對於單純的網絡文件發送,有很多不必要的開銷。

sendfile傳輸

對於上述場景,我們發現從DMA緩衝用戶空間,和從用戶空間套接字緩衝的兩次CPU複製是完全沒必要的,零拷貝由此而生。針對這種情況,Linux內核提供了sendfile系統調用。如果用sendfile()執行上述請求,系統流程可以簡化如下:
sendfile flow

sendfile()系統調用,可以實現數據在DMA內部的複製,而不需要將數據copy到用戶空間。由此,上下文切換次數減少爲了2次,數據複製次數減少爲了3次。這已經實現了用戶空間零拷貝

這裏有一個問題:爲什麼DMA內部會出現一次複製(此次複製需要CPU參與)?這是因爲,早期的網卡,要求被髮送的數據在物理空間上是連續的,所以,需要有Socket Buffer。但是如果網卡本身支持收集操作(scatter-gather),即可以從不連續的內存地址聚集併發送數據,那麼還可以進一步優化。

網卡支持scatter-gathersendfile傳輸

Linux內核版本2.4之後對此做了優化,如果計算機網卡支持收集操作,sendfile()操作可以省去到Socket Buffer的數據複製,取而代之的是,直接將數據位置和長度的描述符(descriptors),傳遞給Socket Buffer
sendfile with gather flow

藉由網卡的支持,上下文切換的次數爲2次,數據複製的次數也降低爲2次。而這兩次的數據複製是必須的,也就是說,數據在內存中的複製已經完全避免。
對於從硬盤向網絡發送文件的場景,如果網卡支持收集操作,那麼sendfile()系統調用,真正意義上的做到了零拷貝

內存映射(mmap)

對於“網絡發送文件”的情況,用sendfile()系統調用可以極大地提高性能(據測試吞吐量可達傳統方式的三倍)。但有一點不足的是,它只支持“讀取->發送”這一“連貫操作”,所以,sendfile()一般用於處理一些靜態網絡資源,如果要對數據進行額外的操作,它無能爲力。

內存映射(Memory mapping–mmap)對此提供瞭解決方案。mmap是一種內存映射文件的方法,它可以將一個文件映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中的虛擬地址的對應。如此一來,用戶進程可以採用指針讀寫操作這一段內存,而內核空間對這段區域的修改也直接反映到用戶空間。簡而言之,mmap實現了用戶空間內核空間數據的共享。可以猜到,如果使用mmap系統調用,上文中所述場景的步驟如下:
mmap flow

用戶發起mmap()系統調用,DMA直接將數據複製到用戶空間內核空間的共享虛擬內存,之後,用戶便可以正常操作數據。期間進行了2次上下文切換,1次數據複製。接下來往網卡發送數據的流程,與前面一樣採用write()系統調用。

數據流如下:
mmap data flow

可以看到,相比於傳統的方式,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;
}

最終調用的是一個nativemap0()方法。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呢?來看看FileChanneltransferTo()方法,簽名如下:

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 JavaMost efficient way to copy a file in Linux

Netty中的零拷貝

分析完了Linux內核和JVM層面的零拷貝,再來看看Netty中的零拷貝又是怎麼回事。

類似的,由於Netty是構建在NIO之上的一個高性能網絡IO框架,它也支持系統層面的零拷貝。舉一個簡單的例子,DefaultFileRegion類可以進行高效的網絡文件傳輸,因爲它封裝了NIOFileChanneltransferTo()方法:

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

compositeByteBuf

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

相比於JDKNetty的實現更合理,省去了不必要的內存複製,可以稱得上是JVM層面的零拷貝。除此之外,整個ByteBufAPI都貫穿了零拷貝的設計理念:儘量避免Buffer複製帶來的開銷。比如關於派生緩衝區(Derived buffers)的操作,duplicate()(複製),slice()(切分),order()(排序)等,雖然都會返回一個新的ByteBuf實例,但它們只是具有自己獨立的讀索引、寫索引和標記索引而已,內部存儲(Buffer數據)是共享的,也就是過程中並沒有複製操作。由此帶來的一個負面影響是,使用這些操作的時候需要注意:修改原對象會影響派生對象,修改派生對象也會影響原對象。

總結

  • 由於Linux系統中內核空間用戶空間的區別,數據的讀取和發送需要有內存中的複製。mmap系統調採用內存映射的方式,讓內核空間用戶控件共享同一塊內存,省去了從內核空間用戶空間複製的開銷。sendfile系統調用可以將文件直接從硬盤經由DMA傳輸到套接字緩衝區,而無需經過用戶空間。如果網卡支持收集操作(scatter-gather),那麼可以做到真正意義上的零拷貝
  • NIOFileChannelmap()transferTo()方法封裝了底層的mmapsendfile系統調用,從而在Java語言上提供了系統層面零拷貝的支持。
  • Netty通過封裝,也可以支持系統級別的零拷貝。此外,Netty中的零拷貝有另一層應用層面的含義:設計良好的ByteBuf API,防止了JVM內部不必要的Buffer複製。

參考

Efficient data transfer through zero copy
Netty in Action

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章