【Netty技術專題】「原理分析系列」Netty強大特性之ByteBuf零拷貝技術原理分析

浩宇天尚發表於2021-12-29

零拷貝Zero-Copy

我們先來看下它的定義:

"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.

所謂的Zero-copy,就是在運算元據時, 不需要將資料buffer從一個記憶體區域拷貝到另一個記憶體區域,少了一次記憶體的拷貝, 減少了cpu的執行,節省了記憶體頻寬。

作業系統層面Zero-Copy

在OS層面上的Zero-copy通常指避免在使用者態(User-space) 與 核心態(Kernel-space) 之間來回拷貝資料。

  • 例如 Linux 提供的 mmap 系統呼叫, 它可以將一段使用者空間記憶體對映到核心空間, 當對映成功後, 使用者對這段記憶體區域的修改可以直接反映到核心空間;

  • 核心空間對這段區域的修改也直接反映使用者空間。正因為有這樣的對映關係, 我們就不需要在 使用者態(User-space) 與 核心態(Kernel-space) 之間拷貝資料, 提高了資料傳輸的效率。

Netty中的 Zero-copy 與上面我們所提到到 OS 層面上的 Zero-copy 不太一樣, Netty的 Zero-copy 完全是在使用者態(Java 層面)的,它的 Zero-copy 的更多的是偏向於 優化資料操作 這樣的概念.

Netty的零拷貝Zero-copy

  • Netty 提供了CompositeByteBuf 類, 它可以將多個 ByteBuf 合併為一個邏輯上的 ByteBuf, 避免了各個 ByteBuf 之間的拷貝。

  • 通過 wrap 操作, 我們可以將 byte[] 陣列、ByteBuf、ByteBuffer等包裝成一個 Netty ByteBuf 物件, 進而避免了拷貝操作。

  • ByteBuf支援slice操作, 因此可以將 ByteBuf 分解為多個共享同一個儲存區域的 ByteBuf, 避免了記憶體的拷貝。

  • 通過 FileRegion 包裝的FileChannel.tranferTo 實現檔案傳輸, 可以直接將檔案緩衝區的資料傳送到目標 Channel, 避免了傳統通過迴圈 write 方式導致的記憶體拷貝問題。

通過 CompositeByteBuf 實現零拷貝

假設我們有一份協議資料, 它由頭部和訊息體組成, 而頭部和訊息體是分別存放在兩個 ByteBuf 中的, 即:

ByteBuf header = ...
ByteBuf body = ...

在程式碼處理中, 通常希望將 header 和 body 合併為一個 ByteBuf, 方便處理, 那麼通常的做法是:

ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
allBuf.writeBytes(header);
allBuf.writeBytes(body);

可以看到, 我們將 header 和 body 都拷貝到了新的 allBuf 中了, 這無形中增加了兩次額外的資料拷貝操作了。那麼有沒有更加高效優雅的方式實現相同的目的呢? 我們來看一下 CompositeByteBuf 是如何實現這樣的需求的吧.

ByteBuf header = ...
ByteBuf body = ...
CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();
compositeByteBuf.addComponents(true, header, body);

上面程式碼中, 我們定義了一個 CompositeByteBuf 物件, 然後呼叫

public CompositeByteBuf addComponents(boolean increaseWriterIndex, ByteBuf... buffers) {
...
}

方法將 header 與 body 合併為一個邏輯上的 ByteBuf, 即:

不過需要注意的是, 雖然看起來CompositeByteBuf是由兩個 ByteBuf 組合而成的, 不過在 CompositeByteBuf 內部, 這兩個 ByteBuf 都是單獨存在的, CompositeByteBuf 只是邏輯上是一個整體.

上面CompositeByteBuf 程式碼還以一個地方值得注意的是, 我們呼叫addComponents(boolean increaseWriterIndex, ByteBuf... buffers) 來新增兩個 ByteBuf, 其中第一個引數是 true, 表示當新增新的 ByteBuf 時, 自動遞增 CompositeByteBuf 的 writeIndex。

除了上面直接使用 CompositeByteBuf 類外, 我們還可以使用 Unpooled.wrappedBuffer 方法, 它底層封裝了 CompositeByteBuf 操作, 因此使用起來更加方便:

ByteBuf header = ...
ByteBuf body = ...
ByteBuf allByteBuf = Unpooled.wrappedBuffer(header, body);

通過wrap 操作實現零拷貝

