Java I/O 模型的演進

waylau發表於2016-03-02

什麼是同步?什麼是非同步?阻塞和非阻塞又有什麼區別?本文先從 Unix 的 I/O 模型講起,介紹了5種常見的 I/O 模型。而後再引出 Java 的 I/O 模型的演進過程,並用例項說明如何選擇合適的 Java I/O 模型來提高系統的併發量和可用性。

由於,Java 的 I/O 依賴於作業系統的實現,所以先了解 Unix 的 I/O 模型有助於理解 Java 的 I/O。

相關概念

同步和非同步

描述的是使用者執行緒與核心的互動方式:

  • 同步是指使用者執行緒發起 I/O 請求後需要等待或者輪詢核心 I/O 操作完成後才能繼續執行;
  • 非同步是指使用者執行緒發起 I/O 請求後仍繼續執行,當核心 I/O 操作完成後會通知使用者執行緒,或者呼叫使用者執行緒註冊的回撥函式。

阻塞和非阻塞

描述的是使用者執行緒呼叫核心 I/O 操作的方式:

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

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

Unix I/O 模型

Unix 下共有五種 I/O 模型:

  1. 阻塞 I/O
  2. 非阻塞 I/O
  3. I/O 多路複用(select 和 poll)
  4. 訊號驅動 I/O(SIGIO)
  5. 非同步 I/O(Posix.1 的 aio_ 系列函式)

阻塞 I/O

請求無法立即完成則保持阻塞。

  • 階段1:等待資料就緒。網路 I/O 的情況就是等待遠端資料陸續抵達;磁碟I/O的情況就是等待磁碟資料從磁碟上讀取到核心態記憶體中。
  • 階段2:資料拷貝。出於系統安全,使用者態的程式沒有許可權直接讀取核心態記憶體,因此核心負責把核心態記憶體中的資料拷貝一份到使用者態記憶體中。

非阻塞 I/O

  • socket 設定為 NONBLOCK(非阻塞)就是告訴核心,當所請求的 I/O 操作無法完成時,不要將程式睡眠,而是返回一個錯誤碼(EWOULDBLOCK) ,這樣請求就不會阻塞
  • I/O 操作函式將不斷的測試資料是否已經準備好,如果沒有準備好,繼續測試,直到資料準備好為止。整個 I/O 請求的過程中,雖然使用者執行緒每次發起 I/O 請求後可以立即返回,但是為了等到資料,仍需要不斷地輪詢、重複請求,消耗了大量的 CPU 的資源
  • 資料準備好了,從核心拷貝到使用者空間。

一般很少直接使用這種模型,而是在其他 I/O 模型中使用非阻塞 I/O 這一特性。這種方式對單個 I/O 請求意義不大,但給 I/O 多路複用鋪平了道路.

I/O 多路複用(非同步阻塞 I/O)

I/O 多路複用會用到 select 或者 poll 函式,這兩個函式也會使程式阻塞,但是和阻塞 I/O 所不同的的,這兩個函式可以同時阻塞多個 I/O 操作。而且可以同時對多個讀操作,多個寫操作的 I/O 函式進行檢測,直到有資料可讀或可寫時,才真正呼叫 I/O 操作函式。

從流程上來看,使用 select 函式進行 I/O 請求和同步阻塞模型沒有太大的區別,甚至還多了新增監視 socket,以及呼叫 select 函式的額外操作,效率更差。但是,使用 select 以後最大的優勢是使用者可以在一個執行緒內同時處理多個 socket 的 I/O 請求。使用者可以註冊多個 socket,然後不斷地呼叫 select 讀取被啟用的 socket,即可達到在同一個執行緒內同時處理多個 I/O 請求的目的。而在同步阻塞模型中,必須通過多執行緒的方式才能達到這個目的。

I/O 多路複用模型使用了 Reactor 設計模式實現了這一機制。

呼叫 select / poll 該方法由一個使用者態執行緒負責輪詢多個 socket,直到某個階段1的資料就緒,再通知實際的使用者執行緒執行階段2的拷貝。 通過一個專職的使用者態執行緒執行非阻塞I/O輪詢,模擬實現了階段一的非同步化

訊號驅動 I/O(SIGIO)

首先我們允許 socket 進行訊號驅動 I/O,並安裝一個訊號處理函式,程式繼續執行並不阻塞。當資料準備好時,程式會收到一個 SIGIO 訊號,可以在訊號處理函式中呼叫 I/O 操作函式處理資料。

非同步 I/O

