前言
繼續更新“用 C 寫一個 web 服務器”項目(上期鏈接:用C寫一個web服務器(一) 基礎功能),本次更新選擇了 I/O 模型的優化,因為它是服務器的基礎,這個先完成的話,后面的優化就可以選擇各個模塊來進行,不必進行全局化的改動了。
I/O模型
接觸過 socket 編程的同學應該都知道一些 I/O 模型的概念,linux 中有阻塞 I/O、非阻塞 I/O、I/O 多路復用、信號驅動 I/O 和 異步 I/O 五種模型。
其他模型的具體概念這里不多介紹,只簡單地提一下自己理解的 I/O 多路復用:簡單的說就是由一個進程來管理多個 socket,即將多個 socket 放入一個表中,在其中有 socket 可操作時,通知進程來處理, I/O 多路復用的實現方式有 select、poll 和 epoll。
select/poll/epoll
在 linux下,通過文件描述符(file descriptor, 下 fd)來進行 socket 的操作,所以下文均是對 fd 操作。
首先說最開始實現的 select 的問題:
- select 打開的 fd 最大數目有限制,一般為1024,在當前計算系統的並發量前顯然有點不適用了。
- select 在收到有 fd 可操作的通知時,是無法得知具體是哪個 fd 的,需要線性掃描 fd 表,效率較低。
- 當有 fd 可操作時,fd 會將 fd 表復制到內核來遍歷,消耗也較大。
隨着網絡技術的發展,出現了 poll:poll 相對於 select,使用 pollfd 表(鏈表實現) 來代替 fd,它沒有上限,但受系統內存的限制,它同樣使用 fd 遍歷的方式,在並發高時效率仍然是一個問題。
最終,epoll 在 Linux 2.6 的內核面世,它使用事件機制,在每一個 fd 上添加事件,當fd 的事件被觸發時,會調用回調函數來處理對應的事件,epoll 的優勢總之如下:
- 只關心活躍的 fd,精確定位,改變了poll的時間效率 O(n) 到 O(1);
- fd 數量限制是系統能打開的最大文件數,會受系統內存和每個 fd 消耗內存的影響,以當前的系統硬件配置,並發數量絕對不是問題。
- 內核使用內存映射,大量 fd 向內核態的傳輸不再是問題。
為了一步到位,也是為了學習最先進的I/O多路復用模型,直接使用了 epoll 機制,接下來介紹一下 epoll 相關基礎和自己服務器的實現過程。
epoll介紹
epoll 需要引入<sys/epoll.h>文件,首先介紹一下 epoll 系列函數:
epoll_create
int epoll_create(int size);
創建一個 epoll 實例,返回一個指向此 epoll 實例的文件描述符,當 epoll 實例不再使用時,需要使用close()方法來關閉它。
在最初的實現中, size 作為期望打開的最大 fd 數傳入,以便系統分配足夠大的空間。在最新版本的內核中,系統內核動態分配內存,已不再需要此參數了,但為了避免程序運行在舊內核中會有問題,還是要求此值必須大於0;
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd 是通過 epoll_create 返回的文件描述符
-
op 則是文件描述符監聽事件的操作方式,
EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL分別表示添加、修改和刪除一個監聽事件。 -
fd 為要監聽的文件描述符。
-
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事件 */
epoll_data_t data; /* 事件相關數據 */
};
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 監聽 epoll 事件:
- events 是 epoll 事件數組,epoll 事件的結構上面已經介紹過。
- maxevents 是一次監聽獲取到的最大事件數目。
- timeout 是一次監聽中獲取不到事件的最長等待時間,設置成 -1 會一直阻塞等待,0 則會立即返回。
epoll行為
在 epoll_ctl 的 event 參數中,事件 events 有如下可選項:
EPOLLIN(可讀)、EPOLLOUT(可寫)、EPOLLRDHUP(連接關閉)、EPOLLPRI(緊急數據可讀),此外 EPOLLERR(錯誤),EPOLLHUP(連接掛斷)事件會被 epoll 默認一直監聽。
除了設置事件外,還可以對監聽的行為設置:
- level trigger:此行為被 epoll 默認支持,不必設置。在 epoll_wait 得到一個事件時,如果應用程序不處理此事件,在 level trigger 模式下,epoll_wait 會持續觸發此事件,直到事件被程序處理;
- EPOLLET(edge trigger):在 edge trigger 模式下,事件只會被 epoll_wait 觸發一次,如果用戶不處理此事件,不會在下次 epoll_wait 再次觸發。在處理得當的情況下,此模式無疑是高效的。需要注意的是此模式需求 socket 處理非阻塞模式,下面會實現此模式。
- EPOLLONESHOT:在單次命中模式下,對同一個文件描述符來說,同類型的事件只會被觸發一次,若想重復觸發,需要重新給文件描述符注冊事件。
- EPOLLWAKEUP:3.5版本加入,如果設置了單次命中和ET模式,而且進程有休眠喚醒能力,當事件被掛起和處理時,此選項確保系統不進入暫停或休眠狀態。 事件被 epoll_wait 調起后,直到下次 epoll_wait 再次調起此事件、文件描述符被關閉,事件被注銷或修改,都會被認為是處於處理中狀態。
- EPOLLEXCLUSIVE:4.5版本加入,為一個關聯到目標文件描述符的 epoll 句柄設置獨占喚醒模式。如果目標文件描述符被關聯到多個 epoll 句柄,當有喚醒事件發生時,默認所有 epoll 句柄都會被喚醒。而都設置此標識后,epoll 句柄之一被喚醒,以避免“驚群”現象。
當監聽事件和行為需求同時設置時,使用運算符 |即可。
代碼實現
整體處理邏輯
使用 epoll 時的服務器受理客戶端請求邏輯如下:
- 創建服務器 socket,注冊服務器 socket 讀事件;
- 客戶端連接服務器,觸發服務器 socket 可讀,服務器創建客戶端 socket,注冊客戶端socket 讀事件;
- 客戶端發送數據,觸發客戶端 socket 可讀,服務器讀取客戶端信息,將響應寫入 socket;
- 客戶端關閉連接,觸發客戶端 socket 可讀,服務器讀取客戶端信息為空,注銷客戶端 socket 讀事件;
代碼實現如下(詳細處理方式見 GitHub:我是地址):
erver_fd = server_start();
epoll_fd = epoll_create(FD_SIZE);
epoll_register(epoll_fd, server_fd, EPOLLIN|EPOLLET);// 這里注冊socketEPOLL事件為ET模式
while (1) {
event_num = epoll_wait(epoll_fd, events, MAX_EVENTS, 0);
for (i = 0; i < event_num; i++) {
fd = events[i].data.fd;
// 如果是服務器socket可讀,則處理連接請求
if ((fd == server_fd) && (events[i].events == EPOLLIN)){
accept_client(server_fd, epoll_fd);
// 如果是客戶端socket可讀,則獲取請求信息,響應客戶端
} else if (events[i].events == EPOLLIN){
deal_client(fd, epoll_fd);
} else if (events[i].events == EPOLLOUT)
// todo 數據過大,緩沖區不足的情況待處理
continue;
}
}
需要注意的是,客戶端socket在可讀之后也是立刻可寫的,我這里直接讀取一次請求,然后將響應信息 write 進去,沒有考慮讀數據時緩沖區滿的問題。
這里提出的解決方案為:
- 設置一個客戶端 socket 和 buffer 的哈希表;
- 在讀入一次信息緩沖區滿時 recv 會返回
EAGIN錯誤,這時將數據放入 buffer,暫時不響應。 - 后續讀事件中讀取到數據尾后,再注冊 socket 可寫事件。
- 在處理可寫事件時,讀取 buffer 內的全部請求內容,處理完畢后響應給客戶端。
- 最后注銷 socket 寫事件。
設置epoll ET(edge trigger)模式
上文說過,ET模式是 epoll 的高效模式,事件只會通知一次,但處理良好的情況下會更適用於高並發。它需要 socket 在非阻塞模式下才可用,這里我們實現它。
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
// 獲取服務器socket的設置,並添加"不阻塞"選項
flags = fcntl(sock_fd, F_GETFL, 0);
fcntl(sock_fd, F_SETFL, flags|O_NONBLOCK);
.....
// 這里注冊服務器socket EPOLL事件為ET模式
epoll_register(epoll_fd, server_fd, EPOLLIN|EPOLLET);
我將處理事件注掉后使用一次客戶端連接請求進行了測試,很清晰地說明了 ET模式下,事件只觸發一次的現象,前后對比圖如下:


小結
Mac OS X 操作系統的某些部分是基於 FreeBSD 的,FreeBSD 不支持,MAC 也不支持(不過有相似的 kqueue),跑到開發機上開發的,作為一個最基礎的 C learner, 靠着printf()和fflush()兩個函數來調試的,不過搞了很久總算是完成了,有用 C 的前輩推薦一下調試方式就最好了。。
另外 epoll 在最新的內核中也更新了些內容,舊的很多博客都沒有提到,話說照這樣的發展速度,我這篇也會在一段時間后“過時”吧,哈哈~
如果您覺得本文對您有幫助,可以點擊下面的 推薦 支持一下我。博客一直在更新,歡迎 關注 。
參考:
epoll interface detail (很不錯的英文文檔,推薦)
