1. 簡介
epoll 是 Linux 平台下特有的一種 I/O 複用模型實作,于 2002 年在 Linux kernel 2.5.44 中被引入。在 epoll 之前,Unix/Linux 平台下的 I/O 複用模型包含 select 和 poll 兩個系統調用。随着網際網路的發展,網際網路的使用者量越來越大,C10K 問題出現。基于 select 和 poll 編寫的網絡服務已經不能滿足不能滿足使用者的需求了,業界迫切希望更高效的系統調用出現。在此背景下,FreeBSD 的 kqueue 和 Linux 的 epoll 被研發了出來。kqueue 和 epoll 的出現,終結了 C10K 問題,C10K 問題就此作古。
因為 Linux 系統的廣泛應用,是以大家在說 I/O 複用時,更多的是想到了 epoll,而不是 kqueue,本文也不例外。本篇文章不會涉及 kqueue,大家有興趣可以自己看看。
2. 基于 epoll 實作 web 伺服器
在 Linux 中,epoll 并不是一個系統調用,而是 epoll_create、epoll_ctl 和 epoll_wait 三個系統調用的統稱。關于這三個系統調用的細節,這裡就不說明了,大家可以自己去查 man-page。接下來,我們來直接看一個例子,這個例子基于 epoll 和
TinyHttpd實作了一個 I/O 複用版的 HTTP Server。在上代碼前,我們先來示範這個玩具版 HTTP Server 的效果。