呼叫 aio_read 函式,告訴核心描述字,緩衝區指標,緩衝區大小,檔案偏移以及通知的方式,然後立即返回。當核心將資料拷貝到緩衝區後,再通知應用程式。

非同步 I/O 模型使用了 Proactor 設計模式實現了這一機制。

告知核心,當整個過程(包括階段1和階段2)全部完成時,通知應用程式來讀資料.

幾種 I/O 模型的比較

前四種模型的區別是階段1不相同,階段2基本相同,都是將資料從核心拷貝到呼叫者的緩衝區。而非同步 I/O 的兩個階段都不同於前四個模型。

同步 I/O 操作引起請求程式阻塞,直到 I/O 操作完成。非同步 I/O 操作不引起請求程式阻塞。

常見 Java I/O 模型

在瞭解了 UNIX 的 I/O 模型之後,其實 Java 的 I/O 模型也是類似。

“阻塞I/O”模式

在上一節 Socket 章節中的 EchoServer 就是一個簡單的阻塞 I/O 例子,伺服器啟動後,等待客戶端連線。在客戶端連線伺服器後,伺服器就阻塞讀寫取資料流。

EchoServer 程式碼:

public class EchoServer {
    public static int DEFAULT_PORT = 7;

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

        int port;

        try {
            port = Integer.parseInt(args[0]);
        } catch (RuntimeException ex) {
            port = DEFAULT_PORT;
        }

        try (
            ServerSocket serverSocket =
                new ServerSocket(port);
            Socket clientSocket = serverSocket.accept();     
            PrintWriter out =
                new PrintWriter(clientSocket.getOutputStream(), true);                   
            BufferedReader in = new BufferedReader(
                new InputStreamReader(clientSocket.getInputStream()));
        ) {
            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                out.println(inputLine);
            }
        } catch (IOException e) {
            System.out.println("Exception caught when trying to listen on port "
                + port + " or listening for a connection");
            System.out.println(e.getMessage());
        }
    }
}

改進為“阻塞I/O+多執行緒”模式

使用多執行緒來支援多個客戶端來訪問伺服器。

主執行緒 MultiThreadEchoServer.java

public class MultiThreadEchoServer {
    public static int DEFAULT_PORT = 7;

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

        int port;

        try {
            port = Integer.parseInt(args[0]);
        } catch (RuntimeException ex) {
            port = DEFAULT_PORT;
        }
        Socket clientSocket = null;
        try (ServerSocket serverSocket = new ServerSocket(port);) {
            while (true) {
                clientSocket = serverSocket.accept();

                // MultiThread
                new Thread(new EchoServerHandler(clientSocket)).start();
            }
        } catch (IOException e) {
            System.out.println(
                    "Exception caught when trying to listen on port " + port + " or listening for a connection");
            System.out.println(e.getMessage());
        }
    }
}

處理器類 EchoServerHandler.java

public class EchoServerHandler implements Runnable {
    private Socket clientSocket;

    public EchoServerHandler(Socket clientSocket) {
        this.clientSocket = clientSocket;
    }

    @Override
    public void run() {
        try (PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
                BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));) {

            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                out.println(inputLine);
            }
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

存在問題:每次接收到新的連線都要新建一個執行緒,處理完成後銷燬執行緒,代價大。當有大量地短連線出現時,效能比較低。

改進為“阻塞I/O+執行緒池”模式

針對上面多執行緒的模型中,出現的執行緒重複建立、銷燬帶來的開銷,可以採用執行緒池來優化。每次接收到新連線後從池中取一個空閒執行緒進行處理,處理完成後再放回池中,重用執行緒避免了頻率地建立和銷燬執行緒帶來的開銷。

主執行緒 ThreadPoolEchoServer.java

public class ThreadPoolEchoServer {
    public static int DEFAULT_PORT = 7;

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

        int port;

        try {
            port = Integer.parseInt(args[0]);
        } catch (RuntimeException ex) {
            port = DEFAULT_PORT;
        }
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        Socket clientSocket = null;
        try (ServerSocket serverSocket = new ServerSocket(port);) {
            while (true) {
                clientSocket = serverSocket.accept();

                // Thread Pool
                threadPool.submit(new Thread(new EchoServerHandler(clientSocket)));
            }
        } catch (IOException e) {
            System.out.println(
                    "Exception caught when trying to listen on port " + port + " or listening for a connection");
            System.out.println(e.getMessage());
        }
    }
}

存在問題:在大量短連線的場景中效能會有提升,因為不用每次都建立和銷燬執行緒,而是重用連線池中的執行緒。但在大量長連線的場景中,因為執行緒被連線長期佔用,不需要頻繁地建立和銷燬執行緒,因而沒有什麼優勢。