我們有一個 byte 陣列, 我們希望將它轉換為一個 ByteBuf 物件, 以便於後續的操作, 那麼傳統的做法是將此 byte 陣列拷貝到 ByteBuf 中, 即:

byte[] bytes = ...
ByteBuf byteBuf = Unpooled.buffer();
byteBuf.writeBytes(bytes);

顯然這樣的方式也是有一個額外的拷貝操作的,我們可以使用 Unpooled 的相關方法, 包裝這個 byte 陣列, 生成一個新的 ByteBuf 例項, 而不需要進行拷貝操作。上面的程式碼可以改為:

byte[] bytes = ...
ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);

通過 Unpooled.wrappedBuffer方法來將 bytes 包裝成為一個 UnpooledHeapByteBuf 物件, 而在包裝的過程中, 是不會有拷貝操作的. 即最後我們生成的生成的 ByteBuf 物件是和 bytes 陣列共用了同一個儲存空間, 對 bytes 的修改也會反映到 ByteBuf 物件中.

通過 slice 操作實現零拷貝

slice 操作和 wrap 操作剛好相反, Unpooled.wrappedBuffer 可以將多個 ByteBuf 合併為一個, 而 slice 操作可以將一個 ByteBuf 切片 為多個共享一個儲存區域的 ByteBuf 物件.
ByteBuf 提供了兩個 slice 操作方法:

public ByteBuf slice();
public ByteBuf slice(int index, int length);

不帶引數的 slice 方法等同於 buf.slice(buf.readerIndex(), buf.readableBytes()) 呼叫, 即返回 buf 中可讀部分的切片. 而 slice(int index, int length) 方法相對就比較靈活了, 我們可以設定不同的引數來獲取到 buf 的不同區域的切片.

ByteBuf byteBuf = ...
ByteBuf header = byteBuf.slice(0, 5);
ByteBuf body = byteBuf.slice(5, 10);

用 slice 方法產生 header 和 body 的過程是沒有拷貝操作的, header 和 body 物件在內部其實是共享了 byteBuf 儲存空間的不同部分而已. 即:

通過FileRegion實現零拷貝

Netty中使用FileRegion實現檔案傳輸的零拷貝, 不過在底層 FileRegion 是依賴於 Java NIO FileChannel.transfer 的零拷貝功能.

首先我們從最基礎的 Java IO 開始吧. 假設我們希望實現一個檔案拷貝的功能, 那麼使用傳統的方式, 我們有如下實現:

public static void copyFile(String srcFile, String destFile) throws Exception {
    byte[] temp = new byte[1024];
    FileInputStream in = new FileInputStream(srcFile);
    FileOutputStream out = new FileOutputStream(destFile);
    int length;
    while ((length = in.read(temp)) != -1) {
        out.write(temp, 0, length);
    }
    in.close();
    out.close();
}

上面是一個典型的讀寫二進位制檔案的程式碼實現了. 不用我說, 大家肯定都知道, 上面的程式碼中不斷中原始檔中讀取定長資料到 temp 陣列中, 然後再將 temp 中的內容寫入目的檔案, 這樣的拷貝操作對於小檔案倒是沒有太大的影響, 但是如果我們需要拷貝大檔案時, 頻繁的記憶體拷貝操作就消耗大量的系統資源了,下面我們來看一下使用 Java NIO 的 FileChannel 是如何實現零拷貝的:

public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {
    RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");
    FileChannel srcFileChannel = srcFile.getChannel();
    RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");
    FileChannel destFileChannel = destFile.getChannel();
    long position = 0;
    long count = srcFileChannel.size();
    srcFileChannel.transferTo(position, count, destFileChannel);
}

可以看到, 使用了 FileChannel 後, 我們就可以直接將原始檔的內容直接拷貝(transferTo) 到目的檔案中, 而不需要額外借助一個臨時 buffer, 避免了不必要的記憶體操作,我們來看一下在 Netty 中是怎麼使用 FileRegion 來實現零拷貝傳輸一個檔案的:

@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    RandomAccessFile raf = null;
    long length = -1;
    try {
        // 1. 通過 RandomAccessFile 開啟一個檔案.
        raf = new RandomAccessFile(msg, "r");
        length = raf.length();
    } catch (Exception e) {
        ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
        return;
    } finally {
        if (length < 0 && raf != null) {
            raf.close();
        }
    }
    ctx.write("OK: " + raf.length() + '\n');
    if (ctx.pipeline().get(SslHandler.class) == null) {
        // SSL not enabled - can use zero-copy file transfer.
        // 2. 呼叫 raf.getChannel() 獲取一個 FileChannel.
        // 3. 將 FileChannel 封裝成一個 DefaultFileRegion
        ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
    } else {
        // SSL enabled - cannot use zero-copy file transfer.
        ctx.write(new ChunkedFile(raf));
    }
    ctx.writeAndFlush("\n");
}

