I/O模型與Java

原文已同步至http://liumian.win/2016/11/23/io-model-and-java/


學習I/O模型之前,首先要明白幾個概念:

  • 同步、異步
  • 阻塞、非阻塞

這幾個概念往往是成對出現的,我們常常能夠看到同步阻塞,異步非阻塞等描述,正因為如此我們往往在腦海里面是一個模糊的概念 - “哦,他們是這個樣子啊,都差不多嘛”。

我剛開始接觸IO知識的時候,也存在上述的問題,分不清他們的區別。隨著學習的深入,漸漸來到了痛點區域 - 不弄懂全身感覺不舒服,非弄懂不可。

同步與異步
描述的是用戶線程與內核的交互方式:

  • 同步是指用戶線程發起 I/O 請求后需要等待或者輪詢內核 I/O 操作完成后才能繼續執行;
  • 異步是指用戶線程發起 I/O 請求后仍繼續執行,當內核 I/O 操作完成后會通知用戶線程,或者調用用戶線程注冊的回調函數。

阻塞和非阻塞
描述的是用戶線程調用內核 I/O 操作的方式:

  • 阻塞是指 I/O 操作需要徹底完成后才返回到用戶空間;
  • 非阻塞是指 I/O 操作被調用后立即返回給用戶一個狀態值,無需等到 I/O 操作徹底完成。

下面來看一種五種常見IO模型的對比,相信你看了這張圖片以后很快就會明白同步、異步、阻塞和非阻塞的區別。


五種IO模型

首先我們得明白一次IO操作是需要兩個階段的:準備數據(內核空間) -> 數據從內核空間拷貝到用戶空間。為什么要這么做呢?因為操作系統在內存中劃分了兩個區域:一個是內核空間,一個是用戶空間。內核空間是留給操作系統進行系統服務的,而用戶空間就是我們的程序運行的內存空間。而操作系統為了系統的安全是不允許我們的程序直接操作內存空間的,所以我們必須等待操作系統把磁盤上面的內容讀入到內核空間,然后拷貝到用戶空間才能操作。從圖片的右側也可以清晰的發現這兩個階段。

這覺得這篇博客總結得非常好,他說:

一個 I/O 操作其實分成了兩個步驟:發起 I/O 請求和實際的 I/O 操作。 阻塞 I/O 和非阻塞 I/O 的區別在于第一步,發起 I/O 請求是否會被阻塞,如果阻塞直到完成那么就是傳統的阻塞 I/O ,如果不阻塞,那么就是非阻塞 I/O 。 同步 I/O 和異步 I/O 的區別就在于第二個步驟是否阻塞,如果實際的 I/O 讀寫阻塞請求進程,那么就是同步 I/O 。

好了,經過上面的解釋是不是對IO相關知識理解又深刻一些了呢?又或者是模糊了許多呢?都沒關系,下面開始進行詳細的IO模型分析。

  1. 阻塞IO模型(BIO)
    如果IO請求無法立即完成,那么當前線程進入阻塞狀態。
    不管是第一階段還是第二階段,全部阻塞。

  2. 非阻塞IO 模型(Non-blinking IO)
    第一階段(準備數據)不會阻塞,第二階段(拷貝數據到用戶空間)會阻塞。
    因為第一階段不會阻塞,所以我們只有不斷的輪詢數據在內核空間是否準備完成,這個過程會造成CPU空轉,浪費了寶貴的CPU時間。所以不推薦直接使用這種IO模型進行項目開發。

  3. I/O復用模型
    從圖中我們可以看到,兩個階段都阻塞了。那么I/O復用模型和阻塞模型有什么區別呢?
    進(線)程將一個或者多個感興趣的事件(可讀、可寫等)注冊在select方法上面,當事件處于就緒狀態時意味著數據在用戶空間已經準備好(就緒之前為阻塞狀態),那么該方法就會返回執行后面的代碼,然后又會阻塞在recvfrom(將數據拷貝到用戶空間)這個過程直至完成。
    如果您之前用過Java中的Selector,可能很容易理解這塊知識。

  4. 信號驅動I/O模型
    這塊我不是很熟,《Netty權威指南》是這樣解釋的:
    首先開啟套接口信號驅動I/O功能,并通過系統調用sigaction執行一個信號處理函數(此系統調用立即返回,進程繼續工作,他是非阻塞的)。當數據準備就緒時,就為該進程生成一個SIGIO信號,通過信號回調通知應用程序調用recvfrom來讀取數據,并通知主循環函數處理數據。

  5. 異步I/O模型(AIO)
    兩個階段均不阻塞線程。工作原理為:通知內核啟動某個IO操作,內核將數據復制到用戶空間(我們指定的空間)后通知我們。這個過程用戶線程不會阻塞。