改進為“非阻塞I/O”模式

“阻塞I/O+執行緒池”網路模型雖然比”阻塞I/O+多執行緒”網路模型在效能方面有提升,但這兩種模型都存在一個共同的問題:讀和寫操作都是同步阻塞的,面對大併發(持續大量連線同時請求)的場景,需要消耗大量的執行緒來維持連線。CPU 在大量的執行緒之間頻繁切換,效能損耗很大。一旦單機的連線超過1萬,甚至達到幾萬的時候,伺服器的效能會急劇下降。

而 NIO 的 Selector 卻很好地解決了這個問題,用主執行緒(一個執行緒或者是 CPU 個數的執行緒)保持住所有的連線,管理和讀取客戶端連線的資料,將讀取的資料交給後面的執行緒池處理,執行緒池處理完業務邏輯後,將結果交給主執行緒傳送響應給客戶端,少量的執行緒就可以處理大量連線的請求。

Java NIO 由以下幾個核心部分組成:

  • Channel
  • Buffer
  • Selector

要使用 Selector,得向 Selector 註冊 Channel,然後呼叫它的 select()方法。這個方法會一直阻塞到某個註冊的通道有事件就緒。一旦這個方法返回,執行緒就可以處理這些事件,事件的例子有如新連線進來,資料接收等。

主執行緒 NonBlokingEchoServer.java

public class NonBlokingEchoServer {
    public static int DEFAULT_PORT = 7;

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

        int port;

        try {
            port = Integer.parseInt(args[0]);
        } catch (RuntimeException ex) {
            port = DEFAULT_PORT;
        }
        System.out.println("Listening for connections on port " + port);

        ServerSocketChannel serverChannel;
        Selector selector;
        try {
            serverChannel = ServerSocketChannel.open();
            InetSocketAddress address = new InetSocketAddress(port);
            serverChannel.bind(address);
            serverChannel.configureBlocking(false);
            selector = Selector.open();
            serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException ex) {
            ex.printStackTrace();
            return;
        }

        while (true) {
            try {
                selector.select();
            } catch (IOException ex) {
                ex.printStackTrace();
                break;
            }
            Set<SelectionKey> readyKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = readyKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                try {
                    if (key.isAcceptable()) {
                        ServerSocketChannel server = (ServerSocketChannel) key.channel();
                        SocketChannel client = server.accept();
                        System.out.println("Accepted connection from " + client);
                        client.configureBlocking(false);
                        SelectionKey clientKey = client.register(selector,
                                SelectionKey.OP_WRITE | SelectionKey.OP_READ);
                        ByteBuffer buffer = ByteBuffer.allocate(100);
                        clientKey.attach(buffer);
                    }
                    if (key.isReadable()) {
                        SocketChannel client = (SocketChannel) key.channel();
                        ByteBuffer output = (ByteBuffer) key.attachment();
                        client.read(output);
                    }
                    if (key.isWritable()) {
                        SocketChannel client = (SocketChannel) key.channel();
                        ByteBuffer output = (ByteBuffer) key.attachment();
                        output.flip();
                        client.write(output);

                        output.compact();
                    }
                } catch (IOException ex) {
                    key.cancel();
                    try {
                        key.channel().close();
                    } catch (IOException cex) {
                    }
                }
            }
        }

    }
}

改進為“非同步I/O”模式

Java SE 7 版本之後,引入了非同步 I/O (NIO.2) 的支援,為構建高效能的網路應用提供了一個利器。

主執行緒 AsyncEchoServer.java

public class AsyncEchoServer {

    public static int DEFAULT_PORT = 7;

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

        try {
            port = Integer.parseInt(args[0]);
        } catch (RuntimeException ex) {
            port = DEFAULT_PORT;
        }

        ExecutorService taskExecutor = Executors.newCachedThreadPool(Executors.defaultThreadFactory());
        // 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;
                            }
                        };
                        taskExecutor.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
                        taskExecutor.shutdown();
                        // wait until all threads are finished
                        while (!taskExecutor.isTerminated()) {
                        }
                        break;
                    }
                }
            } else {
                System.out.println("The asynchronous server-socket channel cannot be opened!");
            }
        } catch (IOException ex) {
            System.err.println(ex);
        }
    }
}

原始碼

本章例子的原始碼,可以在 https://github.com/waylau/essential-java 中 com.waylau.essentialjava.net.echo 包下找到。

參考引用

相關文章