天天看點

NIO多路複用底層原理(Select、Poll、EPoll)一、NIO概述二、檔案描述符三、Select、Poll、EPoll四、總結

NIO

  • 一、NIO概述
    • 1. BIO
    • 2. NIO
  • 二、檔案描述符
  • 三、Select、Poll、EPoll
    • 1. Select
    • 2. Poll模型
    • 3. EPoll模型
  • 四、總結
    • 1. 大緻過程如下:

一、NIO概述

1. BIO

        BIO,即Blockig IO,阻塞IO,一個線程對應一個連接配接,如果你的伺服器有很多使用者,每個使用者都需要與你的伺服器建立一個連接配接,那麼你有多少使用者,你的伺服器就得建立多少個線程,顯然是不顯示的,而且每個線程是阻塞的,隻要你連接配接上,我目前的線程就會等着用戶端發送資料,然後處理,如果用戶端沒斷開連接配接,也沒發送資料,那服務端的線程就會一直等待,資源被浪費。其模型如下

NIO多路複用底層原理(Select、Poll、EPoll)一、NIO概述二、檔案描述符三、Select、Poll、EPoll四、總結

2. NIO

        NIO一種說法叫 New IO,另一種說法叫Non Blockig IO即非阻塞IO,它是JDK1.4引入的,與BIO不同的是,它是一個線程可以顧及多個連接配接,在這裡引入了IO多路複用模型,多個連接配接注冊到同一個多路複用器,然後多路複用器輪詢各個連接配接,有收發資料的就去處理,其模型如下

NIO多路複用底層原理(Select、Poll、EPoll)一、NIO概述二、檔案描述符三、Select、Poll、EPoll四、總結

二、檔案描述符

        本篇文章的NIO是基于Linux的,盡可能淺顯易懂,但核心的檔案描述符關鍵詞不能忽略,首先給大家介紹一下什麼是檔案描述符:

        檔案描述符是計算機科學中的一個術語,是一個指向檔案的引用的抽象化概念。它往往隻适用于Unix、Linux這種系統。直白地說,在Linux系統下,一切皆檔案,我們不管有什麼操作,都離不開對檔案的讀寫,而檔案描述符實際上就是一個索引值,它會指向一個檔案,這個檔案維護了程序對檔案操作的記錄,說白了,看着這個描述符指向的檔案,我就知道我下一步要讀寫哪個檔案了。

三、Select、Poll、EPoll

1. Select

        伺服器端的一個線程處理用戶端的多個連接配接請求,解決了BIO一個線程對應一個連接配接請求的問題,其底層依賴于作業系統的核心,這裡的核心為多路複用器,這個多路複用器會管理所有用戶端的連接配接,一旦有資料收發事件觸發,便會去輪詢所有連接配接,執行相應業務邏輯,那麼,我們想一下,如果你用戶端有100個連接配接,實際有收發資料的隻有5個,那麼輪詢100個,是不是有大量無效循環呢?當然這是JDK1.4剛出BIO時的多路複用器的實作方式,采用的select 模型,而且,當時限制用戶端隻能有1024個連接配接,即多路複用器管理的集合最大為1024,那麼我有超過1024個使用者來連接配接你的服務端,卻發現連不上?你認為合理嗎?如果你的是遊戲伺服器,你認為還會有人玩嗎?

        實際上,在作業系統底層,調用了

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

函數, 這是由作業系統提供的,其中的

fd_set

為檔案描述符的集合,而與服務端建立了連接配接後都會有一個檔案描述符存放在這個集合,這個函數會周遊這個集合,以處理有讀寫需求的連接配接

2. Poll模型

        後來又對多路複用器做了優化,底層采用poll 模型,去掉了1024的限制,理論上來說不限個數,但其餘的實作方式一樣,那麼又有新的問題出現,如果我用戶端有一百萬連接配接,那麼隻有1000個連接配接有收發,那麼我卻要周遊100萬個,而如果有收發資料的恰好在集合最末尾,也就是集合周遊到最後才會執行,那麼對于用戶端來講意味着什麼呢?

Poll模型與Select模型沒什麼大差別,本質問題沒有解決,還是會有很多無效循環,在量大的情況下,非常影響性能

3. EPoll模型

        在JDK1.5,對NIO又進行了一次優化,底層采用了EPoll模型,上圖即為采用EPoll 實作的NIO模型,當我們建立多路複用器時,會調用作業系統提供的函數

epoll_create

,并生成一個epfd,即epoll檔案描述符,這個描述符指向的檔案所存儲的是注冊到的多路複用器的事件的檔案描述符

        其執行原理: EPoll是基于事件驅動模型,就跟Java的swt一樣,隻不過在這裡的事件并不是人為觸發的,而是由作業系統感覺的,比如,目前你的伺服器有一個新的連接配接請求發過來,先按照類型将其注冊到多路複用器上,然後核心感覺到這個請求,采用中斷将其檔案描述符就緒事件隊列當中,後續

epoll_wait

會處理,比如連接配接好以後便通過

epoll_ctl

