Java-NIO之Selector

前言:

關於Java的Selector,其實也沒什麼好說的。
說高級點就是就是多路復用。而多路復用是由於操作系統的支持,才能得以實現。

體悟:
Java代碼只是進行native 方法的調用。
核心代碼在C/C++寫的jdk源碼中。
而多路復用是OS系統(Linux/Windows/MacOS)內核得以支持的。
如果喜歡研究計算機內核、計算機組成原理就不要學Java了,C/C++ 更貼近操作系統(苦笑)。


我也不知道寫什麼,只能把這段時間看過的、學習過的東西記錄下來。


前篇

Selector API 淺談
①:創建selector

        selector = Selector.open();

②:創建channel

        channel = SocketChannel.open();

③:建立連接
服務端:

        // 綁定端口
        channel.socket().bind(new InetSocketAddress(this.ip, port));

客戶端:

        // 連接服務端(不是真正開始創建連接,要由selector進行事件消費)
        channel.connect(new InetSocketAddress(ip, port));

④:註冊事件
服務端:

        selectionKey = channel.register(selector, SelectionKey.OP_ACCEPT);

客戶端:

        selectionKey = channel.register(selector, SelectionKey.OP_CONNECT);

⑤:查詢就緒通道

        // 會持續阻塞,直至存在已就緒的channel
        int select = selector.select();
        Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
  • selectNow():不阻塞,無論是否有就緒的通道。
  • select(long timeout):阻塞一定時間,無論是否有就緒的通道。
  • select():阻塞至必須有一個及以上就緒的通道。

⑥:wakeup()

        /*
         * 若 selector 處於 select 阻塞中,
         * 此時新 register 一個事件是無法掃描到的,需要 wakeUp 一下阻塞線程,或重新進行 select 操作。
         */
        selector.wakeup();

場景:線程A進行調用select()進行阻塞,線程B註冊了一個寫事件,此時線程A是不能通過select立即拿到線程B註冊的寫事件的,需要線程B調用wakeUp使A重新進入下一輪的select()調用,下一輪線程A才能拿到新註冊的寫事件並進行消費。

⑦:close()
selector.close() 操作只會關閉selector,原本註冊的通道產生的SelectionKey會失效,但通道本身並不會關閉。

inode
什麼是inode(索引節點)?

理解inode,要從文件儲存說起。

文件儲存在硬盤上,硬盤的最小存儲單位叫做”扇區”(Sector)。每個扇區儲存512位元組(相當於0.5KB)。
操作系統讀取硬盤的時候,不會一個個扇區地讀取,這樣效率太低,而是一次性連續讀取多個扇區,即一次性讀取一個”塊”(block)。這種由多個扇區組成的”塊”,是文件存取的最小單位。”塊”的大小,最常見的是4KB,即連續八個 sector組成一個 block。

文件數據都儲存在”塊”中,那麼很顯然,我們還必須找到一個地方儲存文件的元信息,比如文件的創建者、文件的創建日期、文件的大小等等。這種儲存文件元信息的區域就叫做inode,中文譯名為”索引節點”。

每一個文件都有對應的inode,裏面包含了與該文件有關的一些信息。

image

參考:理解inode

file descriptor

什麼是file descriptor(文件描述符)?

Linux 系統中,把一切都看做是文件,當進程打開現有文件或創建新文件時,內核向進程返回一個文件描述符,文件描述符就是內核為了高效管理已被打開的文件所創建的索引,用來指向被打開的文件,所有執行I/O操作的系統調用都會通過文件描述符。

文件描述符和socket的關係:

socket的設計採用了和UNIXI/O一樣的思路,即把socket看成一個文件,socket創建完成後,返迴文件描述符,然後使用read、write發送和接收數據,最後關閉socket。

文件描述符和inode的關係:
image
最終是一個映射關係。

參考:文件描述符fd(File Descriptor)簡介

IO multiplexing

