Epoll在LT和ET模式下的讀寫方式

工程師WWW發表於2015-02-10

在一個非阻塞的socket上呼叫read/write函式, 返回EAGAIN或者EWOULDBLOCK(注: EAGAIN就是EWOULDBLOCK)
從字面上看, 意思是:EAGAIN: 再試一次,EWOULDBLOCK: 如果這是一個阻塞socket, 操作將被block,perror輸出: Resource temporarily unavailable

總結:
這個錯誤表示資源暫時不夠,能read時,讀緩衝區沒有資料,或者write時,寫緩衝區滿了。遇到這種情況,如果是阻塞socket,read/write就要阻塞掉。而如果是非阻塞socket,read/write立即返回-1, 同時errno設定為EAGAIN。
所以,對於阻塞socket,read/write返回-1代表網路出錯了。但對於非阻塞socket,read/write返回-1不一定網路真的出錯了。可能是Resource temporarily unavailable。這時你應該再試,直到Resource available。

綜上,對於non-blocking的socket,正確的讀寫操作為:
讀:忽略掉errno = EAGAIN的錯誤,下次繼續讀
寫:忽略掉errno = EAGAIN的錯誤,下次繼續寫

對於select和epoll的LT模式,這種讀寫方式是沒有問題的。但對於epoll的ET模式,這種方式還有漏洞。


epoll的兩種模式LT和ET
二者的差異在於level-trigger模式下只要某個socket處於readable/writable狀態,無論什麼時候進行epoll_wait都會返回該socket而edge-trigger模式下只有某個socket從unreadable變為readable或從unwritable變為writable時,epoll_wait才會返回該socket。如下兩個示意圖:

從socket讀資料:

 

 

 

往socket寫資料

所以,在epoll的ET模式下,正確的讀寫方式為:
讀:只要可讀,就一直讀,直到返回0(#add 讀空),或者 errno = EAGAIN
寫:只要可寫,就一直寫,直到資料傳送完(#add 寫滿),或者 errno = EAGAIN

正確的讀:

點選(此處)摺疊或開啟

  1. = 0; 
  2. while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) { 
  3.    n += nread; 
  4. } 
  5. if (nread == -&& errno != EAGAIN) 
  6. { 
  7.    perror("read error"); 
  8. }

正確的寫

點選(此處)摺疊或開啟

  1. int nwrite, data_size = strlen(buf); 
  2. = data_size; 
  3. while (> 0) 
  4. { 
  5.    nwrite = write(fd, buf + data_size - n, n); 
  6.    if (nwrite < n) 
  7.   { 
  8.      if (nwrite == -&& errno != EAGAIN) 
  9.      { 
  10.         perror("write error"); 
  11.      } 
  12.      break; 
  13.   } 
  14.   n -= nwrite; 
  15. }


點選(此處)摺疊或開啟

正確的accept,accept 要考慮 2 個問題
     (1) 阻塞模式accept存在的問題
考慮這種情況:TCP連線被客戶端夭折,即在伺服器呼叫accept之前,客戶端主動傳送RST終止連線,導致剛剛建立的連線從就緒佇列中移出,如果套介面被設定成阻塞模式,伺服器就會一直阻塞在accept呼叫上,直到其他某個客戶建立一個新的連線為止。但是在此期間,伺服器單純地阻塞在accept呼叫上,就緒佇列中的其他描述符都得不到處理。 解決辦法是把監聽套介面設定為非阻塞,當客戶在伺服器呼叫accept之前中止某個連線時,accept呼叫可以立即返回-1,這時源自Berkeley的實現會在核心中處理該事件,並不會將該事件通知給epool,而其他實現把errno設定為ECONNABORTED或者EPROTO錯誤,我們應該忽略這兩個錯誤。 
     (2)ET模式下accept存在的問題
考慮這種情況:多個連線同時到達,伺服器的TCP就緒佇列瞬間積累多個就緒連線,由於是邊緣觸發模式,epoll只會通知一次,accept只處理一個連線,導致TCP就緒佇列中剩下的連線都得不到處理。 解決辦法是用while迴圈抱住accept呼叫,處理完TCP就緒佇列中的所有連線後再退出迴圈。如何知道是否處理完就緒佇列中的所有連線呢?accept返回-1並且errno設定為EAGAIN就表示所有連線都處理完。 
綜合以上兩種情況,伺服器應該使用非阻塞地accept,accept在ET模式下的正確使用方式為:

點選(此處)摺疊或開啟

  1. while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, (size_t *)&addrlen)) > 0)
  2. { 
  3.    handle_client(conn_sock); 
  4. } 
  5. if (conn_sock == -1) 
  6. { 
  7.    if (errno != EAGAIN && errno != ECONNABORTED && errno != EPROTO && errno != EINTR) 
  8.    perror("accept"); 
  9. }


