一、epoll 系列函數簡介
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
1. int epoll_create(int size)
創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大。這個參數不同於select()中的第一個參數,給出最大監聽的fd+1的值。需要注意的是,當創建好epoll句柄後,它就是會佔用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll後,必須調用close()關閉,否則可能導致fd被耗盡。
2.epoll_create1 產生一個epoll 實例,返回的是實例的句柄。flag 可以設置爲0 或者EPOLL_CLOEXEC,爲0時函數表現與epoll_create一致,EPOLL_CLOEXEC標誌與open 時的O_CLOEXEC 標誌類似,即進程被替換時會關閉打開的文件描述符。3.. 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結構如下:
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隊列裏
關於ET、LT兩種工作模式:
epoll 的EPOLLLT (電平觸發,默認)和 EPOLLET(邊沿觸發)模式的區別
1、EPOLLLT:完全靠kernel epoll驅動,應用程序只需要處理從epoll_wait返回的fds,這些fds我們認爲它們處於就緒狀態。此時epoll可以認爲是更快速的poll。LT(level triggered)是缺省的工作方式,並且同時支持block和no-block
socket.在這種做法中,內核告訴你一個文件描述符是否就緒了,然後你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的,所以,這種模式編程出錯誤可能性要小一點。傳統的select/poll都是這種模型的代表.
2、EPOLLET:此模式下,系統僅僅通知應用程序哪些fds變成了就緒狀態,一旦fd變成就緒狀態,epoll將不再關注這個fd的任何狀態信息,(從epoll隊列移除)直到應用程序通過讀寫操作(非阻塞)觸發EAGAIN狀態,epoll認爲這個fd又變爲空閒狀態,那麼epoll又重新關注這個fd的狀態變化(重新加入epoll隊列)。隨着epoll_wait的返回,隊列中的fds是在減少的,所以在大併發的系統中,EPOLLET更有優勢,但是對程序員的要求也更高,因爲有可能會出現數據讀取不完整的問題。
ET(edge-triggered)是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變爲就緒時,內核通過epoll告訴你。然後它會假設你知道文件描述符已經就緒,並且不會再爲那個文件描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再爲就緒狀態了(比如,你在發送,接收或者接收請求,或者發送接收的數據少於一定量時導致了一個EWOULDBLOCK
錯誤)。但是請注意,如果一直不對這個fd作IO操作(從而導致它再次變成未就緒),內核不會發送更多的通知(only once)
舉例如下:
假設現在對方發送了2k的數據,而我們先讀取了1k,然後這時調用了epoll_wait,如果是邊沿觸發,那麼這個fd變成就緒狀態就會從epoll 隊列移除,很可能epoll_wait 會一直阻塞,忽略尚未讀取的1k數據,與此同時對方還在等待着我們發送一個回覆ack,表示已經接收到數據;如果是電平觸發,那麼epoll_wait 還會檢測到可讀事件而返回,我們可以繼續讀取剩下的1k 數據。
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的產生,類似於select()調用。參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個 maxevents的值不能大於創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。
下面爲用epoll寫的C++程序:
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/epoll.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <vector>
#include <algorithm>
#include "sysutil.h"
typedef std::vector<struct epoll_event> EventList;//定義新類型,內部裝着epoll_event結構體的容器
/* 相比於select與poll,epoll最大的好處是不會隨着關心的fd數目的增多而降低效率 */
int main(void)
{
int count = 0;
int listenfd;
if ((listenfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
ERR_EXIT("socket");
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
ERR_EXIT("setsockopt");
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
ERR_EXIT("bind");
if (listen(listenfd, SOMAXCONN) < 0)
ERR_EXIT("listen");
std::vector<int> clients;//創建一個int類型的vector對象即clients
int epollfd;
epollfd = epoll_create1(EPOLL_CLOEXEC); //epoll實例句柄
struct epoll_event event;//臨時保存需要監聽的fd等待其加入
event.data.fd = listenfd;//將監聽套接字listenfd 加入關心的套接字序列
event.events = EPOLLIN | EPOLLET; //邊沿觸發
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &event);/*第一個參數爲epoll實例句柄,第二個參數爲對文件描述符的操作
第三個參數爲需要操作的目標文件描述符,第四個參數告訴內核需
要監聽什麼事,即第三個參數fd的event
*/
EventList events(16);/*即初始化容器的大小爲16,當返回的事件個數nready 已經等於16時,
需要增大容器的大小,使用events.resize 函數即可,容器可以動態增大,
這也是我們使用c++實現的其中一個原因
*/
struct sockaddr_in peeraddr;
socklen_t peerlen;
int conn;
int i;
int nready;
while (1)
{
nready = epoll_wait(epollfd, &*events.begin(), static_cast<int>(events.size()), -1);
/*第一個參數爲epoll句柄,第二個參數用來從內核得到事件的集合,這裏將得到的事件保存在events容器中,
上面設置的初始大小爲16第三個參數告訴內核這個事件集合有多大,第四個參數爲等待I/O事件的超時值,
-1表示永不超時,返回值爲需要處理的事件的個數。返回0表示已經超時
*/
if (nready == -1)
{
if (errno == EINTR)
continue;
ERR_EXIT("epoll_wait error");
}
if (nready == 0)
continue;
if ((size_t)nready == events.size())//當返回的事件個數nready 已經等於16時,需要增大容器的大小
events.resize(events.size() * 2);
for (i = 0; i < nready; i++)
{
if (events[i].data.fd == listenfd)//第一個爲監聽套接字
{
peerlen = sizeof(peeraddr);
conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen);
if (conn == -1)
ERR_EXIT("accept error");
printf("ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port));
printf("count = %d\n", ++count);
clients.push_back(conn);/*使用 std::vector<int> clients;,來保存每次accept 返回的conn,
這裏表示向容器中添加一個新的值
*/
activate_nonblock(conn);// 將conn 設置爲非阻塞
event.data.fd = conn;
event.events = EPOLLIN | EPOLLET;//設置爲邊沿觸發
epoll_ctl(epollfd, EPOLL_CTL_ADD, conn, &event);//使用epoll_ctl 函數將conn其加入關心的套接字序列
}
else if (events[i].events & EPOLLIN)
{
conn = events[i].data.fd;
if (conn < 0)
continue;
char recvbuf[1024] = {0};
int ret = read(conn, recvbuf, 1024);
if (ret == -1)
ERR_EXIT("read error");
if (ret == 0)
{
printf("client close\n");
close(conn);
event = events[i];//從容器中取出上面關閉的conn中的事件
epoll_ctl(epollfd, EPOLL_CTL_DEL, conn, &event);//使用epoll_ctl函數將其刪除關心的套接字序列
clients.erase(std::remove(clients.begin(), clients.end(), conn), clients.end());
/*std:remove(first,last,val)返回一個迭代器,指向由begin到end區間上第一個要刪除的元素,
這裏是conn,然後在第一個要刪除的元素(conn)到clients.end()的區間上調用erase(),從而刪除
所有的要刪除的元素,使得vector只包含未被刪除的元素。
*/
}
fputs(recvbuf, stdout);
write(conn, recvbuf, strlen(recvbuf));
}
}
}
return 0;
}
在程序的最開始定義一個新類型EventList,內部裝着struct epoll_event 結構體的容器。
接下面的socket,bind,listen 都跟以前說的一樣,不述。接着使用epoll_create1 創建一個epoll 實例,再來看下面四行代碼:
struct epoll_event event;
event.data.fd = listenfd;
event.events = EPOLLIN | EPOLLET; //邊沿觸發
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &event);
根據前面的函數分析,這四句意思就是將監聽套接字listenfd 加入關心的套接字序列。
在epoll_wait 函數中的第二個參數,其實events.begin() 是個迭代器,但其具體實現也是struct epoll_event* 類型,雖然 &*events.begin() 得到的也是struct epoll_event* ,但不能直接使用events.begin() 做參數,因爲類型不匹配,編譯會出錯。
EventList events(16); 即初始化容器的大小爲16,當返回的事件個數nready 已經等於16時,需要增大容器的大小,使用events.resize 函數即可,容器可以動態增大,這也是我們使用c++實現的其中一個原因。
當監聽套接字有可讀事件,accept 返回的conn也需要使用epoll_ctl 函數將其加入關心的套接字隊列。
還需要調用 activate_nonblock(conn); 將conn 設置爲非阻塞,man 7 epoll 裏有這樣一句話:
An application that employs the EPOLLET flag should use nonblocking file descriptors to avoid having a blocking read or
write starve a task that is handling multiple file descriptors.
當下次循環回來某個已連接套接字有可讀事件,則讀取數據,若read 返回0表示對方關閉,需要使用epoll_ctl 函數將conn 從隊列中清除,我們使用 std::vector<int> clients; 來保存每次accept 返回的conn,所以現在也需要將其擦除掉,調用clients.erase() 函數。
先運行服務器程序,再運行客戶端,輸出如下:
客戶端
解釋:
爲什麼服務器端的count 只有1019呢,因爲除去012,一個監聽套接字還有一個epoll 實例句柄,所以1024 - 5 = 1019。
爲什麼客戶端的錯誤提示跟這裏的不一樣呢?這正說明epoll
處理效率比poll和select 都高,因爲處理得快,來一個連接就accept一個,當服務器端accept 完第1019個連接,再次accept 時會因爲文件描述符總數超出限制,打印錯誤提示,而此時客戶端雖然已經創建了第1020個sock,但在connect 過程中發現對等方已經退出了,故打印錯誤提示,連接被對等方重置。如果服務器端處理得慢的話,那麼客戶端會connect 成功1021個連接,然後在創建第1022個sock 的時候出錯,打印錯誤提示:socket:
Too many open files,當然因爲文件描述符的限制,服務器端也只能從已完成連接隊列中accept 成功1019個連接。
二、epoll與select、poll區別
1、相比於select與poll,epoll最大的好處在於它不會隨着監聽fd數目的增長而降低效率。內核中的select與poll的實現是採用輪詢來處理的,輪詢的fd數目越多,自然耗時越多。
2、epoll的實現是基於回調的,如果fd有期望的事件發生就通過回調函數將其加入epoll就緒隊列中,也就是說它只關心“活躍”的fd,與fd數目無關。
3、內核 / 用戶空間 內存拷貝問題,如何讓內核把 fd消息通知給用戶空間呢?在這個問題上select/poll採取了內存拷貝方法。而epoll採用了內核和用戶空間共享內存的方式。
4、epoll不僅會告訴應用程序有I/0 事件到來,還會告訴應用程序相關的信息,這些信息是應用程序填充的,因此根據這些信息應用程序就能直接定位到事件,而不必遍歷整個fd集合。
5、當已連接的套接字數量不太大,並且這些套接字都非常活躍,那麼對於epoll 來說一直在調用callback 函數(epoll 內部的實現更復雜,更復雜的代碼邏輯),可能性能沒有poll 和 select 好,因爲一次性遍歷對活躍的文件描述符處理,在連接數量不大的情況下,性能更好,但在處理大量連接的情況時,epoll 明顯佔優。
補充:
select的特點:select 選擇句柄的時候,是遍歷所有句柄,也就是說句柄有事件響應時,select需要遍歷所有句柄才能獲取到哪些句柄有事件通知,因此效率是非常低。但是如果連接很少的情況下,
select和epoll的LT觸發模式相比, 性能上差別不大。
這裏要多說一句,select支持的句柄數是有限制的, 同時只支持1024個,這個是句柄集合限制的,如果超過這個限制,很可能導致溢出,而且非常不容易發現問題, TAF就出現過這個問題, 調試了n天,才發現:)當然可以通過修改linux的socket內核調整這個參數。
epoll的特點:epoll對於句柄事件的選擇不是遍歷的,是事件響應的,就是句柄上事件來就馬上選擇出來,不需要遍歷整個句柄鏈表,因此效率非常高,內核將句柄用紅黑樹保存的。
對於epoll而言還有ET和LT的區別,LT表示水平觸發,ET表示邊緣觸發,兩者在性能以及代碼實現上差別也是非常大的。
EPOLL的LT與ET的深入說明:
LT:水平觸發,效率會低於ET觸發,尤其在大併發,大流量的情況下。但是LT對代碼編寫要求比較低,不容易出現問題。LT模式服務編寫上的表現是:只要有數據沒有被獲取,內核就不斷通知你,因此不用擔心事件丟失的情況。
ET:邊緣觸發,效率非常高,在併發,大流量的情況下,會比LT少很多epoll的系統調用,因此效率高。但是對編程要求高,需要細緻的處理每個請求,否則容易發生丟失事件的情況。
下面舉一個列子來說明LT和ET的區別(都是非阻塞模式,阻塞就不說了,效率太低):
採用LT模式下, 如果accept調用有返回就可以馬上建立當前這個連接了,再epoll_wait等待下次通知,和select一樣。
但是對於ET而言,如果accpet調用有返回,除了建立當前這個連接外,不能馬上就epoll_wait還需要繼續循環accpet,直到返回-1,且errno==EAGAIN,TAF裏面的示例代碼:
if(ev.events& EPOLLIN)
{
do
{
struct sockaddr_in stSockAddr;
socklen_t iSockAddrSize = sizeof(sockaddr_in);
TC_Socket cs;
cs.setOwner(false);
//接收連接
TC_Socket s;
s.init(fd, false, AF_INET);
int iRetCode = s.accept(cs, (struct sockaddr*) &stSockAddr, iSockAddrSize);
if (iRetCode > 0)
{
...建立連接
}
else
{
//直到發生EAGAIN纔不繼續accept
if(errno == EAGAIN)
{
break;
}
}
}while(true);
}
同樣,recv/send等函數, 都需要到errno==EAGAIN
讀數據的時候需要考慮的是當recv()返回的大小如果等於請求的大小,那麼很有可能是緩衝區還有數據未讀完,也意味着該次事件還沒有處理完,所以還需要再次讀取:
while(rs)
{
buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
if(buflen < 0)
{
// 由於是非阻塞的模式,所以當errno爲EAGAIN時,表示當前緩衝區已無數據可讀
// 在這裏就當作是該次事件已處理處.
if(errno == EAGAIN)
break;
else
return;
}
else if(buflen == 0)
{
// 這裏表示對端的socket已正常關閉.
}
if(buflen == sizeof(buf)
rs = 1; // 需要再次讀取
else
rs = 0;
}
還有,假如發送端流量大於接收端的流量(意思是epoll所在的程序讀比轉發的socket要快),由於是非阻塞的socket,那麼send()函數雖然返回,但實際緩衝區的數據並未真正發給接收端,這樣不斷的讀和發,當緩衝區滿後會產生EAGAIN錯誤(參考man send),同時,不理會這次請求發送的數據.所以,需要封裝socket_send()的函數用來處理這種情況,該函數會盡量將數據寫完再返回,返回-1表示出錯。在socket_send()內部,當寫緩衝已滿(send()返回-1,且errno爲EAGAIN),那麼會等待後再重試.這種方式並不很完美,在理論上可能會長時間的阻塞在socket_send()內部,但暫沒有更好的辦法.
ssize_t socket_send(int sockfd, const char* buffer, size_t buflen)
{
ssize_t tmp;
size_t total = buflen;
const char *p = buffer;
while(1)
{
tmp = send(sockfd, p, total, 0);
if(tmp < 0)
{
// 當send收到信號時,可以繼續寫,但這裏返回-1.
if(errno == EINTR)
return -1;
// 當socket是非阻塞時,如返回此錯誤,表示寫緩衝隊列已滿,
// 在這裏做延時後再重試.
if(errno == EAGAIN)
{
usleep(1000);
continue;
}
return -1;
}
if((size_t)tmp == total)
return buflen;
total -= tmp;
p += tmp;
}
return tmp;
}
從本質上講:與LT相比,ET模型是通過減少系統調用來達到提高並行效率的。
EPOLL ET詳解:
ET模型的邏輯:內核的讀buffer有內核態主動變化時,內核會通知你, 無需再去mod。寫事件是給用戶使用的,最開始add之後,內核都不會通知你了,你可以強制寫數據(直到EAGAIN或者實際字節數小於 需要寫的字節數),當然你可以主動mod OUT,此時如果句柄可以寫了(send buffer有空間),內核就通知你。
這裏內核態主動的意思是:內核從網絡接收了數據放入了讀buffer(會通知用戶IN事件,即用戶可以recv數據)
並且這種通知只會通知一次,如果這次處理(recv)沒有到剛纔說的兩種情況(EAGIN或者實際字節數小於 需要讀寫的字節數),則該事件會被丟棄,直到下次buffer發生變化。
與LT的差別就在這裏體現,LT在這種情況下,事件不會丟棄,而是隻要讀buffer裏面有數據可以讓用戶讀,則不斷的通知你。
另外對於ET而言,當然也不一定非send/recv到前面所述的結束條件才結束,用戶可以自己隨時控制,即用戶可以在自己認爲合適的時候去設置IN和OUT事件:
1 如果用戶主動epoll_mod OUT事件,此時只要該句柄可以發送數據(發送buffer不滿),則epoll
_wait就會響應(有時候採用該機制通知epoll_wai醒過來)。
2 如果用戶主動epoll_mod IN事件,只要該句柄還有數據可以讀,則epoll_wait會響應。
這種邏輯在普通的服務裏面都不需要,可能在某些特殊的情況需要。 但是請注意,如果每次調用的時候都去epoll mod將顯著降低效率。
因此採用et寫服務框架的時候,最簡單的處理就是:
建立連接的時候epoll_add IN和OUT事件, 後面就不需要管了
每次read/write的時候,到兩種情況下結束:
1 發生EAGAIN
2 read/write的實際字節數小於 需要讀寫的字節數
對於第二點需要注意兩點:
A:如果是UDP服務,處理就不完全是這樣,必須要recv到發生EAGAIN爲止,否則就丟失事件了
因爲UDP和TCP不同,是有邊界的,每次接收一定是一個完整的UDP包,當然recv的buffer需要至少大於一個UDP包的大小
隨便再說一下,一個UDP包到底應該多大?
對於internet,由於MTU的限制,UDP包的大小不要超過576個字節,否則容易被分包,對於公司的IDC環境,建議不要超過1472,否則也比較容易分包。
B 如果發送方發送完數據以後,就close連接,這個時候如果recv到數據是實際字節數小於讀寫字節數,根據開始所述就認爲到EAGIN了從而直接返回,等待下一次事件,這樣是有問題的,close事件丟失了!
因此如果依賴這種關閉邏輯的服務,必須接收數據到EAGIN爲止,例如lb。
按照我目前的瞭解,EPOLL模型似乎只有一種格式,所以大家只要參考我下面的代碼,就能夠對EPOLL有所瞭解了,代碼的解釋都已經在註釋中:
while(TRUE)
{
int nfds = epoll_wait (m_epoll_fd, m_events, MAX_EVENTS, EPOLL_TIME_OUT);//等待EPOLL時間的發生,相當於監聽,至於相關的端口,需要在初始化EPOLL的時候綁定。
if (nfds <= 0)
continue;
m_bOnTimeChecking = FALSE;
G_CurTime = time(NULL);
for (int i=0; i<nfds; i++)
{
try
{
if (m_events[i].data.fd == m_listen_http_fd)//如果新監測到一個HTTP用戶連接到綁定的HTTP端口,建立新的連接。由於我們新採用了SOCKET連接,所以基本沒用。
{
OnAcceptHttpEpoll ();
}
else if (m_events[i].data.fd == m_listen_sock_fd)//如果新監測到一個SOCKET用戶連接到了綁定的SOCKET端口,建立新的連接。
{
OnAcceptSockEpoll ();
}
else if (m_events[i].events & EPOLLIN)//如果是已經連接的用戶,並且收到數據,那麼進行讀入。
{
OnReadEpoll (i);
}
OnWriteEpoll (i);//查看當前的活動連接是否有需要寫出的數據。
}
catch (int)
{
PRINTF ("CATCH捕獲錯誤/n");
continue;
}
}
m_bOnTimeChecking = TRUE;
OnTimer ();//進行一些定時的操作,主要就是刪除一些短線用戶等。
}