下面有一張圖,這張圖來自 The Linux Programming Interface(No Starch Press)。它直觀地爲我們展示了 select、poll、epoll 幾種不同的 I/O 複用技術在面對不同文件描述符大小時的表現差異。
從圖中可以明顯地看到,epoll 的性能是最好的,即使在多達 10000 個文件描述的情況下,其性能的下降和有 10 個文件描述符的情況相比,差別也不是很大。而隨着文件描述符的增大,常規的 select 和 poll 方法性能逐漸變得很差。
epoll 的用法
epoll 可以說是和 poll 非常相似的一種 I/O 多路複用技術,有些朋友將 epoll 歸爲異步 I/O,我覺得這是不正確的。本質上 epoll 還是一種 I/O 多路複用技術, epoll 通過監控註冊的多個描述字,來進行 I/O 事件的分發處理。不同於 poll 的是,epoll 不僅提供了默認的 level-triggered(條件觸發)機制,還提供了性能更爲強勁的 edge-triggered(邊緣觸發)機制。
使用 epoll 進行網絡程序的編寫,需要三個步驟,分別是 epoll_create,epoll_ctl 和 epoll_wait。
epoll_create:
int epoll_create(int size);
int epoll_create1(int flags);
返回值: 若成功返回一個大於0的值,表示epoll實例;若返回-1表示出錯
epoll_create() 方法創建了一個 epoll 實例,從 Linux 2.6.8 開始,參數 size 被自動忽略,但是該值仍需要一個大於 0 的整數。這個 epoll 實例被用來調用 epoll_ctl 和 epoll_wait,如果這個 epoll 實例不再需要,比如服務器正常關機,需要調用 close() 方法釋放 epoll 實例,這樣系統內核可以回收 epoll 實例所分配使用的內核資源。
參數 size,在一開始的 epoll_create 實現中,是用來告知內核期望監控的文件描述字大小,然後內核使用這部分的信息來初始化內核數據結構。在新的實現中,這個參數不再被需要,因爲內核可以動態分配需要的內核數據結構。我們只需要注意,每次將 size 設置成一個大於 0 的整數就可以了。
epoll_create1() 的用法和 epoll_create() 基本一致,如果 epoll_create1() 的輸入 flags 爲 0,則和 epoll_create() 一樣,內核自動忽略。可以增加如 EPOLL_CLOEXEC 的額外選項,如果你有興趣的話,可以研究一下這個選項有什麼意義。
epoll_ctl:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
返回值: 若成功返回0;若返回-1表示出錯
在創建完 epoll 實例之後,可以通過調用 epoll_ctl 往這個 epoll 實例增加或刪除監控的事件。
第一個參數 epfd 是剛剛調用 epoll_create 創建的 epoll 實例描述字,可以簡單理解成是 epoll 句柄。
第二個參數表示增加還是刪除一個監控事件,它有三個選項可供選擇:
- EPOLL_CTL_ADD: 向 epoll 實例註冊文件描述符對應的事件。
- EPOLL_CTL_DEL:向 epoll 實例刪除文件描述符對應的事件。
- EPOLL_CTL_MOD: 修改文件描述符對應的事件。
第三個參數是註冊的事件的文件描述符,比如一個監聽套接字。
第四個參數表示的是註冊的事件類型,並且可以在這個結構體裏設置用戶需要的數據,其中最爲常見的是使用聯合結構裏的 fd 字段,表示事件所對應的文件描述符。
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 */
};
我們在前面介紹 poll 的時候已經接觸過基於 mask 的事件類型了,這裏 epoll 仍舊使用了同樣的機制,我們重點看一下這幾種事件類型:
- EPOLLIN:表示對應的文件描述字可以讀;
- EPOLLOUT:表示對應的文件描述字可以寫;
- EPOLLRDHUP:表示套接字的一端已經關閉,或者半關閉;
- EPOLLHUP:表示對應的文件描述字被掛起;
- EPOLLET:設置爲 edge-triggered,默認爲 level-triggered。
epoll_wait:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
返回值: 成功返回的是一個大於0的數,表示事件的個數;返回0表示的是超時時間到;若出錯返回-1.
epoll_wait() 函數類似之前的 poll 和 select 函數,調用者進程被掛起,在等待內核 I/O 事件的分發。
第一個參數是 epoll 實例描述字,也就是 epoll 句柄。
第二個參數返回給用戶空間需要處理的 I/O 事件,這是一個數組,數組的大小由 epoll_wait 的返回值決定,這個數組的每個元素都是一個需要待處理的 I/O 事件,其中 events 表示具體的事件類型,事件類型取值和 epoll_ctl 可設置的值一樣,這個 epoll_event 結構體裏的 data 值就是在 epoll_ctl 那裏設置的 data,也就是用戶空間和內核空間調用時需要的數據。
第三個參數是一個大於 0 的整數,表示 epoll_wait 可以返回的最大事件值。
epoll 例子
#define MAXEVENTS 128
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
int main(int argc, char **argv) {
int listen_fd, socket_fd;
int n, i;
int efd;
struct epoll_event event;
struct epoll_event *events;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(listen_fd, F_SETFL, O_NONBLOCK);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);
int on = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
int rt1 = bind(listen_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
printf("bind failed \n");
exit(0);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
printf("listen failed \n");
exit(0);
}
signal(SIGPIPE, SIG_IGN);
efd = epoll_create1(0);
if (efd == -1) {
printf("epoll create failed\n");
return 0;
}
event.data.fd = listen_fd;
event.events = EPOLLIN | EPOLLET;
/*調用 epoll_ctl 將監聽套接字對應的 I/O 事件進行了註冊,這樣在有新的連接建立之後,就可以感知到。注意這裏使用的是 edge-triggered(邊緣觸發)*/
if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
printf("epoll_ctl add listen fd failed\n");
}
/* Buffer where events are returned */
events = calloc(MAXEVENTS, sizeof(event));
/*主循環調用 epoll_wait 函數分發 I/O 事件,當 epoll_wait 成功返回時,通過遍歷返回的 event 數組,就直接可以知道發生的 I/O 事件*/
while (1) {
n = epoll_wait(efd, events, MAXEVENTS, -1);
printf("epoll_wait wakeup\n");
for (i = 0; i < n; i++) {
if ((events[i].events & EPOLLERR) ||
(events[i].events & EPOLLHUP) ||
(!(events[i].events & EPOLLIN))) {
fprintf(stderr, "epoll error\n");
close(events[i].data.fd);
continue;
} else if (listen_fd == events[i].data.fd) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
} else {
fcntl(fd, F_SETFL, O_NONBLOCK);
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET; //edge-triggered
/*調用 epoll_ctl 把已連接套接字對應的可讀事件註冊到 epoll 實例中*/
if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {
printf("epoll_ctl add connection fd failed\n");
}
}
continue;
} else {//處理了已連接套接字上的可讀事件,讀取字節流,編碼後再回應給客戶端
socket_fd = events[i].data.fd;
printf("get event on socket fd == %d \n", socket_fd);
while (1) {
char buf[512];
if ((n = read(socket_fd, buf, sizeof(buf))) < 0) {
if (errno != EAGAIN) {
printf("read error\n");
close(socket_fd);
}
break;
} else if (n == 0) {
close(socket_fd);
break;
} else {
for (i = 0; i < n; ++i) {
buf[i] = rot13_char(buf[i]);
}
if (write(socket_fd, buf, n) < 0) {
printf("write error\n");
}
}
}
}
}
}
free(events);
close(listen_fd);
}
edge-triggered VS level-triggered
兩者的區別,條件觸發的意思是隻要滿足事件的條件,比如有數據需要讀,就一直不斷地把這個事件傳遞給用戶;而邊緣觸發的意思是隻有第一次滿足條件的時候才觸發,之後就不會再傳遞同樣的事件了。一般我們認爲,邊緣觸發的效率比條件觸發的效率要高,這一點也是 epoll 的殺手鐗之一。
總之, Linux 中 epoll 的出現,爲高性能網絡編程補齊了最後一塊拼圖。epoll 通過改進的接口設計,避免了用戶態 - 內核態頻繁的數據拷貝,大大提高了系統性能。在使用 epoll 的時候,我們一定要理解條件觸發和邊緣觸發兩種模式。條件觸發的意思是隻要滿足事件的條件,比如有數據需要讀,就一直不斷地把這個事件傳遞給用戶;而邊緣觸發的意思是隻有第一次滿足條件的時候才觸發,之後就不會再傳遞同樣的事件了。
溫故而知新 !