Java BIO,NIO,AIO

Cuzzz發表於2023-04-05

一丶IO模型&Java IO

Unix為程式設計師提供了以下5種基本的io模型:

  • blocking io: 阻塞io
  • nonblocking io: 非阻塞io
  • I/O multiplexing: io多路複用
  • signal driven I/O:訊號驅動io
  • asynchronous I/O:非同步io

但我們平時工作中說的最多是,阻塞非阻塞同步非同步

1.阻塞非阻塞,同步非同步

  • 阻塞呼叫是指呼叫結果返回之前,當前執行緒會被掛起。呼叫執行緒只有在得到結果之後才會返回。非阻塞呼叫指在不能立刻得到結果之前,該呼叫不會阻塞當前執行緒。

    image-20230405114445720

    可用把上面的阻塞佇列看作是外賣櫃

    put方法就是外賣員放外賣,如果容量不夠那麼一直等待其他使用者拿走外賣,這是阻塞。

    offer方法也是外賣員放外賣,但是他發現容量不夠的時候,返回false,然後採取其他行動,比如打電話喊你下來拿外賣。

  • 同步與非同步關注的是訊息通訊機制。同步是發起呼叫在沒有得到結果之前,該呼叫就不返回。非同步是發起呼叫後,這個呼叫就直接返回了。

    訊息佇列中介軟體的作用之一就是非同步,傳送方將訊息傳送就立馬返回了,不需要等待這個訊息被消費者處理。

    同步就是你打電話問外賣員外賣到哪裡了,外賣員告知你之前你不結束通話電話。

    非同步就是你外賣app上發訊息問外賣員,發完訊息你立馬可用做其他的事情。

    非同步情況下你怎麼知道外賣到哪裡了暱?

    • 通知

      外賣員透過平臺回覆你

    • 回撥

      你給外賣員註冊了一個回撥事件——收到訊息後,請回電告知,然後你呼叫結束,繼續處理你的事情,但是外賣員收到訊息後,會回撥進行電話。

2.Unix的io模型

io操作分為兩步:

  • 等待資料就緒

    例如讀檔案的過程中需要等待磁碟掃描所需資料,等待資料到達核心緩衝區

  • 將資料從核心空間複製到使用者空間

    對於一次讀取IO的操作,資料並不會直接複製到應用程式的緩衝區(使用者空間),它首先會被複製到作業系統核心的緩衝區(核心空間)中,然後才會從作業系統核心的緩衝區複製到應用程式的緩衝區。

2.1 blocking io阻塞io

img

首先是我們使用者進行進行系統呼叫,產生中斷,作業系統切換到核心態,隨後是核心完成資料準備和資料從核心空間複製到使用者空間,然後應用程式繼續執行。

這裡說的阻塞,是系統呼叫不會立即返回,而是需要阻塞知道資料準備完成,並複製到使用者空間。

2.2 nonblocking io 非阻塞io

img

可看到,和阻塞io的區別在於,準備資料的這個過程,是應用程式不斷進行系統呼叫,詢問作業系統核心是否完成了資料準備,此係統呼叫不會阻塞直到資料準備完成,而是立馬返回。

但是第二階段,資料從核心空間複製到使用者空間是阻塞的,這個過程通常是比較快速的,因為這時候已經有DMA控制器完成了資料從磁碟搬運到記憶體,只需要複製到使用者態空間中即可。

2.3 I/O multiplexing io多路複用

img

可以看到IO多路複用的流程和 blocking io阻塞io類似,甚至還會多一次系統呼叫。那麼IO多路複用存在的意義是什麼暱?

假設我們當前的程式是一個服務端程式,存在多個網路io需要處理,我們需要多個執行緒取處理多個網路io,並且多個執行緒都是阻塞在系統呼叫上的,這是對執行緒資源的浪費。

io多路複用的優點就是:可以使用一個執行緒監聽多路io,這個執行緒阻塞與select系統呼叫上,當多路io存在任何一個io可讀的時候,執行緒將被喚醒,然後進行資料的複製,並進行處理,從而節省執行緒資源。

2.4 signal driven I/O訊號驅動io

img

可以看到,訊號驅動的io在資料準備階段是非阻塞的,當作業系統完成資料準備後將傳送訊號來通知使用者程式發生了某事件,使用者程式需要編寫對應的訊號處理函式,在訊號處理函式中阻塞與核心資料複製,待複製完成後對資料進行處理。

2.5 asynchronous I/O 非同步io

img

上面四種模型都會在資料從核心空間,複製到使用者空間這一步發生阻塞,也就是說至少第二步是需要同步等待作業系統完成複製的。

非同步io模型則解決了這個問題,應用程式只要通知核心要讀取的套接字物件, 以及資料的接收地址, 則整個過程都是由核心獨立來完成, 包括資料從核心空間向使用者空間的複製,複製完成後再透過訊號來通知使用者程式。

2.java中的io模型

阻塞非阻塞同步非同步進行組合

  • 阻塞同步io

    這就是java中的BIO

  • 非阻塞同步io

    這就是java中的NIO,java中的nio是透過io多路複用實現的

  • 非阻塞非同步io

    這就是java中的AIO,java中的AIO也是透過io多路複用實現,呈現出非同步的表象

二丶Java BIO

下面探討下java中BIO實現Socket程式設計方面的不足

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

    ExecutorService threadPool 
            = new ThreadPoolExecutor(10,10,100, TimeUnit.SECONDS,new ArrayBlockingQueue<>(100));

    // 1 建立一個socket server監聽tcp 1111埠
    ServerSocket serverSocket = new ServerSocket(1111);
    // 2 阻塞式接受來自客戶端的連線
    while (true) {
        //這一步是阻塞的  阻塞直到有客戶端連線上來
        Socket socket = serverSocket.accept();
        System.out.println(socket.getRemoteSocketAddress() + "連線到服務端");
        // 3 為了不影響後續連線進來處理,使用多執行緒來處理連線
        threadPool.execute(() -> process(socket));
    }
}

