Java IO學習筆記六:NIO到多路複用

Grey Zeng 發表於 2021-06-17
Java

作者:Grey

原文地址:Java IO學習筆記六:NIO到多路複用

雖然NIO效能上比BIO要好,參考:Java IO學習筆記五:BIO到NIO

但是NIO也有問題,NIO服務端的示例程式碼中往往會包括如下程式碼:即:遍歷所有的SocketChannel,獲取能讀寫資料的客戶端,當客戶端數量非常多的時候,服務端要輪詢所有連線的客戶端拿資料(recv呼叫),很多呼叫是無意義的,這樣會導致頻繁的使用者態切換成核心態,導致效能變差。

....
//遍歷已經連結進來的客戶端能不能讀寫資料
            for (SocketChannel c : clients) {  
                int num = c.read(buffer); 
                if (num > 0) {
                    buffer.flip();
                    byte[] aaa = new byte[buffer.limit()];
                    buffer.get(aaa);

                    String b = new String(aaa);
                    System.out.println(c.socket().getPort() + " : " + b);
                    buffer.clear();
                }
            }
...

多路複用技術可以解決NIO的這個問題,多個IO通過一個系統呼叫獲得其中的IO狀態,然後由程式對有狀態的IO進行讀寫操作。在Linux系統中,多路複用的實現有:

  • 基於POSIX標準的SELECT
  • POLL (select只支援最大fd < 1024,如果單個程式的檔案控制程式碼數超過1024,select就不能用了。poll在介面上無限制)
  • EPOLL

其中SELECT和POLL類似,但是有一些區別,參考select和poll的區別

無論NIO,SELECT還是POLL,都是要遍歷所有IO,詢問狀態,只不過遍歷這件事到底是核心來做還是應用程式來做而已。

而epoll,可以看成是SELECT和POLL的增強,在呼叫select/poll時候,都需要把fd集合從使用者態拷貝到核心態,但是epoll呼叫epoll_ctl時拷貝進核心並儲存,之後每次epoll_wait不做拷貝,而且epoll採用的是事件通知方式,每當fd就緒,系統註冊的回撥函式就會被呼叫,將就緒fd放到rdllist裡面。時間複雜度O(1)。

更多內容可以參考:

Java的Selector封裝了底層epoll和poll的API,可以通過指定如下引數來呼叫執行的核心呼叫, 在Linux平臺,如果指定

-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.PollSelectorProvider

則底層呼叫poll,

指定為:

-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider

或者不指定,則底層呼叫epoll

原始碼參考:jdk8u-jdk

image

接下來,我們使用一套服務端程式碼,在Linux伺服器上執行,分別指定底層用epoll和poll,並用strace來追蹤其核心呼叫。

準備服務端程式碼:

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

public class SocketMultiplexingV1 {

    private Selector selector = null;
    int port = 9090;

