IO那些事


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小結

參考資料

大雄和你一起學編程


免責聲明!

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



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