Java網絡編程和NIO詳解6:Linux epoll實現原理詳解

本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫裏查看

https://github.com/h2pl/Java-Tutorial

喜歡的話麻煩點下Star哈

文章將同步到我的個人博客:

www.how2playlife.com

本×××術江湖】的《不可輕視的Java網絡編程》其中一篇,本文部分內容來源於網絡,爲了把本文主題講得清晰透徹,也整合了很多我認爲不錯的技術博客內容,引用其中了一些比較好的博客文章,如有侵權,請聯繫作者。

該系列博文會告訴你如何從計算機網絡的基礎知識入手,一步步地學習Java網絡基礎,從socket到nio、bio、aio和netty等網絡編程知識,並且進行實戰,網絡編程是每一個Java後端工程師必須要學習和理解的知識點,進一步來說,你還需要掌握Linux中的網絡編程原理,包括IO模型、網絡編程框架netty的進階原理,才能更完整地瞭解整個Java網絡編程的知識體系,形成自己的知識框架。

爲了更好地總結和檢驗你的學習成果,本系列文章也會提供部分知識點對應的面試題以及參考答案。

如果對本系列文章有什麼建議,或者是有什麼疑問的話,也×××術江湖】聯繫作者,歡迎你參與本系列博文的創作和修訂。

<!-- more -->

爲什麼要 I/O 多路複用

當需要從一個叫 r_fd 的描述符不停地讀取數據,並把讀到的數據寫入一個叫 w_fd 的描述符時,我們可以用循環使用阻塞 I/O :

while((n = read(r_fd, buf, BUF_SIZE)) > 0)
    if(write(w_fd, buf, n) != n)
        err_sys("write error");

但是,如果要從兩個地方讀取數據呢?這時,不能再使用會把程序阻塞住的 read 函數。因爲可能在阻塞地等待 r_fd1 的數據時,來不及處理 r_fd2,已經到達的 r_fd2 的數據可能會丟失掉。

這個情況下需要使用非阻塞 I/O

只要做個標記,把文件描述符標記爲非阻塞的,以後再對它使用 read 函數:如果它還沒有數據可讀,函數會立即返回並把 errorno 這個變量的值設置爲 35,於是我們知道它沒有數據可讀,然後可以立馬去對其他描述符使用 read;如果它有數據可讀,我們就讀取它數據。對所有要讀的描述符都調用了一遍 read 之後,我們可以等一個較長的時間(比如幾秒),然後再從第一個文件描述符開始調用 read 。這種循環就叫做輪詢(polling)。

這樣,不會像使用阻塞 I/O 時那樣因爲一個描述符 read 長時間處於等待數據而使程序阻塞。

輪詢的缺點是浪費太多 CPU 時間。大多數時候我們沒有數據可讀,但是還是用了 read 這個系統調用,使用系統調用時會從用戶態切換到內核態。而大多數情況下我們調用 read,然後陷入內核態,內核發現這個描述符沒有準備好,然後切換回用戶態並且只得到 EAGAIN (errorno 被設置爲 35),做的是無用功。描述符非常多的時候,每次的切換過程就是巨大的浪費。

所以,需要 I/O 多路複用。I/O 多路複用通過使用一個系統函數,同時等待多個描述符的可讀、可寫狀態。

爲了達到這個目的,我們需要做的是:建立一個描述符列表,以及我們分別關心它們的什麼事件(可讀還是可寫還是發生例外情況);調用一個系統函數,直到這個描述符列表裏有至少一個描述符關聯的事件發生時,這個函數纔會返回。

select, poll, epoll 就是這樣的系統函數。

select

我們可以在所有 POSIX 兼容的系統裏使用 select 函數來進行 I/O 多路複用。我們需要通過 select 函數的參數傳遞給內核的信息有:

*   我們關心哪些描述符
*   我們關心它們的什麼事件
*   我們希望等待多長時間

select 的返回時,內核會告訴我們:

*   可讀的描述符的個數
*   哪些描述符發生了哪些事件

#include <sys/select.h>
int select(int maxfdp1, fd_set* readfds,
           fd_set* writefds, fd_set* exceptfds,
           struct timeval* timeout);

// 返回值: 已就緒的描述符的個數。超時時爲 0 ,錯誤時爲 -1