    public void initServer() {
        try {
            ServerSocketChannel server = ServerSocketChannel.open();
            server.configureBlocking(false);
            server.bind(new InetSocketAddress(port));
            selector = Selector.open();
            server.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start() {
        initServer();
        System.out.println("伺服器啟動了。。。。。");
        try {
            while (true) {
                Set<SelectionKey> keys = selector.keys();
                System.out.println(keys.size() + "   size");
                while (selector.select() > 0) {
                    //返回的有狀態的fd集合
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    Iterator<SelectionKey> iter = selectionKeys.iterator();
                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        iter.remove();
                        if (key.isAcceptable()) {
                            acceptHandler(key);
                        } else if (key.isReadable()) {
                            readHandler(key);
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void acceptHandler(SelectionKey key) {
        try {
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            SocketChannel client = ssc.accept();
            client.configureBlocking(false);
            ByteBuffer buffer = ByteBuffer.allocate(8192);
            client.register(selector, SelectionKey.OP_READ, buffer);
            System.out.println("-------------------------------------------");
            System.out.println("新客戶端:" + client.getRemoteAddress());
            System.out.println("-------------------------------------------");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void readHandler(SelectionKey key) {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        buffer.clear();
        int read;
        try {
            while (true) {
                read = client.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    while (buffer.hasRemaining()) {
                        client.write(buffer);
                    }
                    buffer.clear();
                } else if (read == 0) {
                    break;
                } else {
                    client.close();
                    break;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();

        }
    }

    public static void main(String[] args) {
        SocketMultiplexingV1 service = new SocketMultiplexingV1();
        service.start();
    }
}

和服務端程式碼在同一目錄下準備一個指令碼 SocketMultiplexingV1.sh

rm -rf ${1}*
/usr/local/jdk/bin/javac SocketMultiplexingV1.java
strace -ff -o $1 /usr/local/jdk/bin/java -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.${1}SelectorProvider SocketMultiplexingV1

執行:

./SocketMultiplexingV1 Poll

底層呼叫Poll

重新開啟一個控制檯,通過nc工具連線這個服務端

nc localhost 9090

服務端可以正常接收到連線

[[email protected] io]# ./SocketMultiplexingV1.sh Poll
伺服器啟動了。。。。。
1   size
-------------------------------------------
新客戶端:/0:0:0:0:0:0:0:1:39724
-------------------------------------------

暫時先不要傳送資料,此時,檢視服務端的程式:

[[email protected] io]# jps
1712 Jps
1659 SocketMultiplexingV1

檢視服務端目前關聯的檔案描述符

[[email protected] io]# lsof -p 1659
...
java    1659 root    4u  IPv6  25831       0t0       TCP *:websm (LISTEN)
...
java    1659 root    7u  IPv6  22508       0t0       TCP localhost:websm->localhost:39724 (ESTABLISHED)

其中4u為服務端監聽的Socket檔案描述符,7u為新連線進來的客戶端Socket檔案描述符。

通過nc客戶端給服務端傳送一些資料,客戶端也可以正常收到服務端返回的資料

[[email protected] io]# nc localhost 9090
sdfasdfasd
sdfasdfasd

接下來停掉服務端和客戶端, 檢視追蹤日誌

[[email protected] io]# ll
total 2444
-rwxr-xr-x. 1 root root     106 Jun 10 19:25 mysh.sh
-rw-r--r--. 1 root root    1714 Jun 12 16:35 OSFileIO.java
-rw-r--r--. 1 root root    9572 Jun 17 19:58 Poll.1659
-rw-r--r--. 1 root root  215792 Jun 17 19:58 Poll.1660
-rw-r--r--. 1 root root    1076 Jun 17 19:58 Poll.1661
-rw-r--r--. 1 root root     983 Jun 17 19:58 Poll.1662
-rw-r--r--. 1 root root     850 Jun 17 19:58 Poll.1663
-rw-r--r--. 1 root root     940 Jun 17 19:58 Poll.1664
-rw-r--r--. 1 root root     948 Jun 17 19:58 Poll.1665
-rw-r--r--. 1 root root     885 Jun 17 19:58 Poll.1666
-rw-r--r--. 1 root root     948 Jun 17 19:58 Poll.1667
-rw-r--r--. 1 root root    1080 Jun 17 19:58 Poll.1668
-rw-r--r--. 1 root root  124751 Jun 17 19:58 Poll.1669
-rw-r--r--. 1 root root    1245 Jun 17 19:58 Poll.1670
-rw-r--r--. 1 root root    1210 Jun 17 19:58 Poll.1671
-rw-r--r--. 1 root root    2416 Jun 17 19:58 Poll.1672
-rw-r--r--. 1 root root   27498 Jun 17 19:58 Poll.1673
-rw-r--r--. 1 root root   27326 Jun 17 19:58 Poll.1674
-rw-r--r--. 1 root root   27602 Jun 17 19:58 Poll.1675
-rw-r--r--. 1 root root   26866 Jun 17 19:58 Poll.1676
-rw-r--r--. 1 root root    1141 Jun 17 19:58 Poll.1677
-rw-r--r--. 1 root root 1953818 Jun 17 19:58 Poll.1678
-rw-r--r--. 1 root root    2204 Jun 17 19:58 Poll.1831
-rw-r--r--. 1 root root    3440 Jun 17 19:48 SocketMultiplexingV1.class
-rw-r--r--. 1 root root    3315 Jun 17 19:13 SocketMultiplexingV1.java
-rwxr-xr-x. 1 root root     199 Jun 17 19:19 SocketMultiplexingV1.sh

其中Poll.1678為主執行緒日誌, 我們一一看下整個呼叫過程

...
2535 socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 4
...
2793 bind(4, {sa_family=AF_INET6, sin6_port=htons(9090), inet_pton(AF_INE        T6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28)         = 0
2794 listen(4, 50)                           = 0
...

以上兩個呼叫對應了程式碼中建立Socket並繫結9090埠進行監聽這個邏輯。

...
2772 fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
...
2883 poll([{fd=5, events=POLLIN}, {fd=4, events=POLLIN}], 2, -1) = 1 ([{f        d=4, revents=POLLIN}])

以上呼叫對應了:

server.configureBlocking(false);

呼叫的poll方法表示一個新的檔案描述符4u有POLLIN(POLLIN:There is data to read)的事件

...
2893 accept(4, {sa_family=AF_INET6, sin6_port=htons(39724), inet_pton(AF_        INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0},         [28]) = 7

...

2935 poll([{fd=5, events=POLLIN}, {fd=4, events=POLLIN}, {fd=7, events=PO        LLIN}], 3, -1) = 1 ([{fd=7, revents=POLLIN}])


這裡說明接收了一個新的Socket連線,就是我們剛才用lsof看到的7u這個檔案描述符。,呼叫了poll方法,說明一個新的檔案描述符7u有POLLIN(POLLIN:There is data to read)的事件。

我們的程式碼中對於每次接收的客戶端,也會把客戶端設定為非阻塞,即:

client.configureBlocking(false);

對應的核心呼叫就是:

2926 fcntl(7, F_SETFL, O_RDWR|O_NONBLOCK)    = 0

以上就是poll呼叫對應核心函式的呼叫。

接下來切換成epoll模式,重新執行指令碼

[[email protected] io]# ./SocketMultiplexingV1.sh EPoll
伺服器啟動了。。。。。
1   size

用nc連線服務端

nc localhost 9090

服務端響應正常

[[email protected] io]# ./SocketMultiplexingV1.sh EPoll
伺服器啟動了。。。。。
1   size
-------------------------------------------
新客戶端:/0:0:0:0:0:0:0:1:39726
-------------------------------------------

通過nc傳送一些資料

[[email protected] io]# nc localhost 9090
asdfasd
asdfasd

也可以正常接收

接下來停掉服務端和客戶端,檢視主執行緒呼叫情況

[[email protected] io]# ll -h EPoll.*
-rw-r--r--. 1 root root 9.4K Jun 17 20:33 EPoll.2067
-rw-r--r--. 1 root root 212K Jun 17 20:33 EPoll.2068
-rw-r--r--. 1 root root 1.1K Jun 17 20:33 EPoll.2069
-rw-r--r--. 1 root root  983 Jun 17 20:33 EPoll.2070
-rw-r--r--. 1 root root  850 Jun 17 20:33 EPoll.2071
-rw-r--r--. 1 root root  983 Jun 17 20:33 EPoll.2072
-rw-r--r--. 1 root root  948 Jun 17 20:33 EPoll.2073
-rw-r--r--. 1 root root  983 Jun 17 20:33 EPoll.2074
-rw-r--r--. 1 root root  850 Jun 17 20:33 EPoll.2075
-rw-r--r--. 1 root root 1.1K Jun 17 20:33 EPoll.2076
-rw-r--r--. 1 root root  31K Jun 17 20:33 EPoll.2077
-rw-r--r--. 1 root root 1.4K Jun 17 20:33 EPoll.2078
-rw-r--r--. 1 root root 1.3K Jun 17 20:33 EPoll.2079
-rw-r--r--. 1 root root 2.4K Jun 17 20:33 EPoll.2080
-rw-r--r--. 1 root root 9.0K Jun 17 20:33 EPoll.2081
-rw-r--r--. 1 root root 8.7K Jun 17 20:33 EPoll.2082
-rw-r--r--. 1 root root 8.6K Jun 17 20:33 EPoll.2083
-rw-r--r--. 1 root root 8.2K Jun 17 20:33 EPoll.2084
-rw-r--r--. 1 root root 1.2K Jun 17 20:33 EPoll.2085
-rw-r--r--. 1 root root 400K Jun 17 20:33 EPoll.2086
-rw-r--r--. 1 root root 2.2K Jun 17 20:33 EPoll.2109

vi EPoll.2068

其中新建Socket,Bind 9090埠,設定非阻塞和Poll都是相同的呼叫

...
  2539 socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 4
....
 2776 fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
 ....
 2797 bind(4, {sa_family=AF_INET6, sin6_port=htons(9090), inet_pton(AF_INE        T6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28)         = 0
 2798 listen(4, 50)                           = 0
....

但是一旦有新的連線進來

···
2852 epoll_create(256)                       = 7

···

2862 epoll_ctl(7, EPOLL_CTL_ADD, 5, {EPOLLIN, {u32=5, u64=140462610448389        }}) = 0

···
2888 epoll_wait(7, [{EPOLLIN, {u32=4, u64=140462610448388}}], 4096, -1) =         1

···

epoll_create1: 建立一個epoll例項,檔案描述符
epoll_ctl: 將監聽的檔案描述符新增到epoll例項中,例項程式碼為將標準輸入檔案描述符新增到epoll中
epoll_wait: 等待epoll事件從epoll例項中發生, 並返回事件以及對應檔案描述符

呼叫epoll_create時,核心除了幫我們在epoll檔案系統裡建了個file結點,在核心cache裡建了個 紅黑樹 用於儲存以後epoll_ctl傳來的socket外,還會再建立一個list連結串列,用於儲存準備就緒的事件.

當epoll_wait呼叫時,僅僅觀察這個list連結串列裡有沒有資料即可。有資料就返回,沒有資料就sleep,等到timeout時間到後即使連結串列沒資料也返回。所以,epoll_wait非常高效。

原始碼:Github

參考資料:

深入理解 Epoll

Select、Poll、Epoll詳解

select和poll的區別