深入理解NIO零拷貝

什麼是零拷貝

張老師的學生無處不在,哈哈哈!@張龍

  傳統的IO在進行文件傳輸的時候,涉及多次數據從內核緩衝區到用戶緩存區的雙向拷貝及用戶態和內核態的轉換,因此效率低下;NIO的零拷貝實現了從內核緩衝區到用戶緩衝區的雙向0拷貝,並取消了內核緩衝區從kernel buffer到socket buffer的拷貝,同時也減少了多次用戶態和內核態之間的轉換,因此在涉及Socket網絡傳輸的時候效率甚高。

傳統的IO實現

  我們以一個客戶端發送一個文件到服務端的例子來說明。先上兩段代碼:

Server端
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @author pulil
 * @version V1.0
 * @Title
 * @Description
 * @date 2019-07-13 下午3:03
 */
public class OldIOServer {

    public static void main(String[] args) throws Exception {
        //創建一個ServerSocket並監聽8888端口
        ServerSocket serverSocket = new ServerSocket(8888);

        while(true) {
            //阻塞方法,獲得連接的socket對象
            Socket socket = serverSocket.accept();
            //通過裝飾器模式獲取DataInputStream
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

            int totalCount = 0;
            //讀取數據
            try {
                byte[] buffer = new byte[4096];

                int read = 0;
                while((read = dataInputStream.read(buffer)) > 0) {
                    totalCount += read;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            System.out.println("服務端接受字節數:" + totalCount);
        }
    }
}
Client端
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * @author pulil
 * @version V1.0
 * @Title
 * @Description
 * @date 2019-07-13 下午4:21
 */
public class OldIOClient {

    public static void main(String[] args) throws  Exception {

        Socket socket = new Socket("localhost",8888);

        String fileName = "本地磁盤路徑/somefile.zip";//大小953.4 MB
        InputStream inputStream = new FileInputStream(fileName);

        DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());

        byte[] buffer = new byte[4096];
        int readCount = 0;
        long total = 0;

        long startTime = System.nanoTime();

        while((readCount = inputStream.read(buffer)) >= 0) {
            total += readCount;
            dataOutputStream.write(buffer,0,readCount);
        }

        System.out.println("發送總字節數:" + total + ", 耗時 :" + (System.nanoTime() - startTime)/1000000);

        dataOutputStream.close();
        socket.close();
        inputStream.close();
    }
}
運行結果
//客戶端運行結果
發送總字節數:953398669, 耗時 :3730

//服務器端運行結果
服務端接受字節數:953398669

  本例子發送了一個953.4 MB的文件,共耗時3730ms。

傳統IO執行過程

在這裏插入圖片描述
傳統IO之所以慢,客戶端在進行網絡傳輸的時候需要經歷上圖步驟:

  1. JVM發出read系統調用
  2. 操作系統切換到內核態(限linux和unix,第一次切換),操作系統通過DMA(直接內存訪問)將數據讀取到內核緩衝區(第一次讀取拷貝)
  3. 操作系統將數據拷貝到用戶緩衝區(第二次拷貝),read系統調用返回;操作系統由內核態切換回用戶態(第二次切換)
  4. JVM循環處理代碼,併發送write系統調用
  5. 操作系統再次由用戶態切換到內核態(第三次切換),並將用戶緩衝區的數據拷貝到內核緩衝區中(第三次拷貝)
  6. 操作系統將內核緩衝區中的內容拷貝到socket buffer(第四次拷貝)中
  7. 協議引擎(protocol engine)從socket buffer中獲取數據並將數據發送,write系統調用返回,內核態切換回用戶態(第四次切換)
傳統IO的流程總結

由此可見,傳統的IO從硬盤上讀取一個文件發送到遠程服務器,需要經歷

