Java-NIO之Selector

竹根七 發表於 2022-06-23
Java

前言:

關於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中。
地址:https://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;  ...
}; // 這裡包含每一個事件對應著的資訊。

程式碼地址:https://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 為什麼用紅黑樹?

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