可以看到, 第一步是通過 RandomAccessFile 開啟檔案, 然後Netty使用DefaultFileRegion 來封裝一個 FileChannel 即:

new DefaultFileRegion(raf.getChannel(), 0, length)

java零拷貝

零拷貝的“零”是指使用者態和核心態間copy資料的次數為零。

傳統的資料copy(檔案到檔案、client到server等)涉及到四次使用者態核心態切換、四次copy,四次copy中,兩次在使用者態和核心態間copy需要CPU參與、兩次在核心態與IO裝置間copy為DMA方式不需要CPU參與,零拷貝避免了使用者態和核心態間的copy、減少了兩次使用者態核心態間的切換。

  • java的zero copy多在網路應用程式中使用。Java的libaries在linux和unix中支援zero copy,關鍵的api是java.nio.channel.FileChannel的transferTo(),transferFrom()方法。

  • 可以用這兩個方法來把bytes直接從呼叫它的channel傳輸到另一個writable byte channel,中間不會使data經過應用程式,以便提高資料轉移的效率。

Web環境的使用零拷貝技術

許多web應用都會向使用者提供大量的靜態內容,這意味著有很多data從硬碟讀出之後,會原封不動的通過socket傳輸給使用者。這種操作看起來可能不會怎麼消耗CPU,但是實際上它是低效。

原始拷貝技術

kernal把資料從disk讀出來,然後把它傳輸給user級的application,然後application再次把同樣的內容再傳回給處於kernal級的socket,application實際上只是作為一種低效的中間介質,用來把disk file的data傳給socket。

零拷貝技術

data每次通過user-kernel boundary,都會被copy,這會消耗CPU,並且佔用RAM的頻寬。因此你可以用一種叫做Zero-Copy的技術來去掉這些無謂的 copy。

  • 應用程式用zero copy來請求kernel直接把disk的data傳輸給socket,而不是通過應用程式傳輸。Zero copy提高了應用程式的效能,並且減少了kernel和user模式的上下文切換。
  • 使用kernel buffer做中介(而不是直接把data傳到user buffer中)看起來比較低效(多了一次copy)。然而實際上kernel buffer是用來提高效能的。
零拷貝的弊端問題

在進行讀操作的時候,kernel buffer起到了預讀cache的作用,當寫請求的data size比kernel buffer的size小的時候,這能夠顯著的提升效能。在進行寫操作時,kernel buffer的存在可以使得寫請求完全非同步。

悲劇的是,當請求的data size遠大於kernel buffer size的時候,這個方法本身變成了效能的瓶頸。因為data需要在disk,kernel buffer,user buffer之間拷貝很多次(每次寫滿整個buffer)。

而Zero copy正是通過消除這些多餘的data copy來提升效能。

傳統方式及涉及到的上下文切換

通過網路把一個檔案傳輸給另一個程式,在OS的內部,這個copy操作要經歷四次user mode和kernel mode之間的上下文切換,甚至連資料都被拷貝了四次,
具體步驟如下:

  • read() 呼叫導致一次從user mode到kernel mode的上下文切換。在內部呼叫了sys_read() 來從檔案中讀取data。第一次copy由DMA (direct memory access)完成,將檔案內容從disk讀出,儲存在kernel的buffer中。

  • 然後請求的資料被copy到user buffer中,此時read()成功返回。呼叫的返回觸發了第二次context switch: 從kernel到user。至此,資料儲存在user的buffer中。

  • send() Socket call 帶來了第三次context switch,這次是從user mode到kernel mode。同時,也發生了第三次copy:把data放到了kernel adress space中。當然,這次的kernel buffer和第一步的buffer是不同的buffer。

  • 最終 send() system call 返回了,同時也造成了第四次context switch。同時第四次copy發生,DMA egine將data從kernel buffer拷貝到protocol engine中。第四次copy是獨立而且非同步的。

zero copy方式及涉及的上下文轉換

在linux 2.4及以上版本的核心中(如linux 6或centos 6以上的版本),開發者修改了socket buffer descriptor,使網路卡支援 gather operation,通過kernel進一步減少資料的拷貝操作。這個方法不僅減少了context switch,還消除了和CPU有關的資料拷貝。user層面的使用方法沒有變,但是內部原理卻發生了變化:

