IO那些事

大~熊發表於2020-10-09

IO(Input\Output): 即輸入輸出,通常指資料在儲存器(內部和外部)或其他周邊裝置之間的輸入和輸出,是資訊處理系統(例如計算機)與外部世界(可能是人類或另一資訊處理系統)之間的通訊。說的簡單點就是與外部裝置(比如磁碟)傳輸資料


IO大致可以分為磁碟IO網路IO記憶體IO。通常所說的IO指的是前兩者。本文將簡單介紹Linux的五大IO模型java中的IO模型,並對java的NIO做一個基本介紹。

IO基本流程

外圍裝置的直接讀寫涉及到中斷,中斷時需要儲存程式資料、狀態等資訊、中斷結束後需要恢復程式資料和狀態,這種成本是比較高的。因此出現了一個叫核心緩衝區(位於核心空間)的東西,我們的程式並不是直接與IO裝置互動的,而是與這個核心緩衝區互動

IO基本過程

io流程示意圖

如圖所示,讀的時候,先將資料從磁碟或者網路卡拷貝到核心緩衝區(這一步是作業系統核心通過讀中斷完成的),然後從核心緩衝區拷貝到程式緩衝區(位於使用者空間)

寫的時候,先將資料寫到程式緩衝區,然後拷貝到核心緩衝區,然後寫到網路卡或者刷到磁碟(這一步是通過寫中斷完成的)。

讀中斷和寫中斷何時進行是核心決定的,大多數的IO操作並沒有實際的IO,而是在程式緩衝區與核心緩衝區來回拷貝資料。

一個完整的讀流程包括兩個階段:

  1. 準備資料:將資料從網路卡拷貝到核心緩衝區
  2. 拷貝資料:將資料從核心緩衝區複製到程式緩衝區

兩個重要的名詞

  • 同步與非同步:同步就是使用者空間是發起IO的一方,非同步是核心空間是發起IO的一方。也可以理解為同步就是自己要去查IO狀態,非同步是核心可以通知你
  • 阻塞與非阻塞:阻塞就是當你呼叫了一個IO讀或者寫時,需要等核心操作徹底(準備與拷貝資料)完成後才能返回,這一段時間使用者空間程式是“卡住的狀態”;非阻塞就是,呼叫了一個讀或寫時不管核心有沒有操作完成,都會立即返回。

五大IO模型

同步阻塞

同步阻塞

同步阻塞IO模型

這個模型印證了上述對同步與非同步、阻塞與非阻塞的解釋。核心準備和拷貝資料的過程中,使用者空間程式一直阻塞,所以是阻塞;使用者空間是發起io的一方,所以是同步。

同步非阻塞

同步非阻塞

同步非阻塞IO模型

同步非阻塞的特點就是在資料準備階段發起io呼叫會立即返回一個錯誤,使用者空間需要輪詢發起IO呼叫。在資料從核心緩衝區拷貝到程式緩衝區階段的呼叫仍然是會被阻塞的。這種模型需要一直輪詢IO狀態,用的比較少。

IO多路複用

IO多路複用

IO多路複用模型

在IO多路複用模型中,引入了一種新的系統呼叫查詢IO的就緒狀態。在Linux系統中,對應的系統呼叫為select/epoll系統呼叫。通過該系統呼叫,一個程式可以監視多個檔案描述符一旦某個描述符就緒(一般是核心緩衝區可讀/可寫),核心能夠將就緒的狀態返回給應用程式。隨後,應用程式根據就緒的狀態,進行相應的IO系統呼叫。

————來自《Netty、Redis、Zookeeper高併發實戰》

相比於同步阻塞模型,這種模型的優勢在於一個執行緒能處理大量的IO連線,而同步阻塞只能靠開很多執行緒來處理多個IO連線,對於大量的IO連線無能為力。

如果連線數少的話,同步阻塞並不一定比IO多路複用效能差,因為IO多路複用有兩個系統呼叫,同步阻塞只有一個。

訊號驅動

訊號驅動IO

訊號驅動IO模型

這種IO模型用的不多,java裡邊找不到對應實現。訊號驅動式模型的一個顯著特點就是用戶態程式不再等待核心態的資料準備好,直接可以去做別的事情。但是等待資料從核心緩衝區拷貝到程式緩衝區仍然是阻塞的。