maxfdp1 意思是 “max file descriptor plus 1” ,就是把你要監視的所有文件描述符裏最大的那個加上 1 。(它實際上決定了內核要遍歷文件描述符的次數,比如你監視了文件描述符 5 和 20 並把 maxfdp1 設置爲 21 ,內核每次都會從描述符 0 依次檢查到 20。)

中間的三個參數是你想監視的文件描述符的集合。可以把 fd_set 類型視爲 1024 位的二進制數,這意味着 select 只能監視小於 1024 的文件描述符(1024 是由 Linux 的 sys/select.h 裏 FD_SETSIZE 宏設置的值)。在 select 返回後我們通過 FD_ISSET 來判斷代表該位的描述符是否是已準備好的狀態。

最後一個參數是等待超時的時長:到達這個時長但是沒有任一描述符可用時,函數會返回 0 。

用一個代碼片段來展示 select 的用法:

    // 這個例子要監控文件描述符 3, 4 的可讀狀態,以及 4, 5 的可寫狀態

    // 初始化兩個 fd_set 以及 timeval
    fd_set read_set, write_set;
    FD_ZERO(read_set);
    FD_ZERO(write_set);
    timeval t;
    t.tv_sec = 5;   // 超時爲 5 秒
    t.tv_usec = 0;  // 加 0 微秒

    // 設置好兩個 fd_set
    int fd1 = 3;
    int fd2 = 4;
    int fd3 = 5;
    int maxfdp1 = 5 + 1;
    FD_SET(fd1, &read_set);
    FD_SET(fd2, &read_set);
    FD_SET(fd2, &write_set);
    FD_SET(fd3, &write_set);

    // 準備備用的 fd_set
    fd_set r_temp = read_set;
    fd_set w_temp = write_set;

    while(true){
        // 每次都要重新設置放入 select 的 fd_set
        read_set = r_temp;
        write_set = w_temp;

        // 使用 select
        int n = select(maxfdp1, &read_set, &write_set, NULL, &t);

        // 上面的 select 函數會一直阻塞,直到
        // 3, 4 可讀以及 4, 5 可寫這四件事中至少一項發生
        // 或者等待時間到達 5 秒,返回 0

        for(int i=0; i<maxfdp1 && n>0; i++){
            if(FD_ISSET(i, &read_set)){
                n--;
                if(i==fd1)
                    prinf("描述符 3 可讀");
                if(i==fd2)
                    prinf("描述符 4 可讀");
            }
            if(FD_ISSET(i, &write_set)){
                n--;
                if(i==fd2)
                    prinf("描述符 3 可寫");
                if(i==fd3)
                    prinf("描述符 4 可寫");
            }
        }
        // 上面的 printf 語句換成對應的 read 或者 write 函數就
        // 可以立即讀取或者寫入相應的描述符而不用等待
    }

可以看到,select 的缺點有:

  • 默認能監視的文件描述符不能大於 1024,也代表監視的總數不超過1024。即使你因爲需要監視的描述符大於 1024 而改動內核的 FD_SETSIZE 值,但由於 select 是每次都會線性掃描整個fd_set,集合越大速度越慢,所以性能會比較差。
  • select 函數返回時只能看見已準備好的描述符數量,至於是哪個描述符準備好了需要循環用 FD_ISSET 來檢查,當未準備好的描述符很多而準備好的很少時,效率比較低。
  • select 函數每次執行的時候,都把參數裏傳入的三個 fd_set 從用戶空間複製到內核空間。而每次 fd_set 裏要監視的描述符變化不大時,全部重新複製一遍並不划算。同樣在每次都是未準備好的描述符很多而準備好的很少時,調用 select 會很頻繁,用戶/內核間的的數據複製就成了一個大的開銷。

還有一個問題是在代碼的寫法上給我一些困擾的,就是每次調用 select 前必須重新設置三個 fd_set。 fd_set 類型只是 1024 位的二進制數(實際上結構體裏是幾個 long 變量的數組;比如 64 位機器上 long 是 64 bit,那麼 fd_set 裏就是 16 個 long 變量的數組),由一位的 1 和 0 代表一個文件描述符的狀態,但是其實調用 select 前後位的 1/0 狀態意義是不一樣的。

先講一下幾個對 fd_set 操作的函數的作用:FD_ZERO 把 fd_set 所有位設置爲 0 ;FD_SET 把一個位設置爲 1 ;FD_ISSET 判斷一個位是否爲 1 。