上面就是玩具版 HTTP Server 的運作效果了,看起來還行。在我第一次把它成功跑起來的時候,感覺很奇妙。好了,看完效果,接下來看代碼吧,如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/sysinfo.h>
#include <sys/epoll.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/types.h>
#include "httpd.h"
#define DEFAULT_PORT 8080
#define MAX_EVENT_NUM 1024
#define INFTIM -1
void process(int);
void handle_subprocess_exit(int);
int main(int argc, char *argv[])
{
struct sockaddr_in server_addr;
int listen_fd;
int cpu_core_num;
int on = 1;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(listen_fd, F_SETFL, O_NONBLOCK); // 設定 listen_fd 為非阻塞
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
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(DEFAULT_PORT);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind error, message: ");
exit(1);
}
if (listen(listen_fd, 5) == -1) {
perror("listen error, message: ");
exit(1);
}
printf("listening 8080\n");
signal(SIGCHLD, handle_subprocess_exit);
cpu_core_num = get_nprocs();
printf("cpu core num: %d\n", cpu_core_num);
// 根據 CPU 數量建立子程序,為了示範“驚群現象”,這裡多建立一些子程序
for (int i = 0; i < cpu_core_num * 2; i++) {
pid_t pid = fork();
if (pid == 0) { // 子程序執行此條件分支
process(listen_fd);
exit(0);
}
}
while (1) {
sleep(1);
}
return 0;
}
void process(int listen_fd)
{
int conn_fd;
int ready_fd_num;
struct sockaddr_in client_addr;
int client_addr_size = sizeof(client_addr);
char buf[128];
struct epoll_event ev, events[MAX_EVENT_NUM];
// 建立 epoll 執行個體,并傳回 epoll 檔案描述符
int epoll_fd = epoll_create(MAX_EVENT_NUM);
ev.data.fd = listen_fd;
ev.events = EPOLLIN;
// 将 listen_fd 注冊到剛剛建立的 epoll 中
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl error, message: ");
exit(1);
}
while(1) {
// 等待事件發生
ready_fd_num = epoll_wait(epoll_fd, events, MAX_EVENT_NUM, INFTIM);
printf("[pid %d] 震驚!我又被喚醒了...\n", getpid());
if (ready_fd_num == -1) {
perror("epoll_wait error, message: ");
continue;
}
for(int i = 0; i < ready_fd_num; i++) {
if (events[i].data.fd == listen_fd) { // 有新的連接配接
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_size);
if (conn_fd == -1) {
sprintf(buf, "[pid %d] accept 出錯了: ", getpid());
perror(buf);
continue;
}
// 設定 conn_fd 為非阻塞
if (fcntl(conn_fd, F_SETFL, fcntl(conn_fd, F_GETFD, 0) | O_NONBLOCK) == -1) {
continue;
}
ev.data.fd = conn_fd;
ev.events = EPOLLIN;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
perror("epoll_ctl error, message: ");
close(conn_fd);
}
printf("[pid %d] 收到來自 %s:%d 的請求\n", getpid(), inet_ntoa(client_addr.sin_addr), client_addr.sin_port);
} else if (events[i].events & EPOLLIN) { // 某個 socket 資料已準備好,可以讀取了
printf("[pid %d] 處理來自 %s:%d 的請求\n", getpid(), inet_ntoa(client_addr.sin_addr), client_addr.sin_port);
conn_fd = events[i].data.fd;
// 調用 TinyHttpd 的 accept_request 函數處理請求
accept_request(conn_fd, &client_addr);
close(conn_fd);
} else if (events[i].events & EPOLLERR) {
fprintf(stderr, "epoll error\n");
close(conn_fd);
}
}
}
}
void handle_subprocess_exit(int signo)
{
printf("clean subprocess.\n");
int status;
while(waitpid(-1, &status, WNOHANG) > 0);
}
上面的代碼有點長,不過還好,基本上都是模闆代碼,沒什麼特别複雜的邏輯。希望大家耐心看一下。
上面的代碼基于
epoll + 多程序
的方式實作,開始,主程序會通過系統調用擷取 CPU 核心數,然後根據核心數建立子程序。為了示範“驚群現象”,這裡多建立了一倍的子程序。關于驚群現象,下一章會講到,大家先别急哈。建立好子程序後,主程序不需再做什麼事了,核心邏輯都會在子線程中執行。首先,每個子程序都會調用 epoll_create 在核心建立 epoll 執行個體,然後再通過 epoll_ctl 将 listen_fd 注冊到 epoll 執行個體中,由核心進行監控。最後,再調用 epoll_wait 等待感興趣的事件發生。當 listen_fd 中有新的連接配接時,epoll_wait 會傳回。此時子程序調用 accept 接受連接配接,并把用戶端 socket 注冊到 epoll 執行個體中,等待 EPOLLIN 事件發生。當該事件發生後,即可接受資料,并根據 HTTP 請求資訊傳回相應的頁面了。
這裡說明一下,上面代碼中處理 HTTP 請求的邏輯是寫在
項目中的,TinyHttpd 是一個隻有 500 行左右的超輕量型Http Server,很适合學習使用。為了适應需求,我對其源碼進行了一定的修改,并添加了一些注釋。本章的測試代碼已經放到了 github 上,需要的同學自取,傳送門 ->
epoll_multiprocess_server.c。
3. 驚群及示範
“驚群現象”是指并發環境下,多線程或多程序等待同一個 socket 事件,當這個事件發生時,多線程/多程序被同時喚醒,這就是“驚群現象”。對應上面的代碼,多個子程序通過調用 epoll_wait 等待 listen_fd 上某個事件發生。當有新連接配接進來時,多個程序會被同時喚醒去處理這個事件。但最終隻有一個程序可以去處理事件,其他程序重新進入等待狀态。使用上面的代碼可以示範驚群現象,如下:
從上圖可以看出,當 listen_fd 上有新連接配接事件發生時,程序19571和19573被喚醒。但最終程序19573成功處理了新連接配接事件,程序19571則失敗了。
驚群現象會影響伺服器性能,因為多個程序被喚醒,但最終隻有一個程序可以成功處理事件。而 CPU 需要為一個事件的發生排程數個程序,是以會浪費 CPU 資源。
對于驚群現象,處理的思路一般有兩種。一種是像 Lighttpd 那樣,無視驚群。另一種是像 Nginx 那樣,使用全局鎖避免驚群。簡單起見,本文測試代碼采用的是 Lighttpd 的處理方式,即無視驚群。對于這兩種思路的細節,由于本人未讀過兩個開源軟體的代碼,這裡就不多說了。如果大家有興趣,可以參考網上的一些博文。
4. 總結
epoll 是 I/O 複用模型重要的一個實作,性能優異,應用廣泛。像 Linux 平台下的 JVM,NIO 部分就是基于 epoll 實作的。再如大名鼎鼎 Nginx 也是使用了 epoll。由此可以看出 epoll 的重要性,是以我們有很有必要去了解 epoll。本文通過一個測試程式簡單示範了一個基于 epoll 的 HTTP Server,總體上也達到了學習 epoll 的目的。大家如果有興趣,可以下載下傳源碼看看。當然,紙上學來終覺淺,還是要自己動手寫才行。本文的測試代碼是本人現學現賣寫的,僅測試使用,寫的不好的地方望諒解。
好了,本文到此結束,謝謝閱讀!
參考
本文在知識共享許可協定 4.0 下釋出,轉載需在明顯位置處注明出處
作者:coolblog
本文同步釋出在我的個人部落格:
http://www.coolblog.xyz
本作品采用
知識共享署名-非商業性使用-禁止演繹 4.0 國際許可協定進行許可。