注冊該連接配接的讀事件,當該通道有讀請求時,依舊由作業系統核心感覺并中斷,把它的檔案描述符賦複制到epfd中,然後

epoll_wait

處理後續。

實際上,EPoll模型的底層核心函數有三個:

  1. int epoll_create(int size)

該函數生成一個epoll專用的檔案描述符。它其實是在核心申請一空間,用來存放你想關注的socketChannel fd上是否發生以及發生了什麼事件。size就是你在這個epoll fd上能關注的最大socketChannel fd數。随你定好了。隻要你有空間。
  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

該函數用于控制某個epoll檔案描述符上的事件,可以注冊事件,修改事件,删除事件。

參數:

epfd: 由 epoll_create生成的epoll專用的檔案描述符;

op: 要進行的操作例如注冊事件,可能的取值EPOLL_CTL_ADD 注冊、EPOLL_CTL_MOD

修改、EPOLL_CTL_DEL 删除

fd: 關聯的檔案描述符; event:指向epoll_event的指針;如果調用成功傳回0,不成功傳回-1

  1. int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout)

該函數用于輪詢I/O事件的發生;

參數:

epfd: 由epoll_create 生成的epoll專用的檔案描述符;

epoll_event: 就緒事件清單,此清單由作業系統提供 maxevents: 每次能處理的事件數;

timeout: 等待I/O事件發生的逾時值;-1相當于阻塞,0相當于非阻塞。一般用-1即可 傳回發生事件數。

        epoll_wait運作的原理是: 等待注冊在epfd上的socketChannel fd的事件的發生,如果發生則将發生的socketChannel fd和事件類型放入到events(圖中就緒事件清單)數組中。并且将注冊在epfd上的socketChannel fd的事件類型給清空(這裡是指多路複用器的另外一個集合),是以如果下一個循環你還要關注這個socketChannel fd的話,則需要用

epoll_ctl(epfd,EPOLL_CTL_MOD,listenfd,&ev)

來重新設定socketChannel fd的事件類型。這時不用

EPOLL_CTL_ADD

,因為socketChannel fd并未清空,隻是事件類型清空。這一步非常重要。

四、總結

        在Linux下,NIO底層就是用的EPoll模型,而selector的底層,是由Linux用C語言在系統級别幫我們建立了一個資料結構體,這個結構體就是EPoll執行個體,我們可以把它了解為Java對象,可以存取資料。

1. 大緻過程如下:

        1.程序先調用epoll的create,傳回一個epoll的fd; epoll通過mmap開辟一塊共享空間,增删改由核心完成,查詢核心和使用者程序都可以 這塊共享空間中有一個紅黑樹和一個連結清單

        2.程序調用epoll的ctl add/delete sfd,把新來的連結放入紅黑樹中,

                2.1程序調用wait(),等待事件(事件驅動)

        3.當紅黑樹中的fd有資料到了,就把它放入一個連結清單中并維護該資料可寫還是可讀,wait傳回;

        4.上層使用者空間(通過epoll)從連結清單中取出fd,然後調用read/write讀寫資料. 是以epoll也是NIO,不是AIO

NIO編寫服務端實作代碼

public static void main(String[] args) throws IOException {
    // 1. 擷取連接配接通道
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 2. 切換成非阻塞模式
    serverSocketChannel.configureBlocking(false);
    // 3. 綁定連結, 是端口
    serverSocketChannel.bind(new InetSocketAddress(8888));
    // 4. 擷取選擇器
    Selector selector = Selector.open();
    // 5. 将連接配接通道注冊到選擇器, 并注冊為接受連接配接事件
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
        // 6. select()為擷取選擇器中的就緒事件, 例如連接配接事件, 如果有連接配接請求
        // 則該事件就是就緒的, 如果一個事件都沒有, 那麼将阻塞在該行代碼
        selector.select();
        // 7.擷取所有就緒事件, 然後輪詢處理
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectionKeys.iterator();
        // 8.輪詢處理就緒事件
        while (iterator.hasNext()) {
            SelectionKey next = iterator.next();
            // 按照不同的監聽事件處理做相應的處理
            if (next.isAcceptable()) {
                // 8.1.1擷取到目前事件的連接配接
                ServerSocketChannel server = (ServerSocketChannel) next.channel();
                // 8.1.2接受目前用戶端的資料傳輸通道
                SocketChannel socketChannel = server.accept();
                // 8.1.3設定非阻塞
                if (socketChannel != null) {
                    socketChannel.configureBlocking(false);
                    // 8.1.4将目前資料傳輸通道注冊到選擇器, 并注冊為讀事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("用戶端連接配接成功了...");}
            } else if (next.isReadable()) {
                // 8.2.1擷取到目前事件的連接配接
                SocketChannel socketChannel = (SocketChannel) next.channel();
                ByteBuffer buffer = ByteBuffer.allocate(128);
                int len = socketChannel.read(buffer);
                if (len > 0) {
                    System.out.println(new String(buffer.array()));
                } else if (len == -1) {
                    System.out.println("用戶端斷開連接配接...");
                    socketChannel.close();
                }}}}
}