【Go 語言社群】epoll詳解
什麼是epoll
epoll是什麼?按照man手冊的說法:是為處理大批量控制代碼而作了改進的poll。當然,這不是2.6核心才有的,它是在2.5.44核心中被引進的(epoll(4) is a new API introduced in Linux kernel 2.5.44),它幾乎具備了之前所說的一切優點,被公認為Linux2.6下效能最好的多路I/O就緒通知方法。
epoll的相關係統呼叫
epoll只有epoll_create,epoll_ctl,epoll_wait 3個系統呼叫。
1. int epoll_create(int size);
建立一個epoll的控制代碼。自從linux2.6.8之後,size引數是被忽略的。需要注意的是,當建立好epoll控制代碼後,它就是會佔用一個fd值,在linux下如果檢視/proc/程序id/fd/,是能夠看到這個fd的,所以在使用完epoll後,必須呼叫close()關閉,否則可能導致fd被耗盡。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件註冊函式,它不同於select()是在監聽事件時告訴核心要監聽什麼型別的事件,而是在這裡先註冊要監聽的事件型別。
第一個引數是epoll_create()的返回值。
第二個引數表示動作,用三個巨集來表示:
EPOLL_CTL_ADD:註冊新的fd到epfd中;
EPOLL_CTL_MOD:修改已經註冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中刪除一個fd;
第三個引數是需要監聽的fd。
第四個引數是告訴核心需要監聽什麼事,struct epoll_event結構如下:
[cpp] view plain copy print?
- //儲存觸發事件的某個檔案描述符相關的資料(與具體使用方式有關)
- typedef union epoll_data {
- void *ptr;
- int fd;
- __uint32_t u32;
- __uint64_t u64;
- } epoll_data_t;
- //感興趣的事件和被觸發的事件
- struct epoll_event {
- __uint32_t events; /* Epoll events */
- epoll_data_t data; /* User data variable */
- };
events可以是以下幾個巨集的集合:
EPOLLIN :表示對應的檔案描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的檔案描述符可以寫;
EPOLLPRI:表示對應的檔案描述符有緊急的資料可讀(這裡應該表示有帶外資料到來);
EPOLLERR:表示對應的檔案描述符發生錯誤;
EPOLLHUP:表示對應的檔案描述符被結束通話;
EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL佇列裡
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
收集在epoll監控的事件中已經發送的事件。引數events是分配好的epoll_event結構體陣列,epoll將會把發生的事件賦值到events陣列中(events不可以是空指標,核心只負責把資料複製到這個events陣列中,不會去幫助我們在使用者態中分配記憶體)。maxevents告之核心這個events有多大,這個 maxevents的值不能大於建立epoll_create()時的size,引數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。如果函式呼叫成功,返回對應I/O上已準備好的檔案描述符數目,如返回0表示已超時。
epoll工作原理
epoll同樣只告知那些就緒的檔案描述符,而且當我們呼叫epoll_wait()獲得就緒檔案描述符時,返回的不是實際的描述符,而是一個代表就緒描述符數量的值,你只需要去epoll指定的一個數組中依次取得相應數量的檔案描述符即可,這裡也使用了記憶體對映(mmap)技術,這樣便徹底省掉了這些檔案描述符在系統呼叫時複製的開銷。
另一個本質的改進在於epoll採用基於事件的就緒通知方式。在select/poll中,程序只有在呼叫一定的方法後,核心才對所有監視的檔案描述符進行掃描,而epoll事先通過epoll_ctl()來註冊一個檔案描述符,一旦基於某個檔案描述符就緒時,核心會採用類似callback的回撥機制,迅速啟用這個檔案描述符,當程序呼叫epoll_wait()時便得到通知。
Epoll的2種工作方式-水平觸發(LT)和邊緣觸發(ET)
假如有這樣一個例子:
1. 我們已經把一個用來從管道中讀取資料的檔案控制代碼(RFD)新增到epoll描述符
2. 這個時候從管道的另一端被寫入了2KB的資料
3. 呼叫epoll_wait(2),並且它會返回RFD,說明它已經準備好讀取操作
4. 然後我們讀取了1KB的資料
5. 呼叫epoll_wait(2)......
Edge Triggered 工作模式:
如果我們在第1步將RFD新增到epoll描述符的時候使用了EPOLLET標誌,那麼在第5步呼叫epoll_wait(2)之後將有可能會掛起,因為剩餘的資料還存在於檔案的輸入緩衝區內,而且資料發出端還在等待一個針對已經發出資料的反饋資訊。只有在監視的檔案控制代碼上發生了某個事件的時候 ET 工作模式才會彙報事件。因此在第5步的時候,呼叫者可能會放棄等待仍在存在於檔案輸入緩衝區內的剩餘資料。在上面的例子中,會有一個事件產生在RFD控制代碼上,因為在第2步執行了一個寫操作,然後,事件將會在第3步被銷燬。因為第4步的讀取操作沒有讀空檔案輸入緩衝區內的資料,因此我們在第5步呼叫 epoll_wait(2)完成後,是否掛起是不確定的。epoll工作在ET模式的時候,必須使用非阻塞套介面,以避免由於一個檔案控制代碼的阻塞讀/阻塞寫操作把處理多個檔案描述符的任務餓死。最好以下面的方式呼叫ET模式的epoll介面,在後面會介紹避免可能的缺陷。
i 基於非阻塞檔案控制代碼
ii 只有當read(2)或者write(2)返回EAGAIN時才需要掛起,等待。但這並不是說每次read()時都需要迴圈讀,直到讀到產生一個EAGAIN才認為此次事件處理完成,當read()返回的讀到的資料長度小於請求的資料長度時,就可以確定此時緩衝中已沒有資料了,也就可以認為此事讀事件已處理完成。
Level Triggered 工作模式
相反的,以LT方式呼叫epoll介面的時候,它就相當於一個速度比較快的poll(2),並且無論後面的資料是否被使用,因此他們具有同樣的職能。因為即使使用ET模式的epoll,在收到多個chunk的資料的時候仍然會產生多個事件。呼叫者可以設定EPOLLONESHOT標誌,在 epoll_wait(2)收到事件後epoll會與事件關聯的檔案控制代碼從epoll描述符中禁止掉。因此當EPOLLONESHOT設定後,使用帶有 EPOLL_CTL_MOD標誌的epoll_ctl(2)處理檔案控制代碼就成為呼叫者必須作的事情。
LT(level triggered)是epoll預設的工作方式,並且同時支援block和no-block socket.在這種做法中,核心告訴你一個檔案描述符是否就緒了,然後你可以對這個就緒的fd進行IO操作。如果你不作任何操作,核心還是會繼續通知你 的,所以,這種模式程式設計出錯誤可能性要小一點。傳統的select/poll都是這種模型的代表.
ET (edge-triggered)是高速工作方式,只支援no-block socket,它效率要比LT更高。ET與LT的區別在於,當一個新的事件到來時,ET模式下當然可以從epoll_wait呼叫中獲取到這個事件,可是如果這次沒有把這個事件對應的套接字緩衝區處理完,在這個套接字中沒有新的事件再次到來時,在ET模式下是無法再次從epoll_wait呼叫中獲取這個事件的。而LT模式正好相反,只要一個事件對應的套接字緩衝區還有資料,就總能從epoll_wait中獲取這個事件。
因此,LT模式下開發基於epoll的應用要簡單些,不太容易出錯。而在ET模式下事件發生時,如果沒有徹底地將緩衝區資料處理完,則會導致緩衝區中的使用者請求得不到響應。
圖示說明:
![](https://img.796t.com/res/2022/05-04/14/38d94e25aff69b067bae3d12ffaee064.jpeg)
Nginx預設採用ET模式來使用epoll。
epoll的優點:
1.支援一個程序開啟大數目的socket描述符(FD)
select 最不能忍受的是一個程序所開啟的FD是有一定限制的,由FD_SETSIZE設定,預設值是2048。對於那些需要支援的上萬連線數目的IM伺服器來說顯然太少了。這時候你一是可以選擇修改這個巨集然後重新編譯核心,不過資料也同時指出這樣會帶來網路效率的下降,二是可以選擇多程序的解決方案(傳統的 Apache方案),不過雖然linux上面建立程序的代價比較小,但仍舊是不可忽視的,加上程序間資料同步遠比不上執行緒間同步的高效,所以也不是一種完美的方案。不過 epoll則沒有這個限制,它所支援的FD上限是最大可以開啟檔案的數目,這個數字一般遠大於2048,舉個例子,在1GB記憶體的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統記憶體關係很大。
2.IO效率不隨FD數目增加而線性下降
傳統的select/poll另一個致命弱點就是當你擁有一個很大的socket集合,不過由於網路延時,任一時間只有部分的socket是"活躍"的,但是select/poll每次呼叫都會線性掃描全部的集合,導致效率呈現線性下降。但是epoll不存在這個問題,它只會對"活躍"的socket進行操作---這是因為在核心實現中epoll是根據每個fd上面的callback函式實現的。那麼,只有"活躍"的socket才會主動的去呼叫 callback函式,其他idle狀態socket則不會,在這點上,epoll實現了一個"偽"AIO,因為這時候推動力在os核心。在一些 benchmark中,如果所有的socket基本上都是活躍的---比如一個高速LAN環境,epoll並不比select/poll有什麼效率,相反,如果過多使用epoll_ctl,效率相比還有稍微的下降。但是一旦使用idle connections模擬WAN環境,epoll的效率就遠在select/poll之上了。
3.使用mmap加速核心與使用者空間的訊息傳遞
這點實際上涉及到epoll的具體實現了。無論是select,poll還是epoll都需要核心把FD訊息通知給使用者空間,如何避免不必要的記憶體拷貝就很重要,在這點上,epoll是通過核心於使用者空間mmap同一塊記憶體實現的。而如果你想我一樣從2.5核心就關注epoll的話,一定不會忘記手工 mmap這一步的。
4.核心微調
這一點其實不算epoll的優點了,而是整個linux平臺的優點。也許你可以懷疑linux平臺,但是你無法迴避linux平臺賦予你微調核心的能力。比如,核心TCP/IP協議棧使用記憶體池管理sk_buff結構,那麼可以在執行時期動態調整這個記憶體pool(skb_head_pool)的大小--- 通過echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函式的第2個引數(TCP完成3次握手的資料包佇列長度),也可以根據你平臺記憶體大小動態調整。更甚至在一個數據包面數目巨大但同時每個資料包本身大小卻很小的特殊系統上嘗試最新的NAPI網絡卡驅動架構。
linux下epoll如何實現高效處理百萬控制代碼的
開發高效能網路程式時,windows開發者們言必稱iocp,linux開發者們則言必稱epoll。大家都明白epoll是一種IO多路複用技術,可以非常高效的處理數以百萬計的socket控制代碼,比起以前的select和poll效率高大發了。我們用起epoll來都感覺挺爽,確實快,那麼,它到底為什麼可以高速處理這麼多併發連線呢?
使用起來很清晰,首先要呼叫epoll_create建立一個epoll物件。引數size是核心保證能夠正確處理的最大控制代碼數,多於這個最大數時核心可不保證效果。
epoll_ctl可以操作上面建立的epoll,例如,將剛建立的socket加入到epoll中讓其監控,或者把 epoll正在監控的某個socket控制代碼移出epoll,不再監控它等等。
epoll_wait在呼叫時,在給定的timeout時間內,當在監控的所有控制代碼中有事件發生時,就返回使用者態的程序。
從上面的呼叫方式就可以看到epoll比select/poll的優越之處:因為後者每次呼叫時都要傳遞你所要監控的所有socket給select/poll系統呼叫,這意味著需要將使用者態的socket列表copy到核心態,如果以萬計的控制代碼會導致每次都要copy幾十幾百KB的記憶體到核心態,非常低效。而我們呼叫epoll_wait時就相當於以往呼叫select/poll,但是這時卻不用傳遞socket控制代碼給核心,因為核心已經在epoll_ctl中拿到了要監控的控制代碼列表。
所以,實際上在你呼叫epoll_create後,核心就已經在核心態開始準備幫你儲存要監控的控制代碼了,每次呼叫epoll_ctl只是在往核心的資料結構裡塞入新的socket控制代碼。
當一個程序呼叫epoll_creaqte方法時,Linux核心會建立一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關:
[cpp] view plain copy print?
/*
171 * This structure is stored inside the "private_data" member of the file
172 * structure and represents the main data structure for the eventpoll
173 * interface.
174 */
175struct eventpoll {
176 /* Protect the access to this structure */
177 spinlock_t lock;
178
179 /*
180 * This mutex is used to ensure that files are not removed
181 * while epoll is using them. This is held during the event
182 * collection loop, the file cleanup path, the epoll file exit
183 * code and the ctl operations.
184 */
185 struct mutex mtx;
186
187 /* Wait queue used by sys_epoll_wait() */
188 wait_queue_head_t wq;
189
190 /* Wait queue used by file->poll() */
191 wait_queue_head_t poll_wait;
192
193 /* List of ready file descriptors */
194 struct list_head rdllist;
195
196 /* RB tree root used to store monitored fd structs */
197 struct rb_root rbr;//紅黑樹根節點,這棵樹儲存著所有新增到epoll中的事件,也就是這個epoll監控的事件
198
199 /*
200 * This is a single linked list that chains all the "struct epitem" that
201 * happened while transferring ready events to userspace w/out
202 * holding ->lock.
203 */
204 struct epitem *ovflist;
205
206 /* wakeup_source used when ep_scan_ready_list is running */
207 struct wakeup_source *ws;
208
209 /* The user that created the eventpoll descriptor */
210 struct user_struct *user;
211
212 struct file *file;
213
214 /* used to optimize loop detection check */
215 int visited;
216 struct list_head visited_list_link;//雙向連結串列中儲存著將要通過epoll_wait返回給使用者的、滿足條件的事件
217};
每一個epoll物件都有一個獨立的eventpoll結構體,這個結構體會在核心空間中創造獨立的記憶體,用於儲存使用epoll_ctl方法向epoll物件中新增進來的事件。這樣,重複的事件就可以通過紅黑樹而高效的識別出來。
在epoll中,對於每一個事件都會建立一個epitem結構體:
[cpp] view plain copy print?
/*
130 * Each file descriptor added to the eventpoll interface will
131 * have an entry of this type linked to the "rbr" RB tree.
132 * Avoid increasing the size of this struct, there can be many thousands
133 * of these on a server and we do not want this to take another cache line.
134 */
135struct epitem {
136 /* RB tree node used to link this structure to the eventpoll RB tree */
137 struct rb_node rbn;
138
139 /* List header used to link this structure to the eventpoll ready list */
140 struct list_head rdllink;
141
142 /*
143 * Works together "struct eventpoll"->ovflist in keeping the
144 * single linked chain of items.
145 */
146 struct epitem *next;
147
148 /* The file descriptor information this item refers to */
149 struct epoll_filefd ffd;
150
151 /* Number of active wait queue attached to poll operations */
152 int nwait;
153
154 /* List containing poll wait queues */
155 struct list_head pwqlist;
156
157 /* The "container" of this item */
158 struct eventpoll *ep;
159
160 /* List header used to link this item to the "struct file" items list */
161 struct list_head fllink;
162
163 /* wakeup_source used when EPOLLWAKEUP is set */
164 struct wakeup_source __rcu *ws;
165
166 /* The structure that describe the interested events and the source fd */
167 struct epoll_event event;
168};
此外,epoll還維護了一個雙鏈表,使用者儲存發生的事件。當epoll_wait呼叫時,僅僅觀察這個list連結串列裡有沒有資料即eptime項即可。有資料就返回,沒有資料就sleep,等到timeout時間到後即使連結串列沒資料也返回。所以,epoll_wait非常高效。
而且,通常情況下即使我們要監控百萬計的控制代碼,大多一次也只返回很少量的準備就緒控制代碼而已,所以,epoll_wait僅需要從核心態copy少量的控制代碼到使用者態而已,如何能不高效?!
那麼,這個準備就緒list連結串列是怎麼維護的呢?當我們執行epoll_ctl時,除了把socket放到epoll檔案系統裡file物件對應的紅黑樹上之外,還會給核心中斷處理程式註冊一個回撥函式,告訴核心,如果這個控制代碼的中斷到了,就把它放到準備就緒list連結串列裡。所以,當一個socket上有資料到了,核心在把網絡卡上的資料copy到核心中後就來把socket插入到準備就緒連結串列裡了。
如此,一顆紅黑樹,一張準備就緒控制代碼連結串列,少量的核心cache,就幫我們解決了大併發下的socket處理問題。執行epoll_create時,建立了紅黑樹和就緒連結串列,執行epoll_ctl時,如果增加socket控制代碼,則檢查在紅黑樹中是否存在,存在立即返回,不存在則新增到樹幹上,然後向核心註冊回撥函式,用於當中斷事件來臨時向準備就緒連結串列中插入資料。執行epoll_wait時立刻返回準備就緒連結串列裡的資料即可。
epoll的使用方法
那麼究竟如何來使用epoll呢?其實非常簡單。
通過在包含一個頭檔案#include <sys/epoll.h> 以及幾個簡單的API將可以大大的提高你的網路伺服器的支援人數。
首先通過create_epoll(int maxfds)來建立一個epoll的控制代碼。這個函式會返回一個新的epoll控制代碼,之後的所有操作將通過這個控制代碼來進行操作。在用完之後,記得用close()來關閉這個創建出來的epoll控制代碼。
之後在你的網路主迴圈裡面,每一幀的呼叫epoll_wait(int epfd, epoll_event events, int max events, int timeout)來查詢所有的網路介面,看哪一個可以讀,哪一個可以寫了。基本的語法為:
nfds = epoll_wait(kdpfd, events, maxevents, -1);
其中kdpfd為用epoll_create建立之後的控制代碼,events是一個epoll_event*的指標,當epoll_wait這個函式操作成功之後,epoll_events裡面將儲存所有的讀寫事件。max_events是當前需要監聽的所有socket控制代碼數。最後一個timeout是 epoll_wait的超時,為0的時候表示馬上返回,為-1的時候表示一直等下去,直到有事件返回,為任意正整數的時候表示等這麼長的時間,如果一直沒有事件,則返回。一般如果網路主迴圈是單獨的執行緒的話,可以用-1來等,這樣可以保證一些效率,如果是和主邏輯在同一個執行緒的話,則可以用0來保證主迴圈的效率。
epoll_wait返回之後應該是一個迴圈,遍歷所有的事件。