一道騰訊後臺開發的面試題
使用Linux epoll模型,水平觸發模式;當socket可寫時,會不停的觸發socket可寫的事件,如何處理?


第一種最普遍的方式:
需要向socket寫資料的時候才把socket加入epoll,等待可寫事件。
接受到可寫事件後,呼叫write或者send傳送資料。
當所有資料都寫完後,把socket移出epoll。

這種方式的缺點是,即使傳送很少的資料,也要把socket加入epoll,寫完後在移出epoll,有一定操作代價。


一種改進的方式:
開始不把socket加入epoll,需要向socket寫資料的時候,直接呼叫write或者send傳送資料。如果返回EAGAIN,把socket加入epoll,在epoll的驅動下寫資料,全部資料傳送完畢後,再移出epoll。

這種方式的優點是:資料不多的時候可以避免epoll的事件處理,提高效率。

最後貼一個使用epoll,ET模式的簡單HTTP伺服器程式碼:


#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/epoll.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <fcntl.h>
#include <errno.h>
 
#define MAX_EVENTS 10
#define PORT 8080
 
//設定socket連線為非阻塞模式
void setnonblocking(int sockfd) {
int opts;
 
opts = fcntl(sockfd, F_GETFL);
if(opts < 0) {
perror("fcntl(F_GETFL)\n");
exit(1);
}
opts = (opts | O_NONBLOCK);
if(fcntl(sockfd, F_SETFL, opts) < 0) {
perror("fcntl(F_SETFL)\n");
exit(1);
}
}
 
int main(){
struct epoll_event ev, events[MAX_EVENTS];
int addrlen, listenfd, conn_sock, nfds, epfd, fd, i, nread, n;
struct sockaddr_in local, remote;
char buf[BUFSIZ];
 
//建立listen socket
if( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("sockfd\n");
exit(1);
}
setnonblocking(listenfd);
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = htonl(INADDR_ANY);;
local.sin_port = htons(PORT);
if( bind(listenfd, (struct sockaddr *) &local, sizeof(local)) < 0) {
perror("bind\n");
exit(1);
}
listen(listenfd, 20);
 
epfd = epoll_create(MAX_EVENTS);
if (epfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
 
ev.events = EPOLLIN;
ev.data.fd = listenfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
 
for (;;) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_pwait");
exit(EXIT_FAILURE);
}
 
for (i = 0; i < nfds; ++i) {
fd = events[i].data.fd;
if (fd == listenfd) {
while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote,
(size_t *)&addrlen)) > 0) {
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: add");
exit(EXIT_FAILURE);
}
}
if (conn_sock == -1) {
if (errno != EAGAIN && errno != ECONNABORTED
&& errno != EPROTO && errno != EINTR)
perror("accept");
}
continue;
}
if (events[i].events & EPOLLIN) {
n = 0;
while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {
n += nread;
}
if (nread == -1 && errno != EAGAIN) {
perror("read error");
}
ev.data.fd = fd;
ev.events = events[i].events | EPOLLOUT;
if (epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) == -1) {
perror("epoll_ctl: mod");
}
}
if (events[i].events & EPOLLOUT) {
sprintf(buf, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\nHello World", 11);
int nwrite, data_size = strlen(buf);
n = data_size;
while (n > 0) {
nwrite = write(fd, buf + data_size - n, n);
if (nwrite < n) {
if (nwrite == -1 && errno != EAGAIN) {
perror("write error");
}
break;
}
n -= nwrite;
}
close(fd);
}
}
}
 