  • 一次從硬件的讀取拷貝
  • 三次數據的拷貝(兩次是內核空間和用戶空間之間的拷貝,一次是內核空間之內的拷貝)
  • 四次的上下文切換。

這就是造成傳統IO速度慢的原因。用戶空間唯一起到的作用就是中轉的作用,其他事情並沒有做。

NIO零拷貝的做法(Linux 2.4之前)

一種優化-sendfile()系統調用的做法
在這裏插入圖片描述

步驟
  1. JVM發送sendfile系統調用
  2. 用戶態切換到內核態(第一次切換),通過DMA從硬件上加載文件到內核緩衝區(第一次讀取拷貝)
  3. 將數據從內核緩衝區拷貝至socket buffer中(第二次拷貝),注意是完整的數據拷貝
  4. 協議引擎從socket buffer中讀取數據併發送,sendfile系統調用返回,內核態切換爲用戶態(第二次切換)
sendfile零拷貝總結,該模式下涉及了:
  • 兩次上下文的切換
  • 一次讀取拷貝
  • 一次內核態下的拷貝

sendfile零拷貝的優化(linux2.4開始

在這裏插入圖片描述
可以看到,從linux2.4開始之後的sendfile做了一個很大的優化,就是使用scatter/gather,減少了一次內核狀態下的拷貝,具體如下:

首先介紹一下Scatter,官方文檔如下

  1. Scattering:在讀取的時候可以不只是讀取到一個buffer中,而是讀取到多個buffer中
/**
* A channel that can read bytes into a sequence of buffers.
*
* <p> A <i>scattering</i> read operation reads, in a single invocation, a
* sequence of bytes into one or more of a given sequence of buffers.
* Scattering reads are often useful when implementing network protocols or
* file formats that, for example, group data into segments consisting of one
* or more fixed-length headers followed by a variable-length body.  Similar
* <i>gathering</i> write operations are defined in the {@link
* GatheringByteChannel} interface.  </p>
*
*
* @author Mark Reinhold
* @author JSR-51 Expert Group
* @since 1.4
*/
public interface ScatteringByteChannel extends ReadableByteChannel
  1. Gathering:在寫的時候,可以將多個buffer中的內容合併寫出去
/**
* A channel that can write bytes from a sequence of buffers.
*
* <p> A <i>gathering</i> write operation writes, in a single invocation, a
* sequence of bytes from one or more of a given sequence of buffers.
* Gathering writes are often useful when implementing network protocols or
* file formats that, for example, group data into segments consisting of one
* or more fixed-length headers followed by a variable-length body.  Similar
* <i>scattering</i> read operations are defined in the {@link
* ScatteringByteChannel} interface.  </p>
*
*
* @author Mark Reinhold
* @author JSR-51 Expert Group
* @since 1.4
*/
public interface GatheringByteChannel extends WritableByteChannel
升級後的sendfile零拷貝 在這裏插入圖片描述

從linux2.4版本之後,對於底層的文件描述符做了修改,這裏面涉及一個gather調用,gather可以實現將多個buffer中將數據彙集到一起寫入網絡中。

  1. JVM發送sendfile系統調用
  2. 操作系統從用戶態切換到內核態,並通過DMA copy從hard drive中將數據拷貝到kernel buffer中,在此同時,會直接將文件描述符(並不是整個數據)寫入socket buffer中,文件描述符保存了數據在kernel buffer中的內存保存點以及長度,這樣就好像數據被直接寫入了socket channel中一樣
  3. 協議引擎(protocol engine)使用gather從kernel buffer和socket buffer合併將數據發送出去
  4. 上下文從內核態切換到用戶態

這樣來看,整個操作過程中,只涉及必要的兩次必要的上下文切換一次必要的數據從硬件的讀取拷貝,內核緩衝區的kernel buffer拷貝到socket buffer中僅僅拷貝了兩個指針,因此真正完成了數據的零拷貝。性能當然大大的提高嘍。

sendfile例子如下

Client端
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;

/**
 * @author pulil
 * @version V1.0
 * @Title
 * @Description
 * @date 2019-07-13 下午4:26
 */
public class NewIOClient {

    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost",8888));
        socketChannel.configureBlocking(true);

        String fileName = "本地磁盤路徑/somefile.zip";//953.4 MB

        FileChannel fileChannel = new FileInputStream(fileName).getChannel();

        long startTime = System.nanoTime();

        //一行代碼實現0拷貝,將文件channel中的內容直接寫到SocketChannel中
        long transferCount = fileChannel.transferTo(0,fileChannel.size(),socketChannel);

        System.out.println("發送總字節數:" + transferCount + ", 耗時 :" + (System.nanoTime() - startTime)/1000000);

        fileChannel.close();
        socketChannel.close();
    }
}

運行結果

//客戶端輸出
發送總字節數:953398669, 耗時 :786

//服務器端輸出
服務端接受字節數:953398669

可以看出,發送同樣的數據,耗時僅786ms,比傳統的IO執行時間3730ms足足快了有4倍還多,推薦大家使用NIO進行網絡傳輸!!!


假裝自己是一個華麗的分割線

最後給大家一個Scatter和Gather的小栗子

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Arrays;

/**
 * @author pulil
 * @version V1.0
 * @Title
 * @Description
 * @date 2019-07-08 下午5:38
 */
public class NioTest11 {
    /**
     * Buffer的Scattering和Gathering
     * Scattering:在讀取的時候可以不只是讀取到一個buffer中,而是讀取到一個buffer數組中
     * Gathering:在寫的時候,可以將一個buffer數組中的內容寫出去
     *
     * 應用:比如在網絡傳輸中,頭信息是10個字節,後面的是消息體
     * 拿就可以天然的將頭信息和消息體分到兩個buffer中,而不是讀到一個buffer中再進行解析
     */

    public static void main(String[] args) throws Exception {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        InetSocketAddress address = new InetSocketAddress(8888);
        serverSocketChannel.socket().bind(address);

        int messageLength = 2 + 3 + 4;

        //構造buffer數組並初始化
        ByteBuffer[] buffers = new ByteBuffer[3];

        buffers[0] = ByteBuffer.allocate(2);
        buffers[1] = ByteBuffer.allocate(3);
        buffers[2] = ByteBuffer.allocate(4);

        SocketChannel socketChannel = serverSocketChannel.accept();

        while(true) {
            int bytesRead = 0;
            while(bytesRead < messageLength) {
                //將數據讀取到buffers中
                long r = socketChannel.read(buffers);
                bytesRead += r;

                System.out.println("bytesRead:" + bytesRead);

                Arrays.asList(buffers).stream().map(buffer -> "position:" + buffer.position() + ", limit: " + buffer.limit())
                        .forEach(System.out::println);
            }

            //反轉
            Arrays.asList(buffers).forEach(buffer-> {
                buffer.flip();
            });

            //寫入
            long bytesWritten = 0;
            while(bytesWritten < messageLength) {
                long r = socketChannel.write(buffers);
                bytesWritten += r;
            }

            Arrays.asList(buffers).forEach(buffer->{
                buffer.clear();
            });

            System.out.println("bytesRead: " + bytesRead + ", butesWritten: " + bytesWritten + ", messageLength: " + messageLength);
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章