常見的IO模型有四種:

  • Blocking IO
    既傳統的同步阻塞IO。

  • NON-Blocking IO
    默認創建的socket都是阻塞的,非阻塞IO要求socket被設置為NONBLOCK。注意這裡所說的NIO並非Java的NIO(New IO)庫。

  • IO Multiplexing
    即經典的Reactor設計模式,有時也稱為異步阻塞IO,Java中的Selector和Linux中的epoll都是這種模型。

  • Asynchronous IO
    即經典的Proactor設計模式,也稱為異步非阻塞IO。


不過有大佬說把IO分成上述四種並不好。

drawing

參考:深入理解 epoll


論channel、socket、selector和文件描述符的關係

1:無論SocketChannel還是ServerSocketChannel,進行連接都需要打開套接字(socket)並獲取文件描述符(file descriptor)。
以ServerSocketChannel的實現類為例:

class ServerSocketChannelImpl extends ServerSocketChannel implements SelChImpl {
    private static NativeDispatcher nd;
    // 文件描述符
    private final FileDescriptor fd;
    private int fdVal;
    // 省略部分屬性 ...

    // 套接字
    ServerSocket socket;
}

2:每個selector上可以註冊多個 channel並綁定讀、寫、連接、接收事件。
3:channel也可以在多個selector上進行註冊。

    @Test
    public void selector() throws IOException {
        Selector selector = Selector.open();
        for (int i = 0; i < 3; i++) {
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.configureBlocking(false);
            ssc.bind(new InetSocketAddress("127.0.0.1", 8360 + i));
            ssc.register(selector, SelectionKey.OP_ACCEPT);
        }
        System.out.println("over");
    }

    @Test
    public void channel() throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.bind(new InetSocketAddress("127.0.0.1", 8366));
        for (int i = 0; i < 3; i++) {
            Selector selector = Selector.open();
            ssc.register(selector, SelectionKey.OP_ACCEPT);
        }
        System.out.println("over");
    }

根據操作系統的不同,最終選擇的Selector也會有不同,這決定了該使用select、poll、epoll、kqueue中的哪種作為最終實現。

  • Windows

selector的實現類為:WindowsSelectorProvider。最終實現看源碼應該是poll。
drawing

  • MacOS

selector的實現類為:KQueueSelectorProvider。最終實現看源碼應該是kqueue/kevent。
drawing

  • Linux/SunOS

Linux選擇的selector的實現類為:EPollSelectorProvider。最終實現看源碼應該是epoll。
drawing

看的出來,其他操作系統默認使用poll來實現多路復用。

後篇

IO multiplexing(多路復用)的發展史:

1983,socket 發佈在 Unix(4.2 BSD)

1983,select 發佈在 Unix(4.2 BSD)

1994,Linux的1.0,已經支持socket和select

1997,poll 發佈在 Linux 2.1.23

2000,FreeBSD 4.1 中首次引入了 kqueue。
隨後也被 NetBSD、OpenBSD、macOS 等操作系統支持。kqueue 是一種可擴展的事件通知接口。

2002,epoll發佈在 Linux 2.5.44

select、poll、epoll、kqueue 都是操作系統對多路復用的一種實現,並提供一個外部接口供操作系統以外的程序進行調用。


論IO multiplexing(多路復用)的優勢
我們先要知道傳統的BIO會有什麼劣勢,才能更好的理解這個問題。
傳統的BIO 無論是讀、寫、連接、監聽 都是阻塞的,這意味這我們打開一個socket如果要同時監聽讀、寫事件,我們需要同時分別為讀、寫開啟一個線程去進行監聽。

比如客戶端開啟一個socket,要先進行 connect,當然connect我們可以同步,等connect處理完畢後需要為 read 和 write 分別新開一個線程去監聽它們。
image
這種模式在客戶端可能還好,但是如果是服務端那就是災難性的了,服務端哪有那麼多資源來開啟這麼多線程?
那麼如果此時服務端有個東西能把創建連接的客戶端socket給是「收集起來」,用戶程序只要調用一個api就可以知道是否有可讀或可寫的socket,那麼就可以解決這個問題,於是IO多路復用這個「好玩意」就誕生了。