return 0;
}
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網路卡驅動架構。

剖析 epoll ET/LT 觸發方式的效能差異誤解(定性分析)

平時大家使用 epoll 時都知道其事件觸發模式有預設的 level-trigger 模式和通過 EPOLLET 啟用的 edge-trigger 模式兩種。從 epoll 發展歷史來看,它剛誕生時只有 edge-trigger 模式,後來因容易產生 race-cond 且不易被開發者理解,又增加了 level-trigger 模式並作為預設處理方式。

二者的差異在於 level-trigger 模式下只要某個 fd 處於 readable/writable 狀態,無論什麼時候進行 epoll_wait 都會返回該 fd;而 edge-trigger 模式下只有某個 fd 從 unreadable 變為 readable 或從 unwritable 變為 writable 時,epoll_wait 才會返回該 fd。

通常的誤區是:level-trigger 模式在 epoll 池中存在大量 fd 時效率要顯著低於 edge-trigger 模式。

但從 kernel 程式碼來看,edge-trigger/level-trigger 模式的處理邏輯幾乎完全相同,差別僅在於 level-trigger 模式在 event 發生時不會將其從 ready list 中移除,略為增大了 event 處理過程中 kernel space 中記錄資料的大小。

然而,edge-trigger 模式一定要配合 user app 中的 ready list 結構,以便收集已出現 event 的 fd,再通過 round-robin 方式挨個處理,以此避免通訊資料量很大時出現忙於處理熱點 fd 而導致非熱點 fd 餓死的現象。統觀 kernel 和 user space,由於 user app 中 ready list 的實現千奇百怪,不一定都經過仔細的推敲優化,因此 edge-trigger 的總記憶體開銷往往還大於 level-trigger 的開銷。

一般號稱 edge-trigger 模式的優勢在於能夠減少 epoll 相關係統呼叫,這話不假,但 user app 裡可不是隻有 epoll 相關係統呼叫吧?為了繞過餓死問題,edge-trigger 模式的 user app 要自行進行 read/write 迴圈處理,這其中增加的系統呼叫和減少的 epoll 系統呼叫加起來,有誰能說一定就能明顯地快起來呢?

實際上,epoll_wait 的效率是 O(ready fd num) 級別的,因此 edge-trigger 模式的真正優勢在於減少了每次 epoll_wait 可能需要返回的 fd 數量,在併發 event 數量極多的情況下能加快 epoll_wait 的處理速度,但別忘了這只是針對 epoll 體系自己而言的提升,與此同時 user app 需要增加複雜的邏輯、花費更多的 cpu/mem 與其配合工作,總體效能收益究竟如何?只有實際測量才知道,無法一概而論。不過,為了降低處理邏輯複雜度,常用的事件處理庫大部分都選擇了 level-trigger 模式(如 libevent、boost::asio等)

結論:
? epoll 的 edge-trigger 和 level-trigger 模式處理邏輯差異極小,效能測試結果表明常規應用場景 中二者效能差異可以忽略。
? 使用 edge-trigger 的 user app 比使用 level-trigger 的邏輯複雜,出錯概率更高。
? edge-trigger 和 level-trigger 的效能差異主要在於 epoll_wait 系統呼叫的處理速度,是否是 user app 的效能瓶頸需要視應用場景而定,不可一概而論。

相關文章