非同步IO(AIO)

非同步IO

非同步IO模型

上述幾種IO模型本質上都是同步IO,就算是訊號驅動,他在資料從核心緩衝區拷貝到程式緩衝區也是阻塞的。

AIO的基本流程是:使用者執行緒通過系統呼叫,向核心註冊某個IO操作。核心在整個IO操作(包括資料準備、資料複製)完成後,通知使用者程式,使用者執行後續的業務操作.

這種IO模型是完美的IO模型,但是據說Linux支援的不太好。大名鼎鼎的netty也是使用的多路複用IO模型,還沒有使用AIO。

java中的IO

BIO

BIO就是Blocking IO, 對應上面說的同步阻塞IO模型。我們常使用的各種InputStream, 這種Reader,以及在網路程式設計用到的ServerSocket/Socket都是BIO。以一個Socket程式為例來直觀感受一下這種模型。


BIO-server

BIO-server

BIO-client

BIO-client

這兩段程式碼分別展示一個tcp服務端和客戶端,實現的功能就是客戶端從本地讀一個檔案傳送給服務端,服務端將收到的檔案寫入磁碟。

服務端的read方法的呼叫是阻塞的,這意味著這個服務端同一時刻只能處理一個連線,這顯然不合理,為了解決這個問題,我們可以考慮多執行緒機制,主執行緒只負責接受連線,收到連線就丟進其他執行緒進行處理,可以每次都開一個執行緒,也可以考慮使用執行緒池。如下的程式碼實現了這個想法。

BIO-thread

BIO的多執行緒版本

NIO

NIO,可以說是java中的新IO(New IO), 也可以叫None-Blocking IO, 他對應的是前文提到的多路複用IO模型

NIO包括三個核心成員,Buffer、Channel、Selector, 後文會做詳細介紹。

這裡簡單對比一下NIO和BIO:

NIO BIO
面向緩衝區 面向流
非阻塞 阻塞
基於通道的雙向資料流 單向資料流
有Selector的概念

上邊BIO的例子可以看到BIO是面向流的,NIO是面向緩衝區的,可以任務他的資料是一塊一塊的,通過後文的例子可以更清楚的看到這一點。

BIO都是阻塞的,也是就核心在準備資料拷貝資料階段,使用者空間發起IO的程式沒法幹別的事。NIO是可以是非阻塞的,他可以通過註冊你感興趣的事件(比如可讀)到Selector中,然後幹別的事(比如接收新的連線),當收到相應事件後再做處理。

NIO有一個通道的概念,既可以向通道里寫資料也可以從裡邊讀。但是BIO就不行,只能從輸入流裡邊讀資料,不能寫;也只能往輸出流寫資料,而不能從裡邊讀。

AIO

對應前文提到的非同步IO模型,這種模型支援不太好,JAVA AIO框架在windows下使用windows IOCP技術,在Linux下使用epoll多路複用IO技術模擬非同步IO。鼎鼎大名的netty也沒有使用AIO,所以這裡也不去深入探究了。

NIO基礎詳解

Buffer

Buffer是一個抽象類,可以認為是一個裝資料的容器,底層是陣列。他有很多子類:

例如:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

使用最多的是ByteBuffer

Buffer的基本結構如下:

Buffer的結構

Buffer的結構

這幾個屬性的含義是必須要搞清楚的,這裡簡單列舉,後文討論Buffer的基本操作會做進一步說明。

  • position: 表示當前正在讀的位置
  • limit: 表示可以讀取或者寫入的上限位置,只有小於這個值的位置才是有效的
  • capacity: 容量,不是位元組數,而是能裝幾個資料,與每個資料佔用的位元組數無關,建立時確定,不能再改變
  • mark: 一個標記位置,可以方便的回到這個位置

buffer的基本操作:

  • put(): 向緩衝區存資料
  • get(): 從緩衝區取資料
  • flip(): 切換到讀取資料的模式
  • rewind():position回到起始位置,可以重複讀
  • clear(): 清空緩衝區,但是資料仍然存在,limit,position回到最初狀態
  • hasRemaining():判斷是否還有資料可以讀
  • remaining():剩餘幾個資料可以讀
  • mark():標記當前操作的位置
  • reset(): 回到之前標記的位置

