IO通訊模型(三)多路複用IO

未讀程式碼發表於2019-11-13

mac揹包

多路複用IO

非阻塞同步IO的介紹中可以發現,為每一個接入建立一個執行緒在請求很多的情況下不那麼適用了,因為這會漸漸耗盡伺服器的資源,人們也都意識到了這個 問題,因此終於有人發明了IO多路複用。最大的特點就是不需要開那麼多的執行緒和程式多路複用IO是指使用一個執行緒來檢查多個檔案描述符(Socket)的就緒狀態,比如呼叫select和poll函式,傳入多個檔案描述符,如果有一個檔案描述符就緒,則返回,否則阻塞直到超時。得到就緒狀態後進行真正的操作可以在同一個執行緒裡執行,也可以啟動執行緒執行(比如使用執行緒池)。

NIO

如圖,這樣在處理多個連線時,可以只需要一個執行緒監控就緒狀態,對就緒的每個連線開一個執行緒處理就可以了,這樣需要的執行緒數大大減少,減少了記憶體開銷和上下文切換的CPU開銷。

多路複用IO有幾個比較重要的概念,下面一一講解。

緩衝區Buffer

Buffer本質是可以寫入可以讀取的記憶體,這塊記憶體被包裝成了NIO的Buffer物件,然後為它提供一組用於訪問的方法。Java則為java.nio.Buffer實現了基本資料型別的Buffer

Buffer

所有的Buffer緩衝區都有4個屬性,具體解釋可以看錶格。

屬性 描述
Capacity 容量,可以容納的最大資料量,不可變
Limit 上屆,緩衝區當前資料量,Capacity=>Limit
Position 位置,下一個要被讀取或者寫入的元素的位置,Capacity>=Position
Mark 標記,呼叫mark()來設定mark=position,再呼叫reset()設定position=mark

這4個屬性遵循大小關係: mark <= position <= limit <= capacity

Buffer的基本用法

使用Buffer讀寫資料一般遵循以下四個步驟:

  1. 寫入資料到Buffer
  2. 呼叫flip()方法。
  3. 從Buffer中讀取資料。
  4. 呼叫clear()方法或者compact()方法。

Buffer的測試程式碼

下面是對於Java中ByteBuffer的測試程式碼:

        // 申請一個大小為1024bytes的緩衝buffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        System.out.println("申請到的Buffer:"+byteBuffer);

        // 寫入helloworld到buffer
        byteBuffer.put("HelloWorld".getBytes());
        System.out.println("寫入HelloWorld到Buffer:"+byteBuffer);

        // 切換為讀模式
        byteBuffer.flip();
        // 當前Buffer已存放的大小
        int length = byteBuffer.remaining();
        byte[] bytes = new byte[length];

        // 讀取bytes長度的資料
        byteBuffer.get(bytes);
        System.out.println("從buffer讀取到資料:"+new String(bytes,"UTF-8"));

        // 切換為compact 清空已讀取的資料
        byteBuffer.compact();
        System.out.println("讀取後的Buffer:"+byteBuffer);
複製程式碼

得到如下輸出:

申請到的Buffer:java.nio.HeapByteBuffer[pos=0 lim=1024 cap=1024]
寫入HelloWorld到Buffer:java.nio.HeapByteBuffer[pos=10 lim=1024 cap=1024]
從buffer讀取到資料:HelloWorld
讀取後的Buffer:java.nio.HeapByteBuffer[pos=0 lim=1024 cap=1024]
複製程式碼

需要說明的是flip()方法將Buffer從寫模式切換到讀模式,clear()方法會清空整個緩衝區。compact()方法只會清除已經讀過的資料。

Buffer的讀寫模式

注意讀寫模式切換時候幾個標記位的變化。

IO讀寫模式

通道Channel

通道Channel和流類似,不同的是通道的工作模式可以是全雙工。也就是說既可以讀取,也可以寫入。同時也可以非同步的進行讀寫。Channel連線著底層資料與緩衝區Buffer。 同樣的,Java中針對不同的情況實現了不同的Channel操作類。常用的有

  1. FileChannel 從檔案中讀寫資料。
  2. DatagramChannel 能通過UDP讀寫網路中的資料。
  3. SocketChannel 能通過TCP讀寫網路中的資料。
  4. ServerSocketChannel可以監聽新進來的TCP連線,像Web伺服器那樣。對每一個新進來的連線都會建立一個SocketChannel。

下面是對於Java中Channel和Buffer的簡單演示:

    // 申請一個大小為1024bytes的緩衝buffer
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
     // 初始化Channel資料
    FileInputStream fis = new FileInputStream("f:/test.txt");
    FileChannel channel = fis.getChannel();
    System.out.println("Init Channel size:" + channel.size());
     // 從channel中讀取資料
    int read = channel.read(byteBuffer);
    System.out.println("Read Size :" + read);
    System.out.println("byteBuffer:"+byteBuffer);
     // 切換到讀取模式
    byteBuffer.flip();
     // 輸出byteBuffer內容
    System.out.print("print byteBuffer:");
    while (byteBuffer.hasRemaining()){
        System.out.print((char) byteBuffer.get());
    }
     byteBuffer.clear();
    System.out.println(byteBuffer);
    fis.close();

複製程式碼

輸出資訊如下:

Init Channel size:10
Read Size :10
byteBuffer:java.nio.HeapByteBuffer[pos=10 lim=1024 cap=1024]
print byteBuffer:helloworld
複製程式碼

需要注意的是,在讀取之前一定要呼叫flip()切換到讀取模式。

選擇器Selector

Selector(選擇器)是Java NIO中能夠檢測一到多個NIO通道,並能夠知曉通道是否為諸如讀寫事件做好準備的元件。這樣,一個單獨的執行緒可以管理多個channel,從而管理多個網路連線。我們也可以稱Selector為輪詢代理器,事件訂閱器或者channel容器管理器。 應用程式將向Selector物件註冊需要它關注的Channel,以及具體的某一個Channel會對哪些IO事件感興趣。Selector中也會維護一個“已經註冊的Channel”的容器。

關於IO事件,我們可以在SelectionKey類中找到幾個常用事件:

  1. OP_READ 可以讀取
  2. OP_WRITE 可以寫入
  3. OP_CONNECT 已經連線
  4. OP_ACCEPT 可以接受

值得注意的是,在程式中都是通過不斷的輪訓已經註冊的Channel,根據檢查註冊時的感興趣事件是否已經就緒來決定是否可以進行後續操作。同時Selector也有幾個經常使用的方法。

  1. select() 阻塞到至少有一個通道在你註冊的事件上就緒了。

  2. select(long timeout) 最長會阻塞timeout毫秒

  3. selectNow() 會阻塞,不管什麼通道就緒都立刻返回

  4. selectedKeys() 返回就緒的通道

下面是一個對Java中Selector編寫服務端的簡單使用測試(客戶端不在此編寫了,如有需要,可以檢視IO通訊模型(一)同步阻塞模式BIO(Blocking IO)中的客戶端程式碼):


import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

/**
 * <p>
 * NIO-Selector
 * 選擇器的使用測試
 * Selector(選擇器)是Java NIO中能夠檢測一到多個NIO通道,並能夠知曉通道是否為諸如讀
 * 寫事件做好準備的元件。這樣,一個單獨的執行緒可以管理多個channel,從而管理多個網路連線
 * 。我們也可以稱Selector為輪詢代理器,事件訂閱器或者channel容器管理器。
 * 應用程式將向Selector物件註冊需要它關注的Channel,以及具體的某一個Channel會對哪些
 * IO事件感興趣。Selector中也會維護一個“已經註冊的Channel”的容器。
 *
 * @Author niujinpeng
 * @Date 2018/10/26 15:31
 */
public class NioSelector {

    public static void main(String[] args) throws IOException {
        // 獲取channel
        ServerSocketChannel channel = ServerSocketChannel.open();
        // channel是否阻塞
        channel.configureBlocking(false);
        // 監聽88埠
        ServerSocket socket = channel.socket();
        socket.bind(new InetSocketAddress(83));


        // 建立選擇器Selector
        Selector selector = Selector.open();
        // 像選擇器中註冊channel
        channel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            // 阻塞到有一個就緒
            int readyChannel = selector.select();
            if (readyChannel == 0) {
                continue;
            }
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                iterator.remove();
                // 是否可以接受
                if (selectionKey.isAcceptable()) {
                    System.out.println("準備就緒");
                    SelectableChannel selectableChannel = selectionKey.channel();
                    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectableChannel;
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    // 註冊感興趣事件-讀取
                    socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(2048));
                } else if (selectionKey.isConnectable()) {
                    System.out.println("已連線");

                } else if (selectionKey.isReadable()) {
                    System.out.println("可以讀取");

                } else if (selectionKey.isWritable()) {
                    System.out.println("可以寫入");

                }
            }
        }
    }
}

複製程式碼

Java NIO程式設計

到這裡,已經對多路複用IO有了一個基本的認識了,可以結合上面的三個概念就行多路複用IO程式設計了,下面演示使用Java語言編寫一個多路複用IO服務端。 NioSocketServer.java

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

/**
 * <p>
 * 使用Java NIO框架,實現一個支援多路複用IO的伺服器端
 *
 * @Author niujinpeng
 * @Date 2018/10/16 0:53
 */