說了這么多大家是不是想問,你不是說Java中的I/O嗎?怎么到目前為止跟Java好像一點關系都沒有呢?嘿嘿,別急,下面我們就聊聊Java中的I/O模型~

Java中的I/O模型
首先剛剛說的大多數I/O模型在Java中都有對應的實現。為什么是大多數呢?因為信號驅動I/O模型沒有相應的實現。直接上代碼~

  1. 阻塞I/O
    我們通常在Socket編程入門的時候會這樣寫,
/**
 * 阻塞IO
 * Created by liumian on 2016/11/23.
 */
public class BlockServer {

    public static void main(String[] args) {
        int port = 8080;
        try {
            ServerSocket server = new ServerSocket(port);
            Socket clientSocket = server.accept();
            //client do something
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

}

這就是一個阻塞IO,阻塞在ServerSocket#accept方法上面,直到有數據到達才會執行后面的代碼。

  1. 非阻塞I/O與多路復用I/O
    相對于阻塞I/O,代碼要復雜很多。關于NIO的知識,一時半會也說不完,讀者可以下去了解一下相關知識~
/**
 * 非阻塞IO
 * Created by liumian on 2016/11/23.
 */
public class NonBlockServer {

    public static void main(String[] args) {
        int port = 8080;

        Selector selector = null;
        try {
            ServerSocketChannel channel = ServerSocketChannel.open();
            channel.socket().bind(new InetSocketAddress(port));
            //設置為非阻塞IO
            channel.configureBlocking(false);
            //打開一個復用器
            selector = Selector.open();
            //注冊感興趣的事件
            channel.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }

        while (true){
            try {
                selector.select();
            } catch (IOException e) {
                e.printStackTrace();
            }
            Set<SelectionKey> keySet = selector.selectedKeys();
            Iterator<SelectionKey> iterator = keySet.iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                if (key.isAcceptable()){
                    //do something
                }
            }
        }
    }

}

在NIO中出現了通道channel的概念。相對于之前阻塞IO模型中的流 - 只能單向移動(讀或者寫),它相當于一根水管可以雙向移動(既可以寫又可以讀或者同時進行)。