論IO multiplexing(多路復用)的原理

以windows為例:
drawing

  • select
  1. 應用程序調用select接口,select線程會阻塞調用它的線程。
  2. kernel 內核使用 輪詢 的方式檢查所有select關注的文件描述符。
  3. 當存在數據準備好的文件描述符,select 可以拿到就緒的文件描述符個數,但無法知道哪些文件描述符已就緒,因此需要將之前關注的fd_set從內核拷貝到進程的緩衝區(內核態到用戶態)。
  4. 應用程序通過遍歷fd_set(結構相當於bitset),拿到準備就緒的fd。

時間複雜度:O(n)。
缺點:

  1. 進行可打開的fd有限制(32操作系統是1024個,64位是64個)(因為存儲fd是一個固定大小的數據)。
  2. 輪詢的效率較低。
  3. 從內核態拷貝到用戶態開銷較大。
  • poll

poll和select的工作機制長不多,主要有以下幾點 不同:

  1. 採用鏈表結構存儲fd,因此可監聽的文件描述符數量為系統可以打開的最大文件描述符數量(65535),且poll相比select不會修改文件描述符。
  2. poll相比於select 提供了更多事件類型,且對文件描述符的利用率比select高。
  • epoll

在jdk/src/solaris/classes/sun/nio/ch/EPollArrayWrapper.java文件中epoll提供了三個api:

    private native int epollCreate();
    private native void epollCtl(int epfd, int opcode, int fd, int events);
    private native int epollWait(long pollAddress, int numfds, long timeout, int epfd) throws IOException;

然而上述三個api並不是我們要的,哈哈哈哈~~~~
即使是jdk的c++代碼,仍然不是我們想要的:
drawing

我們要的代碼應該是EPollArrayWrapper.c文件頭中聲明的epoll.h文件的代碼:
drawing

那我們應該如何找到這個epoll.h代碼呢,我在openjdk8的源代碼中並沒有找到這個文件。
沮喪的百度了一圈後,發現epoll.h文件應該存在於glibc中。
地址://github.com/bminor/glibc/blob/glibc-2.4/sysdeps/unix/sysv/linux/sys/epoll.h

    // 創建一個epoll示例,並返回這個新實例的文件描述符(實際是文件描述符的指針)。
    //(通過文件描述符找到被打開的文件(套接字/socket))。
    extern int epoll_create (int __size) __THROW;

    // 將需要監聽的fd和事件event交給epoll。成功返回1,異常返回-1。
    extern int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event) __THROW;

    // 阻塞以等待註冊的是事件被觸發(存在已就緒的文件描述符)或直至timeout發生。
    // 返回觸發的事件數量,異常則返回-1。
    extern int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);

以上代碼來自於GitHub開源的 openJDK8glibc2.4 代碼庫。


epoll的工作流程:

  1. 調用 epoll_create 會創建一個epoll實例,然後創建並維護 一顆紅黑樹rbr 和 一條**就緒隊列rbllist(鏈表結構)。
  2. 調用 epoll_ctl 將感興趣的event 和 fd 傳給epoll實例,然後進行事件掛載、註冊回調函數。內核會在紅黑樹上添加相應的節點。文件描述符(fd)就緒的時候會觸發回調函數,然後回調函數會將該就緒的fd假如當就緒隊列rbllist中。
  3. 調用 epoll_wait 會持續檢查就緒隊列中是否有準備就緒的fd,直至超時。若就緒隊列rbllist存在就緒的fd就複製到用戶和內核的 共享空間(減少不必要的拷貝),共享空間是 mmap結構(零拷貝的一種實現)。
  4. 用戶獲取到就緒的fd_set(不需要自己判斷哪些fd就緒,用戶空間拿到的就是準備就緒的fd_est)。

時序圖:
drawing

流程圖:
drawing