public class NioSocketServer {
    /**
     * 日誌
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(NioSocketServer.class);

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        // 是否阻塞
        serverChannel.configureBlocking(false);
        ServerSocket serverSocket = serverChannel.socket();
        serverSocket.setReuseAddress(true);
        serverSocket.bind(new InetSocketAddress(83));

        Selector selector = Selector.open();
        // 伺服器通道只能註冊SelectionKey.OP_ACCEPT事件
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            // java程式對多路複用IO的支援也包括了阻塞模式 和非阻塞模式兩種。
            if (selector.select(100) == 0) {
                //LOGGER.info("本次詢問selector沒有獲取到任何準備好的事件");
                continue;
            }

            // 詢問系統,所有獲取到的事件型別
            Iterator<SelectionKey> selectionKeys = selector.selectedKeys().iterator();
            while (selectionKeys.hasNext()) {
                SelectionKey readKey = selectionKeys.next();
                // 上面獲取到的readKey要移除,不然會一直存在selector.selectedKeys()的集合之中
                selectionKeys.remove();

                SelectableChannel selectableChannel = readKey.channel();
                if (readKey.isValid() && readKey.isAcceptable()) {
                    LOGGER.info("--------------channel通道已經準備完畢-------------");
                    /*
                     * 當server socket channel通道已經準備好,就可以從server socket channel中獲取socketchannel了
                     * 拿到socket channel後,要做的事情就是馬上到selector註冊這個socket channel感興趣的事情。
                     * 否則無法監聽到這個socket channel到達的資料
                     * */
                    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectableChannel;
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    registerSocketChannel(socketChannel, selector);
                } else if (readKey.isValid() && readKey.isConnectable()) {
                    LOGGER.info("--------------socket channel 建立連線-------------");
                } else if (readKey.isValid() && readKey.isReadable()) {
                    LOGGER.info("--------------socket channel 資料準備完成,可以開始讀取-------------");
                    try {
                        readSocketChannel(readKey);
                    } catch (Exception e) {
                        LOGGER.error(e.getMessage());
                    }
                }
            }
        }

    }

    /**
     * 在server socket channel接收到/準備好 一個新的 TCP連線後。
     * 就會向程式返回一個新的socketChannel。<br>
     * 但是這個新的socket channel並沒有在selector“選擇器/代理器”中註冊,
     * 所以程式還沒法通過selector通知這個socket channel的事件。
     * 於是我們拿到新的socket channel後,要做的第一個事情就是到selector“選擇器/代理器”中註冊這個
     * socket channel感興趣的事件
     *
     * @param socketChannel
     * @param selector
     * @throws Exception
     */
    private static void registerSocketChannel(SocketChannel socketChannel, Selector selector) {
        // 是否阻塞
        try {
            socketChannel.configureBlocking(false);
            // 讀模式只能讀,寫模式可以同時讀
            // socket通道可以且只可以註冊三種事件SelectionKey.OP_READ | SelectionKey.OP_WRITE | SelectionKey.OP_CONNECT
            socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(2048));
        } catch (IOException e) {
            LOGGER.info(e.toString(), e);
        }

    }

    private static void readSocketChannel(SelectionKey readKey) throws Exception {
        SocketChannel clientSocketChannel = (SocketChannel) readKey.channel();
        //獲取客戶端使用的埠
        InetSocketAddress sourceSocketAddress = (InetSocketAddress) clientSocketChannel.getRemoteAddress();
        int sourcePort = sourceSocketAddress.getPort();

        // 拿到這個socket channel使用的快取區,準備讀取資料
        // 解快取區的用法概念,實際上重要的就是三個元素capacity,position和limit。
        ByteBuffer contextBytes = (ByteBuffer) readKey.attachment();
        // 通道的資料寫入到【快取區】
        // 由於之前設定了ByteBuffer的大小為2048 byte,所以可以存在寫入不完的情況,需要調整
        int realLen = -1;
        try {
            realLen = clientSocketChannel.read(contextBytes);
        } catch (Exception e) {
            LOGGER.error(e.getMessage());
            clientSocketChannel.close();
            return;
        }

        // 如果快取中沒有資料
        if (realLen == -1) {
            LOGGER.warn("--------------快取中沒有資料-------------");
            return;
        }

        // 將快取區讀寫狀態模式進行切換
        contextBytes.flip();
        // 處理編碼問題
        byte[] messageBytes = contextBytes.array();
        String messageEncode = new String(messageBytes, "UTF-8");
        String message = URLDecoder.decode(messageEncode, "UTF-8");

        // 接受到了"over"則清空buffer,並響應,否則不清空快取,並還原Buffer寫狀態
        if (message.indexOf("over") != -1) {
            //清空已經讀取的快取,並從新切換為寫狀態(這裡要注意clear()和capacity()兩個方法的區別)
            contextBytes.clear();
            LOGGER.info("埠【" + sourcePort + "】客戶端發來的資訊:" + message);
            LOGGER.info("埠【" + sourcePort + "】客戶端訊息傳送完畢");
            // 響應
            ByteBuffer sendBuffer = ByteBuffer.wrap(URLEncoder.encode("Done!", "UTF-8").getBytes());
            clientSocketChannel.write(sendBuffer);
            clientSocketChannel.close();
        } else {
            LOGGER.info("埠【" + sourcePort + "】客戶端發來的資訊還未完畢,繼續接收");
            // limit和capacity的值一致,position的位置是realLen的位置
            contextBytes.position(realLen);
            contextBytes.limit(contextBytes.capacity());
        }
    }
}
複製程式碼

多路複用IO優缺點

  • 不需要使用多執行緒進行IO處理了
  • 同一個埠可以處理多種協議
  • 多路複用IO具有作業系統級別的優化
  • 其實底層還都是同步IO

文章程式碼已經上傳GitHub:github.com/niumoo/java…

<完>

個人網站:www.codingme.net
如果你喜歡這篇文章,可以關注公眾號,一起成長。
關注公眾號回覆資源可以沒有套路的獲取全網最火的的 Java 核心知識整理&面試資料。

公眾號

相關文章