微信公衆號【黃小斜】做者是螞蟻金服 JAVA 工程師,目前在螞蟻財富負責後端開發工做,專一於 JAVA 後端技術棧,同時也懂點投資理財,堅持學習和寫做,用大廠程序員的視角解讀技術與互聯網,個人世界裏不僅有 coding!關注公衆號後回覆」架構師「便可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送做者原創的Java學習指南、Java程序員面試指南等乾貨資源node
在linux 沒有實現epoll事件驅動機制以前,咱們通常選擇用select或者poll等IO多路複用的方法來實現併發服務程序。在大數據、高併發、集羣等一些名詞唱得火熱之年代,select和poll的用武之地愈來愈有限,風頭已經被epoll佔盡。linux
本文便來介紹epoll的實現機制,並附帶講解一下select和poll。經過對比其不一樣的實現機制,真正理解爲什麼epoll能實現高併發。程序員
這部分轉自https://jeff.wtf/2017/02/IO-multiplexing/面試
當須要從一個叫 r_fd
的描述符不停地讀取數據,並把讀到的數據寫入一個叫 w_fd
的描述符時,咱們能夠用循環使用阻塞 I/O :數據庫
123 |
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 就是這樣的系統函數。
咱們能夠在全部 POSIX 兼容的系統裏使用 select 函數來進行 I/O 多路複用。咱們須要經過 select 函數的參數傳遞給內核的信息有:
select 的返回時,內核會告訴咱們:
123456 |
#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 的用法:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455 |
// 這個例子要監控文件描述符 3, 4 的可讀狀態,以及 4, 5 的可寫狀態// 初始化兩個 fd_set 以及 timevalfd_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_setint 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_setfd_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 的缺點有:
FD_SETSIZE
值,但因爲 select 是每次都會線性掃描整個fd_set,集合越大速度越慢,因此性能會比較差。FD_ISSET
來檢查,當未準備好的描述符不少而準備好的不多時,效率比較低。還有一個問題是在代碼的寫法上給我一些困擾的,就是每次調用 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 和 select 是類似的,只是給的接口不一樣。
1234 |
#include <poll.h>int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);// 返回值: 已就緒的描述符的個數。超時時爲 0 ,錯誤時爲 -1 |
---|
fdarray
是 pollfd
的數組。pollfd
結構體是這樣的:
12345 |
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 是在 Linux 2.5.44 中首度登場的。不像 select 和 poll ,它提供了三個系統函數而不是一個。
1234 |
#include <sys/epoll.h>int epoll_create(int size);// 返回值:epoll 描述符 |
---|
size
用來告訴內核你想監視的文件描述符的數目,可是它並非限制了能監視的描述符的最大個數,而是給內核最初分配的空間一個建議。而後系統會在內核中分配一個空間來存放事件表,並返回一個 epoll 描述符,用來操做這個事件表。
123 |
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);// 返回值:成功時返回 0 ,失敗時返回 -1 |
---|
epfd
是 epoll 描述符。
op
是操做類型(增長/刪除/修改)。
fd
是但願監視的文件描述符。
event
是一個 epoll_event 結構體的指針。epoll_event 的定義是這樣的:
1234567891011 |
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,它是用來讓咱們在獲得下面第三個函數的返回值之後方便的定位文件描述符的。
1234 |
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 的用法:
123456789101112131415161718192021222324252627282930313233343536373839404142434445 |
// 這個例子要監控文件描述符 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 4struct 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 的代碼能運行在全部平臺上。
select的缺點:
相比select模型,poll使用鏈表保存文件描述符,所以沒有了監視文件數量的限制,但其餘三個缺點依然存在。
拿select模型爲例,假設咱們的服務器須要支持100萬的併發鏈接,則在__FD_SETSIZE 爲1024的狀況下,則咱們至少須要開闢1k個進程才能實現100萬的併發鏈接。除了進程間上下文切換的時間消耗外,從內核/用戶空間大量的無腦內存拷貝、數組輪詢等,是系統難以承受的。所以,基於select模型的服務器程序,要達到10萬級別的併發訪問,是一個很難完成的任務。
所以,該epoll上場了。
因爲epoll的實現機制與select/poll機制徹底不一樣,上面所說的 select的缺點在epoll上不復存在。
設想一下以下場景:有100萬個客戶端同時與一個服務器進程保持着TCP鏈接。而每一時刻,一般只有幾百上千個TCP鏈接是活躍的(事實上大部分場景都是這種狀況)。如何實現這樣的高併發?
在select/poll時代,服務器進程每次都把這100萬個鏈接告訴操做系統(從用戶態複製句柄數據結構到內核態),讓操做系統內核去查詢這些套接字上是否有事件發生,輪詢完後,再將句柄數據複製到用戶態,讓服務器應用程序輪詢處理已發生的網絡事件,這一過程資源消耗較大,所以,select/poll通常只能處理幾千的併發鏈接。
epoll的設計和實現與select徹底不一樣。epoll經過在Linux內核中申請一個簡易的文件系統(文件系統通常用什麼數據結構實現?B+樹)。把原先的select/poll調用分紅了3個部分:
1)調用epoll_create()創建一個epoll對象(在epoll文件系統中爲這個句柄對象分配資源)
2)調用epoll_ctl向epoll對象中添加這100萬個鏈接的套接字
3)調用epoll_wait收集發生的事件的鏈接
如此一來,要實現上面說是的場景,只須要在進程啓動時創建一個epoll對象,而後在須要的時候向這個epoll對象中添加或者刪除鏈接。同時,epoll_wait的效率也很是高,由於調用epoll_wait時,並無一股腦的向操做系統複製這100萬個鏈接的句柄數據,內核也不須要去遍歷所有的鏈接。
下面來看看Linux內核具體的epoll機制實現思路。
當某一進程調用epoll_create方法時,Linux內核會建立一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。eventpoll結構體以下所示:
[cpp] view plain copy
每個epoll對象都有一個獨立的eventpoll結構體,用於存放經過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重複添加的事件就能夠經過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n爲樹的高度)。
而全部添加到epoll中的事件都會與設備(網卡)驅動程序創建回調關係,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。
在epoll中,對於每個事件,都會創建一個epitem結構體,以下所示:
[cpp] view plain copy
當調用epoll_wait檢查是否有事件發生時,只須要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素便可。若是rdlist不爲空,則把發生的事件複製到用戶態,同時將事件數量返回給用戶。
epoll數據結構示意圖
從上面的講解可知:經過紅黑樹和雙鏈表數據結構,並結合回調機制,造就了epoll的高效。
OK,講解完了Epoll的機理,咱們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。
第一步:epoll_create()系統調用。此調用返回一個句柄,以後全部的使用都依靠這個句柄來標識。
第二步:epoll_ctl()系統調用。經過此調用向epoll對象中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。
第三部:epoll_wait()系統調用。經過此調用收集收集在epoll監控中已經發生的事件。
最後,附上一個epoll編程實例。
幾乎全部的epoll程序都使用下面的框架:
[cpp] view plaincopyprint?
[cpp] view plaincopyprint?
微信公衆號【Java技術江湖】一位阿里 Java 工程師的技術小站。(關注公衆號後回覆」Java「便可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送做者原創的Java學習指南、Java程序員面試指南等乾貨資源)