  1. 異步I/O
    Java在JDK7的時候引入了異步IO(NIO2.0)
    代碼借鑒了這個博客 Java I/O 模型的演進,(逃

public class AsyncServer {
    public static void main(String[] args) {
        int port = 8080;
        ExecutorService executor = Executors.newCachedThreadPool();
        // create asynchronous server socket channel bound to the default group
        try (AsynchronousServerSocketChannel asynchronousServerSocketChannel = AsynchronousServerSocketChannel.open()) {
            if (asynchronousServerSocketChannel.isOpen()) {
                // set some options
                asynchronousServerSocketChannel.setOption(StandardSocketOptions.SO_RCVBUF, 4 * 1024);
                asynchronousServerSocketChannel.setOption(StandardSocketOptions.SO_REUSEADDR, true);
                // bind the server socket channel to local address
                asynchronousServerSocketChannel.bind(new InetSocketAddress(port));
                // display a waiting message while ... waiting clients
                System.out.println("Waiting for connections ...");
                while (true) {
                    Future<AsynchronousSocketChannel> asynchronousSocketChannelFuture = asynchronousServerSocketChannel
                            .accept();
                    try {
                        final AsynchronousSocketChannel asynchronousSocketChannel = asynchronousSocketChannelFuture
                                .get();
                        Callable<String> worker = new Callable<String>() {
                            @Override
                            public String call() throws Exception {
                                String host = asynchronousSocketChannel.getRemoteAddress().toString();
                                System.out.println("Incoming connection from: " + host);
                                final ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
                                // transmitting data
                                while (asynchronousSocketChannel.read(buffer).get() != -1) {
                                    buffer.flip();
                                    asynchronousSocketChannel.write(buffer).get();
                                    if (buffer.hasRemaining()) {
                                        buffer.compact();
                                    } else {
                                        buffer.clear();
                                    }
                                }
                                asynchronousSocketChannel.close();
                                System.out.println(host + " was successfully served!");
                                return host;
                            }
                        };
                        executor.submit(worker);
                    } catch (InterruptedException | ExecutionException ex) {
                        System.err.println(ex);
                        System.err.println("\n Server is shutting down ...");
                        // this will make the executor accept no new threads
                        // and finish all existing threads in the queue
                        executor.shutdown();
                        // wait until all threads are finished
                        while (!executor.isTerminated()) {
                        }
                        break;
                    }
                }
            } else {
                System.out.println("The asynchronous server-socket channel cannot be opened!");
            }
        } catch (IOException ex) {
            System.err.println(ex);
        }
    }
}


  1. 偽異步I/O
    只要理解了異步I/O,那么偽異步I/O很好理解。
    異步I/O無非就是在所有的操作完成之后再來通知用戶線程進行后續操作,我們完全可以通過線程來偽造這種行為。
/**
 * 利用線程池來實現偽異步
 * Created by liumian on 2016/11/23.
 */
public class NAsyncServer {

    public static void main(String[] args) {
        int port = 8080;
        ExecutorService executor = Executors.newCachedThreadPool();
        try {
            ServerSocket server = new ServerSocket(port);
            while (true){
                Socket client = server.accept();
                executor.execute(new ClientHandler(client));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static class ClientHandler implements Runnable{

        private Socket socket;

        public ClientHandler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            //do something
        }
    }

}

總結
通過NIO、AIO我們可以獲得哪些好處?

  • 獲得更好的性能。通常基于塊的傳輸要比流要更高效。
  • 避免多線程。利用多路復用IO,我們能利用一個線程管理成千上萬的連接,而不用為每一個連接創建一個線程。
  • 提高CPU的利用率。不管是NIO還是AIO,都能夠大大減少IO阻塞時間,從而充分的利用CPU。

從JDK的發展可以看到,從阻塞IO到非阻塞IO到異步IO,我們可以通過靈活的運用IO構建我們的高性能服務器。不過從JDK發展的過程也可以看出,往往越靈活的操作使用起來越困難,所以《Netty權威指南》作者建議直接使用成熟的NIO框架去構建我們的服務器而不是使用原生的NIO接口,這樣可以避免很多陷阱。

個人感覺I/O這些知識不僅要多用,還要去想底層是怎么實現的。這樣有助于我們理解為什么要這么做~
以前剛接觸異步IO的時候,總是有這些問題:誰幫我們去完成了IO操作?我如何知道IO操作何時完成?IO操作完成以后數據是放在哪里的?等等問題。后面隨著學習的深入,結合操作系統、Java IO API等知識,慢慢也對IO有了自己的理解~~

檢查了很多遍,感覺寫的還是不夠通順,咬咬牙,硬著頭皮發布(逃


參考資料

Java NIO淺析 - 美團點評技術博客
Java I/O 模型的演進
《Netty 權威指南》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容