我們直接通過一個demo來說明這些操作:

測試Buffer

Buffer的基本操作

輸出如下:

建立後:
position=0,capacity=10,limit=10
寫入一個資料後:
position=2,capacity=10,limit=10
切換為讀模式後:
position=0,capacity=10,limit=2
讀取一個資料:1
position=1,capacity=10,limit=2
呼叫rewind:
position=0,capacity=10,limit=2
再次讀一個資料:
position=1,capacity=10,limit=2
呼叫Buffer.clear後
position=0,capacity=10,limit=10

通過這個測試可以看出各種操作的基本使用及其對Buffer幾個屬性的影響。

直接緩衝區與非直接緩衝區:

  • 非直接緩衝區:通過allocate()分配的緩衝區,將緩衝區建立在jvm的記憶體中
  • 直接緩衝區:通過allocateDirect()分配的緩衝區,將緩衝區建立在實體記憶體中,zero copy
  • 可以通過isDirect()判斷是否是直接緩衝區

Channel

NIO中的一個連線用一個通道表示,通道本身並不存放資料,只能與Buffer互動。

常見的通道:

  1. FileChannel: 用於讀寫檔案的通道
  2. SocketChannel:用於Socket套接字TCP連線的資料讀寫
  3. ServerSocketChannel:允許我們監聽TCP連線請求,為每個監聽到的請求,建立一個SocketChannel套接字通道
  4. DatagramChannel:用於UDP協議的資料讀寫

通道的獲取方法:

  1. 通過支援通道的類的getChannel方法

本地io:

  • FileInputStream
  • FileOutputStream
  • RandomAccessFile
fileInputStream.getChannel();

網路io:

  • Socket
  • ServerSocket
  • DatagramSocket
socket.getChannel();
  1. 使用各個通道的靜態方法open()獲取,jdk>=1.7
FileChannel fileChannel = FileChannel.open(Paths.get("a.jpg"), StandardOpenOption.READ);
  1. 使用Files的newByteChannel()獲取,jdk>=1.7
SeekableByteChannel byteChannel = Files.newByteChannel(Paths.get("a.jpg"), StandardOpenOption.WRITE);

通道的基本操作

  1. 讀:將通道里的資料讀到buffer裡,返回值表示讀取到的資料個數,返回0表示沒有了。此方法還有幾個過載
public int read(ByteBuffer dst) throws IOException
  1. 寫: 將buffer寫入通道,也有幾個過載
 public int write(ByteBuffer src) throws IOException
  1. 獲取當前通道的大小,單位byte
public abstract long size() throws IOException
  1. 將一個通道的資料傳送到另一個通道
public long transferTo(long position, long count,
                                    WritableByteChannel target)
        throws IOException;
  1. 上述反向
public long transferFrom(ReadableByteChannel src,
                                      long position, long count)
        throws IOException;
  1. 關閉通道
public final void close() throws IOException

此外還有記憶體對映檔案、鎖相關內容。限於篇幅,此處不再展開,之後可能專門寫一篇探討。

Selector

我們可以將一個通道註冊到Selector中,並且指定你感興趣的事件(可以是多個,中間用|)。通過不斷呼叫select選擇IO就緒事件,在發生相應事件時會得到一個通知,做後續處理。

選擇器的使命是完成IO的多路複用。一個通道代表一條連線通路,通過選擇器可以同時監控多個通道的IO(輸入輸出)狀況。選擇器和通道的關係,是監控和被監控的關係。

這裡還涉及到SelectionKey的概念,SelectionKey選擇鍵就是那些被選擇器選中的IO事件。

主要方法:

  1. 開啟一個Selector
public static Selector open() throws IOException
  1. 獲取SelectionKey
public Set<SelectionKey> selectedKeys();
  1. 選擇感興趣的IO就緒事件
1. public int select(long timeout)
        throws IOException;
2. public int select() throws IOException;
  1. 關閉Selector
public void close() throws IOException;

NIO涉及的概念和API較多,下面通過一個具體的例子簡單演示(移除了異常處理、關閉通道或連線的操作)

IO事件:

  • (1)可讀:SelectionKey.OP_READ
  • (2)可寫:SelectionKey.OP_WRITE
  • (3)連線:SelectionKey.OP_CONNECT
  • (4)接收:SelectionKey.OP_ACCEPT