調用 select 前:我們用 FD_ZERO 把 fd_set 先全部初始化,然後用 FD_SET 把我們關心的代表描述符的位設置爲 1 。我們這時可以用 FD_ISSET 判斷這個位是否被我們設置,這時的含義是我們想要監視的描述符是否被設置爲被監視的狀態

調用 select 時:內核判斷 fd_set 裏的位並把各個 fd_set 裏所有值爲 1 的位記錄下來,然後把 fd_set 全部設置成 0 ;一個描述符上有對應的事件發生時,把對應 fd_set 裏代表這個描述符的位設置爲 1 。

在 select 返回之後:我們同樣用 FD_ISSET 判斷各個我們關心的位是 0 還是 1 ,這時的含義是,這個位是否是發生了我們關心的事件

所以,在下一次調用 select 前,我們不得不把已經被內核改掉的 fd_set 全部重新設置一下。

select 在監視大量描述符尤其是更多的描述符未準備好的情況時性能很差。《Unix 高級編程》裏寫,用 select 的程序通常只使用 3 到 10 個描述符。

poll

poll 和 select 是相似的,只是給的接口不同。

#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);

// 返回值: 已就緒的描述符的個數。超時時爲 0 ,錯誤時爲 -1

fdarray 是 pollfd 的數組。pollfd 結構體是這樣的:

struct pollfd {
    int fd;         // 文件描述符
    short events;   // 我期待的事件
    short revents;  // 實際發生的事件:我期待的事件中發生的;或者異常情況
};

nfds 是 fdarray 的長度,也就是 pollfd 的個數。

timeout 代表等待超時的毫秒數。

相比 select ,poll 有這些優點:由於 poll 在 pollfd 裏用 int fd 來表示文件描述符而不像 select 裏用的 fd_set 來分別表示描述符,所以沒有必須小於 1024 的限制,也沒有數量限制;由於 poll 用 events 表示期待的事件,通過修改 revents 來表示發生的事件,所以不需要像 select 在每次調用前重新設置描述符和期待的事件。

除此之外,poll 和 select 幾乎相同。在 poll 返回後,需要遍歷 fdarray 來檢查各個 pollfd 裏的 revents 是否發生了期待的事件;每次調用 poll 時,把 fdarray 複製到內核空間。在描述符太多而每次準備好的較少時,poll 有同樣的性能問題。

epoll

epoll 是在 Linux 2.5.44 中首度登場的。不像 select 和 poll ,它提供了三個系統函數而不是一個。

epoll_create 用來創建一個 epoll 描述符:

#include <sys/epoll.h>
int epoll_create(int size);

// 返回值:epoll 描述符

size 用來告訴內核你想監視的文件描述符的數目,但是它並不是限制了能監視的描述符的最大個數,而是給內核最初分配的空間一個建議。然後系統會在內核中分配一個空間來存放事件表,並返回一個 epoll 描述符,用來操作這個事件表。

epoll_ctl 用來增/刪/改內核中的事件表:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 返回值:成功時返回 0 ,失敗時返回 -1

epfd 是 epoll 描述符。

op 是操作類型(增加/刪除/修改)。

fd 是希望監視的文件描述符。

event 是一個 epoll_event 結構體的指針。epoll_event 的定義是這樣的:

typedef union epoll_data {
   void        *ptr;
   int          fd;
   uint32_t     u32;
   uint64_t     u64;
} epoll_data_t;

struct epoll_event {
   uint32_t     events;      // 我期待的事件
   epoll_data_t data;        // 用戶數據變量
};

這個結構體裏,除了期待的事件外,還有一個 data ,是一個 union,它是用來讓我們在得到下面第三個函數的返回值以後方便的定位文件描述符的。

epoll_wait 用來等待事件

int epoll_wait(int epfd, struct epoll_event *result_events,
              int maxevents, int timeout);

// 返回值:已就緒的描述符個數。超時時爲 0 ,錯誤時爲 -1

epfd 是 epoll 描述符。

result_events 是 epoll_event 結構體的指針,它將指向的是所有已經準備好的事件描述符相關聯的 epoll_event(在上個步驟裏調用 epoll_ctl 時關聯起來的)。下面的例子可以讓你知道這個參數的意義。

maxevents 是返回的最大事件個數,也就是你能通過 result_events 指針遍歷到的最大的次數。

timeout 是等待超時的毫秒數。

用一個代碼片段來展示 epoll 的用法:
// 這個例子要監控文件描述符 3, 4 的可讀狀態,以及 4, 5 的可寫狀態