private static void process(Socket socket) {
    try (OutputStream out = socket.getOutputStream()) {
        byte[] buffer = new byte[1024];
        int len;
        while ((len = socket.getInputStream().read(buffer)) > 0) {
            System.out.println(socket.getRemoteSocketAddress() + "傳送資料:" + new String(buffer, 0, len));
            out.write(buffer, 0, len);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

上面程式碼實現了,如果客戶端請求過來講客戶端請求原封不動的寫回,可以看到為了實現實現服務端支援多個客戶端的連線,我們使用的執行緒池。

首先 Socket socket = serverSocket.accept(),這一步會阻塞直到有客戶端連線上來(這一步無所謂,甚至避免了主執行緒無休止的自旋)

其次process方法中拿到輸入輸出流寫回的操作也是阻塞的,這一步需要使用作業系統提供的系統呼叫,將資料從網路卡或者硬碟讀入核心空間,然後從核心空間複製到使用者空間,我們的java程式才可以進行讀操作,寫則反之。

由於read,write這兩個方法是阻塞式的,它需要阻塞直到系統呼叫完成,我們的程式傻傻阻塞等待,因此我們使用了執行緒池,希望一個執行緒處理一個客戶端請求,阻塞也只阻塞執行緒池中的執行緒。但是process方法中阻塞的這部分,會體現在我們執行緒池的執行緒,也就是說,執行緒池中存在一些執行緒阻塞於read,write函式。

這種模型的優點:

  • 簡單直接,可以讓開發人員專注於編寫process的業務邏輯
  • 不用過多考慮系統的過載、限流等問題。執行緒池本身就是一個天然的漏斗,可以緩衝一些系統處理不了的連線或請求。
  • 使用多執行緒利用多核心cpu的能力,當執行緒阻塞的時候,cpu可以切換時間片給其他執行緒

這種模型的缺點:

  • 非常依賴於執行緒,執行緒是寶貴的資源,雖然使用執行緒池進行了複用,當前當大量請求到來的時候,我們無法無限制的開闢執行緒。眾多的執行緒被掛起,被喚醒還會導致上下文切換頻繁,cpu利用率降低
  • 執行緒本身佔用較大記憶體,過多的執行緒導致jvm記憶體岌岌可危

那麼怎麼解決上述的問題暱,能不能解放執行緒不讓他們阻塞在read和write中,能讀那就讀,不能讀那就繼續處理其他socket?

三丶Java NIO

image-20230405164149306

回顧這張圖,我們上面說的解放執行緒不讓他們阻塞在read和write中,能讀那就讀,不能讀那就繼續處理其他socket,不正是上面非阻塞的方式,希望系統呼叫可以立即返回,而不是阻塞。

Java中的nio基於io多路複用實現了同步非阻塞的處理方式

public static void main(String[] args) throws IOException, InterruptedException {
    // 1 建立selector用來偵聽多路IO訊息 '檔案描述符'
    // selector 擔任了重要的通知角色,可以將任意IO註冊到selector上,透過非阻塞輪巡selector來得知哪些路IO有訊息了
    // 底層是epoll(linux下)
    // 後續會把server端註冊上來,有服務端被客戶端連線上來的IO訊息
    // 也會把每個客戶端連線註冊上來,有客戶端傳送過來的資料
    Selector selector = Selector.open();
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    // 2 把server端註冊上去
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.socket().bind(new InetSocketAddress("127.0.0.1", 1111));
    //配置為非阻塞
    serverSocketChannel.configureBlocking(false);
    //關心accept事件,
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
        // 3 這一步是阻塞的,基於io多路複用中的select poll,epoll
        // 這裡可以設定等待事件
        if (selector.select() == 0) {
            continue;
        }

        // 4 如果有至少一路IO有訊息,那麼set不為空
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        for (SelectionKey key : selectionKeys) {
            if (key.isAcceptable()) {
                System.out.println("客戶端連線");
                // 因為我們只註冊了serverSocketChannel這一個可以accept的所以這裡用強轉即可
                SocketChannel socketChannel = ((ServerSocketChannel) key.channel()).accept();
                socketChannel.configureBlocking(false);
                // 5 當第一次客戶端連線時,就將這個連線也作為channel註冊上,他是可讀型的
                //當前只是有客戶端連線上來了,但是並不代表可讀,還需要DMA將網路卡資料搬運到記憶體
                socketChannel.register(selector, SelectionKey.OP_READ);
            } else if (key.isReadable()) {
                // 6 因為步驟5把客戶端連線也註冊上來了,並且是可讀上面的資料的,如果該channel被選出來說明有客戶端資料來了
                SocketChannel socketChannel = (SocketChannel) key.channel();
                // 7 必須藉助ByteBuffer接受和傳送資料
                byteBuffer.clear();
                if (socketChannel.read(byteBuffer) <= 0) {
                    continue;
                }
                byteBuffer.flip();
                byte[] b = new byte[byteBuffer.limit()];
                byteBuffer.get(b);
                System.out.println(key + " 資料來了: " + new String(b));
                byteBuffer.clear();
                byteBuffer.put(b);
                byteBuffer.flip();
                socketChannel.write(byteBuffer);
            }
        }
        // 8 非常重要一定要清理掉每個channel的key,來表示已經處理過了,不然下一次還會被select
        selectionKeys.clear();
    }
}

select是阻塞的,無論是透過作業系統的通知(epoll)還是不停的輪詢(select,poll),這個函式是阻塞的,它還支援超時阻塞模式。這是一個執行緒監聽多路io的體現,只要有一個事件就緒那麼select就會返回。

socketChannel.configureBlocking(false)將 socketChannel設定為非阻塞其讀寫操作都是非阻塞的,也就說如果無法讀,那麼read函式返回-1,將會讓當前執行緒去遍歷其他就緒的事件,而不是傻傻等待,這是非阻塞io的體現

四丶Java AIO

 public static void main(String[] args) throws IOException, InterruptedException {
        AsynchronousServerSocketChannel serverChannel =
                AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(1111));
        System.out.println(Thread.currentThread() + "開始監聽1111埠");
        serverChannel.accept(null, new CompletionHandler<>() {
            @SneakyThrows
            @Override
            public void completed(AsynchronousSocketChannel channel, Object attachment) {
                // 遞迴註冊accept
                serverChannel.accept(attachment, this);
                System.out.println(Thread.currentThread() + "有客戶端連線上來了" + channel.getRemoteAddress());
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                channel.read(buffer, null, new CompletionHandler<Integer, ByteBuffer>() {
                    @SneakyThrows
                    @Override
                    public void completed(Integer len, ByteBuffer attachment) {
                        // 遞迴註冊read
                        channel.read(buffer, null, this);
                        buffer.flip();
                        System.out.println(channel.getRemoteAddress() + ":" + new String(buffer.array(), 0, len));
                        buffer.clear();
                        channel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                    }

                    @Override
                    public void failed(Throwable exc, ByteBuffer attachment) {

                    }
                });
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
            }
        });
        Thread.sleep(Integer.MAX_VALUE);
    }

AIO中,所有建立的通道都會直接在OS上註冊監聽,當出現IO請求時,會先由作業系統接收、準備、複製好資料,然後再通知監聽對應通道的程式處理資料。

客戶端的連線到來後同樣會先註冊到選擇器上,但客戶端的I/O請求會先交由OS處理,當核心將資料複製完成後才會分配一條執行緒處理。這一點不同於BIO和NIO,NIO和BIO在核心複製資料到使用者態的這一步任然是阻塞的。

相關文章