並不是所有Channel都支援這幾個事件,例如ServerSocketChannel只支援OP_ACCEPT

一個NIO傳檔案的例子

/**
    * 移除了一些關閉通道的程式碼,可能無法執行
    * 正常應該在try finally關閉, 或者使用try with resources語法自動關閉
    * @throws IOException
    */
@Test
public void server() throws IOException {
    // 獲得channel
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 繫結埠
    serverSocketChannel.bind(new InetSocketAddress(1234));
    // 設定為非阻塞,這很重要!!!
    serverSocketChannel.configureBlocking(false);
    // 開啟Selector
    Selector selector = Selector.open();
    // 將通道註冊到Selector
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    int i = 0;
    while (selector.select() > 0) { // 輪詢選擇感興趣的io事件
        // 拿到選擇鍵
        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
        while (iterator.hasNext()) { // 遍歷選擇鍵,對特定時間做處理, 可以單獨去開執行緒處理
            SelectionKey key = iterator.next();
            if (key.isAcceptable()) { // 處理接收事件
                ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                SelectableChannel channel = serverChannel.accept();
                channel.configureBlocking(false);
                // 將客戶端連線的SocketChannel也進行註冊
                channel.register(selector, SelectionKey.OP_READ);
            } else if (key.isReadable()) { // 處理讀事件
                ByteBuffer buffer = ByteBuffer.allocate(1 * mb);
                SocketChannel clientChannel = (SocketChannel) key.channel();
                FileChannel fileChannel = FileChannel.open(Paths.get(path, "qrcode" + (++i) + ".png"),
                        StandardOpenOption.WRITE, StandardOpenOption.CREATE);
                int len = -1;
                while ((len = clientChannel.read(buffer)) > 0) {
                    buffer.flip(); // 切換到讀模式
                    fileChannel.write(buffer);
                    buffer.clear(); // 切回寫模式,別忘了!!
                }
                clientChannel.close();
                fileChannel.close();
            }
            // 處理過的事件一定要移除
            iterator.remove();
        }
    }
}

@Test
public void client() throws IOException {
    // 獲取channel
    SocketChannel socketChannel = SocketChannel.open();
    // 連線
    socketChannel.connect(new InetSocketAddress(1234));
    // 設定非阻塞
    socketChannel.configureBlocking(false);
    // 開選擇器
    Selector selector = Selector.open();
    // 將channel註冊進選擇器
    socketChannel.register(selector, SelectionKey.OP_WRITE);
    while (selector.select() > 0) { // 選擇感興趣的事件
        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
        while (iterator.hasNext()) {
            SelectionKey key = iterator.next();
            SocketChannel channel = (SocketChannel) key.channel();
            if (key.isWritable()) { // 處理可寫事件
                FileChannel fileChannel = FileChannel.open(Paths.get(path, "qrcode.png"), StandardOpenOption.READ);
                ByteBuffer byteBuffer = ByteBuffer.allocate(1 * mb);
                int len = -1;
                while ((len = fileChannel.read(byteBuffer)) > 0) {
                    byteBuffer.flip();
                    channel.write(byteBuffer);
                    byteBuffer.clear();
                }
            }
        }
    }
}

NIO使用步驟總結

  1. 獲取Channel
  2. 開啟Selector
  3. 將channel註冊到Selector
  4. 輪詢感興趣的事件
  5. 遍歷SelectionKey並最不同事件型別做相應處理

NIO的難度確實比BIO高不少,而且上述只是一個簡單的例子,而且可能存在問題,實際中會比這裡複雜的多,比如粘包拆包、序列化之類的問題。正因如此,才有了Netty,Netty有非常廣泛的應用,比如Dubbo底層、RocketMQ等等。Netty是後邊需要和大家一起研究的話題。

小結

本文介紹了5種IO模型,同步阻塞、同步非阻塞、多路複用、訊號驅動、非同步;然後介紹了java中的三種IO模型;最後對NIO的基礎支援點做了簡單介紹。期望能幫助你複習或者瞭解相關知識點,疏漏之處,請不吝指出。IO之路,道阻且長,加油~

IO小結

參考資料

大雄和你一起學程式設計