transferTo()方法使得檔案內容被copy到了kernel buffer,這一動作由DMA engine完成。 沒有data被copy到socket buffer。取而代之的是socket buffer被追加了一些descriptor的資訊,包括data的位置和長度。然後DMA engine直接把data從kernel buffer傳輸到protocol engine,這樣就消除了唯一的一次需要佔用CPU的拷貝操作。

Java NIO 零拷貝示例

NIO中的FileChannel擁有transferTo和transferFrom兩個方法,可直接把FileChannel中的資料拷貝到另外一個Channel,或直接把另外一個Channel中的資料拷貝到FileChannel。該介面常被用於高效的網路/檔案的資料傳輸和大檔案拷貝。

在作業系統支援的情況下,通過該方法傳輸資料並不需要將源資料從核心態拷貝到使用者態,再從使用者態拷貝到目標通道的核心態,同時也避免了兩次使用者態和核心態間的上下文切換,也即使用了“零拷貝”,所以其效能一般高於Java IO中提供的方法。

通過網路把一個檔案從client傳到server:
/**
 * disk-nic零拷貝
 */
class ZerocopyServer {
    ServerSocketChannel listener = null;
    protected void mySetup() {
        InetSocketAddress listenAddr = new InetSocketAddress(9026);
        try {
            listener = ServerSocketChannel.open();
            ServerSocket ss = listener.socket();
            ss.setReuseAddress(true);
            ss.bind(listenAddr);
            System.out.println("監聽的埠:" + listenAddr.toString());
        } catch (IOException e) {
            System.out.println("埠繫結失敗 : " + listenAddr.toString() + " 埠可能已經被使用,出錯原因: " + e.getMessage());
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {
        ZerocopyServer dns = new ZerocopyServer();
        dns.mySetup();
        dns.readData();
    }

    private void readData() {
        ByteBuffer dst = ByteBuffer.allocate(4096);
        try {
            while (true) {
                SocketChannel conn = listener.accept();
                System.out.println("建立的連線: " + conn);
                conn.configureBlocking(true);
                int nread = 0;
                while (nread != -1) {
                    try {
                        nread = conn.read(dst);
                    } catch (IOException e) {
                        e.printStackTrace();
                        nread = -1;
                    }
                    dst.rewind();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
class ZerocopyClient {
    public static void main(String[] args) throws IOException {
        ZerocopyClient sfc = new ZerocopyClient();
        sfc.testSendfile();
    }

    public void testSendfile() throws IOException {
        String host = "localhost";
        int port = 9026;
        SocketAddress sad = new InetSocketAddress(host, port);
        SocketChannel sc = SocketChannel.open();
        sc.connect(sad);
        sc.configureBlocking(true);
        String fname = "src/main/java/zerocopy/test.data";
        FileChannel fc = new FileInputStream(fname).getChannel();
        long start = System.nanoTime();
        long nsent = 0, curnset = 0;
        curnset = fc.transferTo(0, fc.size(), sc);
        System.out.println("傳送的總位元組數:" + curnset + " 耗時(ns):" + (System.nanoTime() - start));
        try {
            sc.close();
            fc.close();
        } catch (IOException e) {
            System.out.println(e);
        }
    }
}
檔案到檔案的零拷貝
/**
 * disk-disk零拷貝
 */
class ZerocopyFile {
    @SuppressWarnings("resource")
    public static void transferToDemo(String from, String to) throws IOException {
        FileChannel fromChannel = new RandomAccessFile(from, "rw").getChannel();
        FileChannel toChannel = new RandomAccessFile(to, "rw").getChannel();
        long position = 0;
        long count = fromChannel.size();
        fromChannel.transferTo(position, count, toChannel);
        fromChannel.close();
        toChannel.close();
    }
    @SuppressWarnings("resource")
    public static void transferFromDemo(String from, String to) throws IOException {
        FileChannel fromChannel = new FileInputStream(from).getChannel();
        FileChannel toChannel = new FileOutputStream(to).getChannel();
        long position = 0;
        long count = fromChannel.size();
        toChannel.transferFrom(fromChannel, position, count);
        fromChannel.close();
        toChannel.close();
    }
    public static void main(String[] args) throws IOException {
        String from = "src/main/java/zerocopy/1.data";
        String to = "src/main/java/zerocopy/2.data";
        // transferToDemo(from,to);
        transferFromDemo(from, to);
    }
}

相關文章