相比於select和poll、epoll 做了哪些優化?

  1. epoll採用 回調機制,棄用 輪詢 的方式去檢測就緒的文件描述符。用戶程序拿到的fd_set全部都是準備就緒的fd,不用像select一樣自己去輪詢fd_set,減少了對文件描述符的遍歷。時間複雜度O(1)

  2. 減少了 用戶態(user space)和 內核態(kernel space) 之間文件描述符的拷貝。

  3. select和poll 只支持 LT模式,而epoll還支持更高級的 ET模式,並且還支持EPOLLONESHOT事件。


epoll為什麼用紅黑樹?
先開開紅黑樹節點屬性:

struct epitem {
  ...
  //紅黑樹節點
  struct rb_node rbn;
  //雙向鏈表節點
  struct list_head rdllink;
  //事件句柄等信息
  struct epoll_filefd ffd;
  //指向其所屬的eventepoll對象
  struct eventpoll *ep;
  //期待的事件類型
    /* The structure that describe the interested events and the source fd */
  struct epoll_event event;
   //下一個epitem實例
   struct epitem *next;  ...
}; // 這裡包含每一個事件對應着的信息。

代碼地址://github.com/torvalds/linux/blob/master/fs/eventpoll.c

參考:linux源碼解讀(十七):紅黑樹在內核的應用——epoll

我的個人小結:
事件註冊時,需要插入到 紅黑樹中(中間觀察者)。而節點保存了fd信息和epoll_event信息,當註冊事件發生變化時,可以通過fd找到應該響應的socket。
這個過程包含插入(事件掛載)、查詢(事件回調)的過程,而紅黑樹的特性,查詢上略遜色於AVL樹,而插入上因為不需要保證絕對平衡所以又略優於AVL樹,因此是一個比較折中的方案。

我們也可以不關心具體用什麼數據結構實現這個「中間觀察者」角色:

epoll_create 底層實現,到底是不是紅黑樹,其實也不太重要(完全可以換成 hashtable)。重要的是 efd 是個指針,其數據結構完全可以對外透明的修改成任意其他數據結構。


LT模式和ET模式

LT模式:
當epoll_wait()檢查到描述符事件到達時,將此事件通知進行,進程可以不立即處理該事件,下次調用epoll_wait()會再次通知進程。
是默認的一種模式,並且同時支持Blocking和No-Blocking。

ET模型:
和LT模式不同的是,通知之後進程必須立即處理事件,下次再調用epoll_wait()時不會再得到事件到達的通知。
ET模式很大程度上減少了epoll事件被重複觸發的次數,因此效率比LT模式要高。只支持No-Blocking,以避免由於一個文件句柄的阻塞讀/阻塞寫操作把處理多個文件描述符的任務餓死。


select、poll及epoll分別在什麼場景下比較合適?

epoll雖然優勢最多,但也不是每個場景下都能勝任的。

  • select
    select的timeout參數精度為1 ns,比poll和epoll的1ms 實時性更高,因此適合實時性要求高的場景,且select的可移植性比較好。

  • poll
    poll 沒有最大描述符數量限制(取決於系統可支持開啟的最大數量),因此實時性要求不高的場景應該使用poll。

  • epoll
    適合於 linux平台、開啟描述符的數量較大、描述符活躍度低的場景(如長連接、事件響應率低)下最適合。
    原因如下:
    如果描述符開啟數量小、活躍度高,那麼輪詢也能有比較好的效果,體現不出epoll的優勢。
    ①:epoll依賴於系統回調機制,如果連接活躍度高會造成epoll_ctl() 調用頻繁,頻繁的系統調用降低了效率。
    ②:epoll的fd_set存儲的內核中,不易於調試。


本文參考:

  1. select、poll、epoll的原理與區別20
  2. select,poll及epoll區別
  3. epoll 為什麼用紅黑樹?

本篇文章權屬個人理解所談,完全是站在巨人的肩膀上看世界。若有不對的地方歡迎交流指正,感謝!

Tags: