Java Socket 之 NIO

大胃粥發表於2018-05-03

在前面的兩篇文章中,留下了一個問題,對於 TCP 或 UDP 的伺服器,如何實現併發處理客戶端。

最直觀的想法就是為每個到來的請求,建立一個單獨的執行緒來處理,但是這種方式未免太浪費資源了,那可以使用執行緒池來管理執行緒,這樣可以節約資源。以 TCP 伺服器舉例。

首先需要定義一個需要提交到執行緒池中的任務。

public class TCPRunnable implements Runnable {
    private Socket mSocket;

    public TCPRunnable(Socket socket) {
        mSocket = socket;
    }

    @Override
    public void run() {
        try {
            System.out.println("Handling client: " + mSocket.getRemoteSocketAddress());
            InputStream in = mSocket.getInputStream();
            OutputStream out = mSocket.getOutputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(in));
            String line;
            System.out.println("Client said: ");
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
            out.write("Welcome!".getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (mSocket != null) {
                    mSocket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

在建構函式中,需要傳入一個 Socket 例項,當任務提交到執行緒池後,Socket 的讀寫操作就在非同步執行緒中執行。

現在可以改進下伺服器端,只需要在獲取 Socket 例項後提交任務即可

public class TCPServer1 {
    public static void main(String[] args) {
        ExecutorService mThreadPool = Executors.newCachedThreadPool();
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(8890);
            while (true) {
                Socket socket = serverSocket.accept();
                mThreadPool.execute(new TCPRunnable(socket));
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (serverSocket != null) {
                    serverSocket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

使用執行緒池好像很完美,但是現在再思考一個總是,假如客戶端希望與伺服器保持一個長連線,那麼很顯然執行緒池也限制了客戶端併發訪問的數量,因為核心執行緒就那麼幾個。 那麼可不可以增大執行緒池中的核心執行緒數量呢? 可以是可以,但是要增大多少呢?面對數以百萬計的客戶端,你選擇不了!而且增大執行緒數量,只會帶來更大的執行緒開銷,包括執行緒排程以及上下文切換。 同時,我們還要面對一個總是,那就是多執行緒臨界資源的訪問,我們需要同步或者加鎖,這些隱藏的開銷是開發者無法控制的。

Java NIO 的到來解決了這些問題,並且可以讓伺服器同時處理上千個客戶端,而且還可以保持良好的效能。那麼本文就探討下 NIO 到底強在哪裡。

Channel

NIO 使用通道 (Channel) 來傳送和接收資料,而不使用傳統的流 (InputStream/OutputStream)。

Channel 例項代表了開啟一個實體的連線,這些實體包括硬體裝置,檔案,網路套接字等等。 Channel 有個特色,在 Channel 上的操作,例如讀寫,都是執行緒安全的。

SelectableChannel

SelectableChannel 是一個抽象類,它實現了 Channel 介面,這個類比較特殊。

首先 SelectableChannel 可以是阻塞或者非阻塞模式。如果是阻塞模式,在這個通道上的任何 I/O 操作都是阻塞的直到 I/O 完成。 而如果是非阻塞模式,任何在這個通道上的 I/O 都不會阻塞,但是傳輸的位元組數可能比原本請求的位元組數要少,甚至一個也沒有。

其次呢 SelectableChannel 可以被 Selector 用來多路複用,不過首先需要呼叫 selectableChannel.configureBlocking(false) 調整為非阻塞模式(nonblocking mode),這一點很重要。然後進行註冊

SelectionKey register(Selector sel, int ops)
SelectionKey register(Selector sel, int ops, Object att)
複製程式碼

第一個引數代表要註冊的 Selector 例項。關於 Selector 後面再講。

第二個引數代表本通道感興趣的操作,這些都定義在 SelectionKey 類中,如下

public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;
複製程式碼

對於 SocketChannel ,它感興趣的操作只有 OP_READ, OP_WIRTEOP_CONNECT,然而它並不包括 OP_ACCEPT。 而 ServerSocketChannel可以對這四個操作都感興趣。為何?因為只有 ServerSocketChannelaccpet() 方法。

SocketChannelServerSocketChannel 都是 SelectableChannel 的子類。

第三個引數 Object att 是註冊時的附件,也就是可以在註冊的時候帶點什麼東西過去。

register() 方法會返回一個 SelectionKey 例項。SelectionKey 相當於一個 Java Bean,其實就是 register() 的三個引數的容器,它可以返回和設定這些引數

Selector selector();
int interestOps();
Object attachment()
複製程式碼

SocketChannel

SocketChannel 代表套接字通道(socket channel)。

SocketChannel 例項是通過它的靜態的方法 open() 建立的

    public static SocketChannel open() throws IOException {
        return SelectorProvider.provider().openSocketChannel();
    }

    public static SocketChannel open(SocketAddress remote)
        throws IOException
    {
        // 1. ceate socket channel
        SocketChannel sc = open();
        try {
            // 2. connect channel's socket, blocking until connected or error
            sc.connect(remote);
        } catch (Throwable x) {
            try {
                sc.close();
            } catch (Throwable suppressed) {
                x.addSuppressed(suppressed);
            }
            throw x;
        }
        assert sc.isConnected();
        return sc;
    }    
複製程式碼

open() 方法僅僅是建立一個 SocketChannel 物件,而 open(SocketAddress remote) 就更進一步,它還呼叫了 connect(addr) 來連線伺服器。

SocketChannelSelectableChannel 的子類,還記得前面 SelectableChannel 的特性嗎?如果不配置阻塞模式,那麼 SocketChannel 物件預設就是阻塞模式,那麼 open(SocketAddress remote) 方法其實就是阻塞式開啟伺服器連線。而且在 SocketChannel 上任何 I/O 操作都是阻塞式的。

那麼既然 SelectableChannel 可以在非阻塞模式下的任何 I/O 操作都不阻塞,那麼我們可以先呼叫無參的 open() 方法,然後再配置為非阻塞模式,再進行連線,而這個連線就是非阻塞式連線,虛擬碼如下

// 建立 SocketChannel 例項
SocketChannel sc = SocketChannel.open();
// 調整為非阻塞模式
sr.configureBlocking(false);
// 連線伺服器
sr.connect(remoteAddr);
複製程式碼

此時的 connect() 方法是非阻塞式的,我們可以通過 isConnectionPending() 方法來查詢是否還在連線中,如果還在連線中我們可以做點其它事,而不用像建立 Socket 一樣一起阻塞走到連線建立,在這裡我們可以看到使用 NIO 的好處了。

如果 isConnectionPending() 返回了 false,那就代表已經建立連線了,但是我們還要呼叫 finishConnect() 來完成連線,這點需要注意。

用 SocketChannel 實現客戶端

public class NonBlockingTCPClient {
    public static void main(String[] args) {
        byte[] data = "hello".getBytes();
        SocketChannel channel = null;
        try {
            // 1. open a socket channel
            channel = SocketChannel.open();
            // adjust to be nonblocking
            channel.configureBlocking(false);
            // 2. init connection to server and repeatedly poll with complete
            // connect() and finishConnect() are nonblocking operation, both return immediately
            if (!channel.connect(new InetSocketAddress(InetAddress.getLocalHost(), 8899))) {
                while (!channel.finishConnect()) {
                    System.out.print(".");
                }
            }

            System.out.println("Connected to server...");

            ByteBuffer writeBuffer = ByteBuffer.wrap(data);
            ByteBuffer readBuffer = ByteBuffer.allocate(data.length);
            int totalBytesReceived = 0;
            int bytesReceived;
            // 3. read and write bytes
            while (totalBytesReceived < data.length) {
                if (writeBuffer.hasRemaining()) {
                    channel.write(writeBuffer);
                }
                if ((bytesReceived = channel.read(readBuffer)) == -1) {
                    throw new SocketException("Connection closed prematurely");
                }
                totalBytesReceived += bytesReceived;
                System.out.print(".");
            }
            System.out.println("Server said: " + new String(readBuffer.array()));
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 4 .close socket channel
            try {
                if (channel != null) {
                    channel.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

第一步,建立 SocketChannel 例項,並配置為非阻塞模式,只有在非阻塞模式下,任何在 SocketChannel 例項上的 I/O 操作才是非阻塞的。這樣我們的客戶端就是一個非阻塞式客戶端,也就可以提升客戶端效能。

第二步,用 connect() 方法連線伺服器,同時用 while 迴圈不斷檢測並完全連線。 其實我們可以不用這樣盲等,這裡只是為了演示連線的過程。 當你在需要馬上進行 I/O 操作前,必須要用 finishConnect() 完成連線過程。

第三步,用 ByteBuffer 讀寫位元組,這裡我們為何和一個 while 迴圈不斷地讀寫呢? 還記得前面講 SelectableChannel 非阻塞時的特性嗎? 如果一個 SelectableChannel 為非阻塞模式,它的 I/O 操作讀寫的位元組數可能比實際的要少,甚至沒有。 所以我們這裡用迴圈不斷的讀寫,保證讀寫完成。

官方對 SocketChannel.write() 有一段話是這樣說的: A socket channel in non-blocking mode, for example, cannot write any more bytes than are free in the socket's output buffer.

ServerSocketChannel

ServerSocketChannel 類代表伺服器端套接字通道(server-socket channel)。

ServerSocketChannelSocktChannel 一樣,需要通過靜態方法 open() 來建立一個例項,建立後,還需要通過 bind() 方法來繫結到本地的 IP 地址和埠

ServerSocketChannel bind(SocketAddress local)
ServerSocketChannel bind(SocketAddress local, int limitQueue)
複製程式碼

引數 SocketAddress local 代表本地 IP 地址和埠號,引數 int limitQueue 限制了連線的數量。

Selector

SelectorSelectableChannel 的多路複用器,可以用一個 Selector 管理多個 SelectableChannel。例如,可以用 Selector 在一個執行緒中管理多個 ServerSocketChannel,那麼我們就可以在單執行緒中同時監聽多個埠的請求,這簡直是美不可言。 從這裡我們也可以看出使用 NIO 的好處。

建立 Selector 例項

Selector 例項也需要通過靜態方法 open() 建立。

註冊 SelectableChannel

前面說過,我們需要呼叫 SelectableChannelregister() 來向 Selector 註冊,它會返回一個 SelctionKey 來代表這次註冊。

選擇通道

前面說過,可以通過 Selector 管理多個 SelectableChannel,它的 select() 方法可以監測哪些通道已經準備好進行 I/O 操作了,返回值代表了這些 I/O 的數量。

int select()
int select(long timeout)
int selectNow()
複製程式碼

當呼叫 select() 方法後,它會把代表已經準備好 I/O 操作的通道的 SelectionKey 儲存在一個集合中,可以通過 selectedKeys() 返回。

Set<SelectionKey> selectedKeys()
複製程式碼

select() 的三個方法,從命名就可以看出這幾個方法的不同之處,第一個方法是阻塞式呼叫,第三個方法設定了一個超時時間,第三個方法是立即返回。

wakeUp()

如果呼叫 selcet() 方法會導致執行緒阻塞,甚至無限阻塞,wakeUp() 方法是喚醒那些呼叫 select() 方法而處於阻塞狀態的執行緒。

使用 Selector 和 ServerSocketChannel 實現伺服器

package com.ckt.sockettest;


import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class TCPChannelServer {
    public static void main(String[] args) {
        Selector selector = null;
        try {
            // 1. open a selector
            selector = Selector.open();
            // 2. listen for server socket channel
            ServerSocketChannel ssc = ServerSocketChannel.open();
            // must to be nonblocking mode before register
            ssc.configureBlocking(false);
            // bind server socket channel to port 8899
            ssc.bind(new InetSocketAddress(8899));
            // 3. register it with selector
            ssc.register(selector, SelectionKey.OP_ACCEPT);

            while (true) { // run forever
                // 4. select ready SelectionKey for I/O operation
                if (selector.select(3000) == 0) {
                    continue;
                }
                // 5. get selected keys
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                // 6. handle selected key's interest operations
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();

                    if (key.isAcceptable()) {
                        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
                        // get socket channel from server socket channel
                        SocketChannel clientChannel = serverSocketChannel.accept();
                        // must to be nonblocking before register with selector
                        clientChannel.configureBlocking(false);
                        // register socket channel to selector with OP_READ
                        clientChannel.register(key.selector(), SelectionKey.OP_READ);
                    }

                    if (key.isReadable()) {
                        // read bytes from socket channel to byte buffer
                        SocketChannel clientChannel = (SocketChannel) key.channel();
                        ByteBuffer readBuffer = ByteBuffer.allocate(10);
                        int readBytes = clientChannel.read(readBuffer);
                        if (readBytes == -1) {
                            System.out.println("closed.......");
                            clientChannel.close();
                        } else if (readBytes > 0) {
                            String s = new String(readBuffer.array());
                            System.out.println("Client said: " + s);
                            if (s.trim().equals("Hello")) {
                                // attachment is content used to write
                                key.interestOps(SelectionKey.OP_WRITE);
                                key.attach("Welcome!!!");
                            }
                        }
                    }

                    if (key.isValid() && key.isWritable()) {
                        SocketChannel clientChannel = (SocketChannel) key.channel();
                        // get content from attachment
                        String content = (String) key.attachment();
                        // write content to socket channel
                        clientChannel.write(ByteBuffer.wrap(content.getBytes()));
                        key.interestOps(SelectionKey.OP_READ);
                    }

                    // remove handled key from selected keys
                    iterator.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // close selector
            if (selector != null) {
                try {
                    selector.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
複製程式碼

第一步,建立 Selector 例項。

第二步,建立 ServerSocketChannel 例項,配置為非阻塞模式,繫結本地埠。

第三步,把 ServerSocketChannel例項 註冊到 Selector 例項中。

第四步,選擇一些準備好 I/O 操作的通道,這裡設定了3秒超時時間,也就是阻塞3秒。

第五步,獲取選中的 SelectionKey 的集合。

第六步,處理 SelectionKey 的感興趣的操作。註冊到 selector 中的 serverSocketChannel 只能是 isAcceptable() ,因此通過它的 accept() 方法,我們可以獲取到客戶端的請求 SocketChannel 例項,然後再把這個 socketChannel 註冊到 selector 中,設定為可讀的操作。那麼下次遍歷 selectionKeys 的時候,就可以處理那麼可讀的操作。

總結

通過三篇文章,概要性的描述了 Java Socket 的輪廓。 然而我在實際的工作中並沒有接觸這方面內容,因此這三篇文章只是膚淺的入門,如果日後有機會深入學習,再來改善這些文章內容。

相關文章