/* 通過 epoll_create 創建 epoll 描述符 */
int epfd = epoll_create(4);

int fd1 = 3;
int fd2 = 4;
int fd3 = 5;

/* 通過 epoll_ctl 註冊好四個事件 */
struct epoll_event ev1;
ev1.events = EPOLLIN;      // 期待它的可讀事件發生
ev1.data   = fd1;          // 我們通常就把 data 設置爲 fd ,方便以後查看
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev1);  // 添加到事件表

struct epoll_event ev2;
ev2.events = EPOLLIN;
ev2.data   = fd2;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev2);

struct epoll_event ev3;
ev3.events = EPOLLOUT;     // 期待它的可寫事件發生
ev3.data   = fd2;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev3);

struct epoll_event ev4;
ev4.events = EPOLLOUT;
ev4.data   = fd3;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd3, &ev4);

/* 通過 epoll_wait 等待事件 */
# DEFINE MAXEVENTS 4
struct epoll_event result_events[MAXEVENTS];

while(true){
    int n = epoll_wait(epfd, &result_events, MAXEVENTS, 5000);

    for(int i=0; i<n; n--){
        // result_events[i] 一定是 ev1 到 ev4 中的一個
        if(result_events[i].events&EPOLLIN)
            printf("描述符 %d 可讀", result_events[i].fd);
        else if(result_events[i].events&EPOLLOUT)
            printf("描述符 %d 可寫", result_events[i].fd)
    }
}

所以 epoll 解決了 poll 和 select 的問題:

  • 只在 epoll_ctl 的時候把數據複製到內核空間,這保證了每個描述符和事件一定只會被複制到內核空間一次;每次調用 epoll_wait 都不會複製新數據到內核空間。相比之下,select 每次調用都會把三個 fd_set 複製一遍;poll 每次調用都會把 fdarray 複製一遍。

  • epoll_wait 返回 n ,那麼只需要做 n 次循環,可以保證遍歷的每一次都是有意義的。相比之下,select 需要做至少 n 次至多 maxfdp1 次循環;poll 需要遍歷完 fdarray 即做 nfds 次循環。

  • 在內部實現上,epoll 使用了回調的方法。調用 epoll_ctl 時,就是註冊了一個事件:在集合中放入文件描述符以及事件數據,並且加上一個回調函數。一旦文件描述符上的對應事件發生,就會調用回調函數,這個函數會把這個文件描述符加入到就緒隊列上。當你調用 epoll_wait 時,它只是在查看就緒隊列上是否有內容,有的話就返回給你的程序。select() poll() epoll_wait() 三個函數在操作系統看來,都是睡眠一會兒然後判斷一會兒的循環,但是 select 和 poll 在醒着的時候要遍歷整個文件描述符集合,而 epoll_wait 只是看看就緒隊列是否爲空而已。這是 epoll 高性能的理由,使得其 I/O 的效率不會像使用輪詢的 select/poll 隨着描述符增加而大大降低。

注 1 :select/poll/epoll_wait 三個函數的等待超時時間都有一樣的特性:等待時間設置爲 0 時函數不阻塞而是立即返回,不論是否有文件描述符已準備好;poll/epoll_wait 中的 timeout 爲 -1,select 中的 timeout 爲 NULL 時,則無限等待,直到有描述符已準備好纔會返回。

注 2 :有的新手會把文件描述符是否標記爲阻塞 I/O 等同於 I/O 多路複用函數是否阻塞。其實文件描述符是否標記爲阻塞,決定了你 read 或 write 它時如果它未準備好是阻塞等待,還是立即返回 EAGAIN ;而 I/O 多路複用函數除非你把 timeout 設置爲 0 ,否則它總是會阻塞住你的程序。

注 3 :上面的例子只是入門,可能是不準確或不全面的:一是數據要立即處理防止丟失;二是 EPOLLIN/EPOLLOUT 不完全等同於可讀可寫事件,具體要去搜索 poll/epoll 的事件具體有哪些;三是大多數實際例子裏,比如一個 tcp server ,都會在運行中不斷增加/刪除的文件描述符而不是記住固定的 3 4 5 幾個描述符(用這種例子更能看出 epoll 的優勢);四是 epoll 的優勢更多的體現在處理大量閒連接的情況,如果場景是處理少量短連接,用 select 反而更好,而且用 select 的代碼能運行在所有平臺上。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章