概述
如果我們要開發一個高併發的TCP
程式。常規的做法是:多程式或者多執行緒。即:使用其中一個執行緒或者程式去監聽有沒有客戶端連線上來,一旦有新客戶端連線,就新開一個執行緒(程式),將其扔到執行緒(或程式)中去處理具體的讀寫操作等業務邏輯,主執行緒(程式)繼續等待,監聽其他的客戶端。
這樣操作往往存在很大的弊端。首先是浪費資源,要知道,單個程式的最大虛擬記憶體是4G
,單個執行緒的虛擬記憶體也有將近8M
,那麼,如果上萬個客戶端連線上來,伺服器將會承受不住。
其次是浪費時間,因為你必須一直等在accept
那個地方,十分被動。
上述的網路模型,其實說白了,就是一個執行緒一路IO
,在單個執行緒裡只能處理一個IO
。因此,也可稱之為單路IO
。而一路IO
,就是一個併發。有多少個併發,就必須要開啟多少個執行緒,因此,對資源的浪費是不言而喻的。
那麼,有沒有一種方式,可以在一個執行緒裡,處理多路IO
呢?
我們回顧一下多執行緒模型 ,它最大的技術難點是accept
和recv
函式都是阻塞的。只要沒有新連線上來,accept
就阻塞住了,無法處理後續的業務邏輯;沒有資料過來,recv
又阻塞住了 ,無法處理新的accept
請求。因此,只要能夠搞定在同一個執行緒裡同時accept
和recv
的問題,似乎所有問題就迎刃而解了。
有人說,這怎麼可能嘛?肯定要兩個執行緒的 。
還真有可能,而這所謂的可能 ,就是IO
多路複用技術。
IO多路複用
所謂的IO
多路複用,它的核心思想就是,把監聽新客戶端連線、讀寫事件等的操作轉包出去,讓系統核心來做這件事情。即由核心來負責監聽有沒有連線建立、有沒有讀寫請求,作為服務端,只需要註冊相應的事件,當事件觸發時,由核心通知服務端程式去處理就行了。
這樣做的好處顯而易見:只需要在一個主執行緒裡,就可以完成所有的工作,既不會阻塞,也不會浪費太多資源。
說得通俗易懂一些,就是原來需要由主執行緒乾的活,現在都交給核心去幹了。我們不用阻塞在accept
和recv
那裡,而是由核心告訴程式,有新客戶端連線上來了 ,或者有資料傳送過來了,我們再去呼叫accept
和recv
就行了,其餘時間,我們可以處理其他的業務邏輯。
那麼有人問了,你不還是要呼叫accept
和recv
嗎?為什麼現在就不會阻塞了呢 ?
這就要深入說一下listen
和accept
的關係了。
假如伺服器是海底撈火鍋店的話,listen
就是門口迎賓的小姐,當來了一個客人(客戶端),就將其迎進店內。而accept
則是店內的大堂經理 ,當沒人來的時候,就一直閒在那裡沒事做,listen
將客人 迎進來之後,accept
就會分配一個服務員(fd
)專門服務於這個客人。
所以說,只要listen
正常工作,就能源源不斷地將客人迎進飯店(客戶端能正常連線上伺服器),即使此時並沒有accept
。那麼,有人肯定有疑問,總不能一直往裡迎吧,酒店也是有大小的,全部擠在大堂也裝不下那麼多人啊。還記得listen
函式的第二個引數backlog
嗎?它就表示在沒有accept
之前,最多可以迎多少個客人進來。
因此,對於多執行緒模型來說,accept
作為大堂經理,在沒客人來的時候,就眼巴巴地盯著門口 ,啥也不幹,當listen
把人迎進來了,才開始幹活。只能說,摸魚,還是accpet
會啊。
而IO
多路複用,則相當於請了一個秘書。accept
作為大堂經理,肯定有很多其他事情可以忙,他就不用一直盯著門口,當listen
把人迎進來之後,秘書就會把客人(們)帶到經理身邊,讓經理安排服務員(fd
)。
只是這個秘書是核心提供的,因此不僅免費,而且勤快。免費的勞動力,何樂而不為呢?
它的流程圖大概是下面這樣子的:
我們通常所說的IO
多路複用技術,在Linux
環境下,主要有三種實現,分別為select
、poll
和epoll
,當然還有核心新增的io_uring
。在darwin
平臺 ,則有kqueue
,Windows
下則是iocp
。從效能上來說,iocp
要優於epoll
,與io_uring
不相上下。但select
、poll
、epoll
的演變是一個持續迭代的過程,雖說從效率以及使用普及率上來說,epoll
堪稱經典,但並不是另外兩種實現就毫無用處,也是有其存在的意義的,尤其是select
。
本文不會花太多筆墨來介紹kqueue
,筆者始終認為,拿MacOS
作為伺服器開發,要麼腦子瓦特了,要麼就是錢燒的。基本上除了自己寫寫demo
外,極少能在生產環境真正用起來。而iocp
自成一派,未來有暇,將專門開闢專題細說。io_uring
作為較新的核心才引入的特性,本文也不宜大肆展開。
唯有select
、poll
以及epoll
,久經時間考驗,已被廣泛運用於各大知名網路應用,並由此誕生出許多經典的網路模型,實在是值得好好細說。
select
原型
select函式原型:
/* According to POSIX.1-2001, POSIX.1-2008 */
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
引數說明:
- nfds: 最大的檔案描述符+1,代表監聽這一組描述符(為什麼要+1?因為除了當前最大描述符之外,還有可能有新的fd連線上來)
- fd_set: 是一個點陣圖集合, 對於同一個檔案描述符,可以監聽不同的事件
- readfds:檔案描述符“可讀”事件
- writefds:檔案描述符“可寫”事件
- exceptfds:檔案描述符“異常”事件,一般核心用的,實際程式設計很少使用
- timeout:超時時間:0是立即返回,-1是一直阻塞,如果大於0,則達到設定值的微秒數即返回
- 返回值: 所監聽的所有監聽集合中滿足條件的總數(滿足條件的讀、寫、異常事件的總數),出錯時返回-1,並設定errno。如果超時時間觸發,則返回0。
從select
的函式原型可知,它主要依賴於三個bitmap
的集合,分別為可讀事件集合,可寫事件集合,以及異常事件集合。我們只需要將待監聽的fd
加入到對應的集合中,當有對應事件觸發,我們再從集合中將其拿出來進行處理就行了。
那麼,怎麼將檔案描述符加到監聽事件集合中呢?
核心為我們提供了四個操作宏:
void FD_CLR(int fd, fd_set *set); //將fd從set中清除出去,點陣圖置為0
int FD_ISSET(int fd, fd_set *set); //判斷fd是否在集合中,返回值為1,說明滿足了條件
void FD_SET(int fd, fd_set *set); //將fd設定到set中去,點陣圖置為1
void FD_ZERO(fd_set *set); //將set集合清空為0值
有了以上基礎,我們 就能大致梳理一下select
處理的流程:
- 建立
fd_set
點陣圖集合(3個集合,一個readfds
,一個writefds
,一個exceptfds
)FD_ZERO
將set
清空- 使用
FD_SET
將需要監聽的fd
設定對應的事件select
函式監聽事件,只要select
函式返回了大於1的值,說明有事件觸發,這時候把set
拿出來做判斷FD_ISSET
判斷fd
到底觸發了什麼事件
實現
其程式碼 實現如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>
int main(int argc, char *argv[]){
int i, n, maxi;
int nready, client[FD_SETSIZE]; // FD_SETSIZE 為核心定義的,大小為1024, client儲存已經被監聽的檔案描述符,避免每次都遍歷1024個fd
int maxfd, listenfd, connfd, sockfd;
char buf[BUFSIZ], str[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN = 16
struct sockaddr_in clie_addr, serv_addr;
socklen_t clie_addr_len;
fd_set rset, allset; //allset為所有已經被監聽的fd集合,rset為select返回的有監聽事件的fd
listenfd = socket(AF_INET, SOCK_STREAM, 0); //建立服務端fd
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(8888);
if (bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
}
listen(listenfd, 20);
maxfd = listenfd;
maxi = -1;
for(i = 0; i < FD_SETSIZE; i++) {
client[i] = -1;
}
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
//----------------------------------------------------------
//至此,初始化全部完成, 開始監聽
while(1) {
rset = allset; //allset不能被select改掉了,所以要複製一份出來放到rset
nready = select(maxfd+1, &rset, NULL, NULL, NULL);
if (nready < 0) {
perror("select");
}
//listenfd有返回,說明有新連線建立了
if (FD_ISSET(listenfd, &rset)) {
clie_addr_len = sizeof(clie_addr);
connfd = accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
printf("received form %s at port %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)), ntohs(clie_addr.sin_port));
//把新連線的client fd放到client陣列中
for (i = 0; i < FD_SETSIZE; i++) {
if (client[i] == -1) {
client[i] = connfd;
break;
}
}
//連線數超過了1024, select函式處理不了了,直接報錯返回
if (i == FD_SETSIZE) {
fputs("too many clients\n", stderr);
exit(1);
}
FD_SET(connfd, &allset); //把新的客戶端fd加入到下次要監聽的列表中
if (connfd > maxfd) {
maxfd = connfd; //主要給select第一個引數用的
}
if (i > maxi) {
maxi = i; //保證maxi存的總是client陣列的最後一個下標元素
}
//如果nready = 0, 說明新連線都已經處理完了,且沒有已建立好的連線觸發讀事件
if (--nready == 0) {
continue;
}
}
for (i = 0; i <= maxi; i++) {
if ((sockfd = client[i]) < 0) {
continue;
}
//sockfd 是存在client裡的fd,該函式觸發,說明有資料過來了
if (FD_ISSET(sockfd, &rset)) {
if ((n = read(sockfd, buf, sizeof(buf))) == 0) {
printf("socket[%d] closed\n", sockfd);
close(sockfd);
FD_CLR(sockfd, &allset);
client[i] = -1;
} else if (n > 0) {
//正常接收到了資料
printf("accept data: %s\n", buf);
}
if (--nready == 0) {
break;
}
}
}
}
close(listenfd);
return 0;
}
缺點
select
作為IO
多路複用的初始版本,只能說是能用而已,效能並不能高到哪兒去,使用的侷限性也比較大。主要體現在以下幾個方面:
- 檔案描述符上限:
1024
,同時監聽的最大檔案描述符也為1024
個select
需要遍歷所有的檔案描述符(1024
個),所以通常需要自定義資料結構(陣列),單獨存檔案描述符,減少遍歷- 監聽集合和滿足條件的集合是同一個集合,導致判斷和下次監聽時需要對集合讀寫,也就是說,下次監聽時需要清零,那麼當前的集合結果就需要單獨儲存。
優點
但select
也並不是一無是處,我個人是十分喜歡select
這個函式的,主要得益於以下幾個方面:
- 它至少提供了單執行緒同時處理多個
IO
的一種解決方案,在一些簡單的場景(比如併發小於1024
)的時候 ,還是很有用處的select
的實現比起poll
和epoll
,要簡單明瞭許多,這也是我為什麼推薦在一些簡單場景優先使用select
的原因select
是跨平臺的,相比於poll
和epoll
是Unix
獨有,select
明顯有更加廣闊的施展空間利用
select
的跨平臺特性,可以實現很多有趣的功能。比如實現一個跨平臺的sleep
函式。
- 我們知道,
Linux
下的原生sleep
函式是依賴於sys/time.h
的,這也就意味著它無法被Windows
平臺呼叫。- 因為
select
函式本身跨平臺,而第五個引數恰好是一個超時時間,即:我們可以傳入一個超時時間,此時程式就會阻塞在select
這裡,直到超時時間觸發,這也就間接地實現了sleep
功能。程式碼實現如下
//傳入一個微秒時間 void general_sleep(int t){ struct timeval tv; tv.tv_usec = t % 10e6; tv.tv_sec = t / 10e6; select(0, NULL, NULL, NULL, &tv); }
select
實現的sleep
函式至少有兩個好處:
- 可以跨平臺呼叫
- 精度可以精確到微秒級,比起
Linux
原生的sleep
函式,精度要高得多。
poll
原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
引數說明:
fds
: 陣列的首地址nfds
: 最大監聽的檔案描述符個數timeout
: 超時時間
鑑於select
函式的一些 缺點和侷限性,poll
的實現就做了一些升級。首先,它突破了1024
檔案描述符的限制,其次,它將事件封裝了一下 ,構成了pollfd
的結構體,並將這個 結構體中註冊的事件直接與fd
進行了繫結,這樣 就無需每次有事件觸發,就遍歷所有的fd
了,我們只需要遍歷這個 結構體陣列中的fd
即可。
那麼 ,poll
函式可以註冊哪些事件型別呢?
POLLIN 讀事件
POLLPRI 觸發異常條件
POLLOUT 寫事件
POLLRDHUP 關閉連線
POLLERR 發生了錯誤
POLLHUP 結束通話
POLLNVAL 無效請求,fd未開啟
POLLRDNORM 等同於POLLIN
POLLRDBAND 可以讀取優先帶資料(在 Linux 上通常不使用)。
POLLWRNORM 等同於POLLOUT
POLLWRBAND 可以寫入優先順序資料。
事件雖然比較多,但我們主要關心POLLIN
和POLLOUT
就行了。
實現
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#include <errno.h>
#include <ctype.h>
#define OPEN_MAX 1024
int main(int argc, char *argv[]){
int i, maxi, listenfd, connfd, sockfd;
int nready; // 接受poll返回值,記錄滿足監聽事件的fd個數
ssize_t n;
char buf[BUFSIZ], str[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN = 16
struct pollfd client[OPEN_MAX]; //用來存放監聽檔案描述符和事件的集合
struct sockaddr_in cliaddr, servaddr;
socklen_t clilen;
listenfd = socket(AF_INET, SOCK_STREAM, 0); //建立服務端fd
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //設定埠複用
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8888);
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind");
}
listen(listenfd, 128);
//設定第一個要監聽的檔案描述符,即服務端的檔案描述符
client[0].fd = listenfd;
client[0].events = POLLIN; //監聽讀事件
for(i = 1; i < OPEN_MAX; i++) { //注意從1開始,因為0已經被listenfd用了
client[i].fd = -1;
}
maxi = 0; //因為已經加進去一個了,所以從0開始就行
//----------------------------------------------------------
//至此,初始化全部完成, 開始監聽
for(;;) {
nready = poll(client, maxi+1, -1); // 阻塞監聽是否有客戶端讀事件請求
if (client[0].revents & POLLIN) { // listenfd觸發了讀事件
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen); //接受新客戶端的連線請求
printf("recieved from %s at port %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port));
for (i = 0; i < OPEN_MAX; i++) {
if (client[i].fd < 0) {
client[i].fd = connfd; // 將新連線的檔案描述符加到client陣列中
break;
}
}
if (i == OPEN_MAX) {
perror("too many open connections");
}
client[i].events = POLLIN;
if(i > maxi) {
maxi = i;
}
if (--nready == 0) {
continue;
}
}
//前面的if沒滿足,說明有資料傳送過來,開始遍歷client陣列
for (i = 1; i <= maxi; i++) {
if ((sockfd = client[i].fd) < 0) {
continue;
}
//讀事件滿足,用read去接受資料
if (client[i].revents & POLLIN) {
if ((n = read(sockfd, buf, sizeof(buf))) < 0) {
if (errno = ECONNRESET) { // 收到RST標誌
printf("client[%d] aborted conection\n", client[i].fd);
close(sockfd);
client[i].fd = -1; //poll中不監控該檔案描述符,直接置-1即可,無需像select中那樣移除
} else {
perror("read error");
}
} else if (n == 0) { //客戶端關閉連線
printf("client[%d] closed connection\n", client[i].fd);
close(sockfd);
client[i].fd = -1;
} else {
printf("recieved data: %s\n", buf);
}
}
if (--nready <= 0) {
break;
}
}
}
close(listenfd);
return 0;
}
優點
poll
函式相比於select
函式來說,最大的優點就是突破了1024
個檔案描述符的限制,這使得百萬併發變得可能。
而且不同於select
,poll
函式的監聽和返回是分開的,因此不用在每次操作之前都單獨備份一份了,簡化了程式碼實現。因此,可以理解為select
的升級增強版。
缺點
雖然poll
不需要遍歷所有的檔案描述符了,只需要遍歷加入陣列中的描述符,範圍縮小了很多,但缺點仍然是需要遍歷。假設真有百萬併發的場景,當僅有兩三個事件觸發的時候,仍然要遍歷上百萬個檔案描述符,只為了找到那觸發事件的兩三個fd
,這樣看來 ,就有些得不償失了。而這個缺點,將在epoll
中得以徹底解決。
poll
作為 一個過度版本的實現 ,說實話地位有些尷尬:它既不具備select
函式跨平臺的優勢,又不具備epoll
的高效能。因此使用面以及普及程度相對來說,反而是三者之中最差勁的一個。
若說它的唯一使用場景,大概也就是開發者既想突破1024
檔案描述符的限制,又不想把程式碼寫得像epoll
那樣複雜了。
epoll
原型
epoll
可謂是當前IO
多路複用的最終形態,它是poll
的 增強版本。我們說poll
函式,雖然突破了select
函式1024
檔案描述符的限制,且把監聽事件和返回事件分開了,但是說到底還是要遍歷所有檔案描述符,才能知道到底是哪個檔案描述符觸發了事件,或者需要單獨定義一個陣列。
而epoll
則可以返回一個觸發了事件的所有描述符的陣列集合,在這個陣列集合裡,所有的檔案描述符都是需要處理的,就不需要我們再單獨定義陣列了。
雖然epoll
功能強大了,但是使用起來卻麻煩得多。不同於select
和poll
使用一個函式監聽即可,epoll
提供了三個函式。
epoll_create
首先,需要使用epoll_create
建立一個控制程式碼:
#include <sys/epoll.h>
int epoll_create(int size);
該函式返回一個檔案描述符,這個檔案描述符並不是 一個常規意義的檔案描述符,而是一個平衡二叉樹(準確來說是紅黑樹)的根節點。size
則是樹的大小,它代表你將監聽多少個檔案描述符。epoll_create
將按照傳入的大小,構造出一棵大小為size
的紅黑樹。
注意:這個size
只是建議值,實際核心並不一定侷限於size
的大小,可以監聽比size
更多的檔案描述符。但是由於平衡二叉樹增加節點時可能需要自旋,如果size
與實際監聽的檔案描述符差別過大,則會增加核心開銷。
epoll_ctl
第二個函式是epoll_ctl
, 這個函式主要用來操作epoll
控制程式碼,可以使用該函式往紅黑樹裡增加檔案描述符,修改檔案描述符,和刪除檔案描述符。
可以看到,select
和poll
使用的都是bitmap
點陣圖,而epoll
使用的是紅黑樹。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl
有四個引數,引數1就是epoll_create
建立出來的控制程式碼。
第二個引數op
是操作標誌位,有三個值,分別如下:
- EPOLL_CTL_ADD 向樹增加檔案描述符
- EPOLL_CTL_MOD 修改樹中的檔案描述符
- EPOLL_CTL_DEL 刪除樹中的檔案描述符
第三個引數就是需要操作的檔案描述符,這個沒啥說的。
重點看第四個引數,它是一個結構體。這個結構體原型如下:
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 */
};
第一個元素為uint32_t
型別的events
,這個和poll
類似,是一個bit mask
,主要使用到的標誌位有:
- EPOLLIN 讀事件
- EPOLLOUT 寫事件
- EPOLLERR 異常事件
這個結構體還有第二個元素,是一個epoll_data_t
型別的聯合體。我們先重點關注裡面的fd
,它代表一個檔案描述符,初始化的時候傳入需要監聽的檔案描述符,當監聽返回時,此處會傳出一個有事件發生的檔案描述符,因此,無需我們遍歷得到結果了。
epoll_wait
epoll_wait
才是真正的監聽函式,它的原型如下:
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
第一個引數不用說了, 注意第二個引數,它雖然也是struct epoll_event *
型別,但是和epoll_ctl
中的含義不同,epoll_ctl
代表傳入進去的是一個地址,epoll_wait
則代表傳出的是一個陣列。這個陣列就是返回的所有觸發了事件的檔案描述符集合。
第三個引數maxevents
代表這個陣列的大小。
timeout
不用說了,它代表的是超時時間。不過要注意的是,0
代表立即返回,-1
代表永久阻塞,如果大於0
,則代表毫秒數(注意select
的timeout
是微秒)。
這個函式的返回值也是有意義的,它代表有多少個事件觸發,也就可以簡單理解為傳出引數events
的大小。
監聽流程
大致梳理一下epoll
的監聽流程:
- 首先,要有一個服務端的
listenfd
- 然後,使用
epoll_create
建立一個控制程式碼- 使用
epoll_ctl
將listenfd
加入到樹中,監聽EPOLLIN
事件- 使用
epoll_wait
監聽- 如果
EPOLLIN
事件觸發,說明有客戶端連線上來,將新客戶端加入到events
中,重新監聽- 如果再有
EPOLLIN
事件觸發:- 遍歷
events
,如果fd
是listenfd
,則說明又有新客戶端連線上來,重複上面的步驟,將新客戶端加入到events
中- 如果
fd
不為listenfd
,這說明客戶端有資料發過來,直接呼叫read
函式讀取內容即可。
觸發
epoll
有兩種觸發方式,分別為水平觸發和邊沿觸發。
水平觸發
所謂的水平觸發,就是隻要仍有資料處於就緒狀態,那麼可讀事件就會一直觸發。
舉個例子,假設客戶端一次性發來了
4K
資料 ,但是伺服器recv
函式定義的buffer
大小僅為1024
位元組,那麼一次肯定是不能將所有資料都讀取完的,這時候就會繼續觸發可讀事件,直到所有資料都處理完成。epoll
預設的觸發方式就是水平觸發。邊沿觸發
邊沿觸發恰好相反,邊沿觸發是隻有資料傳送過來的時候會觸發一次,即使資料沒有讀取完,也不會繼續觸發。必須
client
再次呼叫send
函式觸發了可讀事件,才會繼續讀取。假設客戶端 一次性發來
4K
資料,伺服器recv
的buffer
大小為1024
位元組,那麼伺服器在第一次收到1024
位元組之後就不會繼續,也不會有新的可讀事件觸發。只有當客戶端再次傳送資料的時候,伺服器可讀事件觸發 ,才會繼續讀取第二個1024
位元組資料。注意:第二次可讀事件觸發時,它讀取的仍然是上次未讀完的資料 ,而不是客戶端第二次發過來的新資料。也就是說:資料沒讀完雖然不會繼續觸發
EPOLLIN
,但不會丟失資料。觸發方式的設定:
水平觸發和邊沿觸發在核心裡 使用兩個
bit mask
區分,分別為:EPOLLLT 水平 觸發
EPOLLET 邊沿觸發我們只需要在註冊事件的時候將其與需要註冊的事件做一個位或運算即可:
ev.events = EPOLLIN; //LT ev.events = EPOLLIN | EPOLLET; //ET
實現
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
#include<ctype.h>
#define OPEN_MAX 1024
int main(int argc, char **argv){
int i, listenfd, connfd, sockfd,epfd, res, n;
ssize_t nready = 0;
char buf[BUFSIZ] = {0};
char str[INET_ADDRSTRLEN];
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
struct epoll_event event, events[OPEN_MAX];
//開始建立服務端套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8888);
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(listenfd, 128);
//開始初始化epoll
epfd = epoll_create(OPEN_MAX);
event.events = EPOLLIN;
event.data.fd = listenfd;
res = epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &event);
if (res == -1) {
perror("server epoll_ctl error");
exit(res);
}
for(;;) {
//開始監聽
nready = epoll_wait(epfd, events, OPEN_MAX, -1);
if (nready == -1) {
perror("epoll_wait error");
exit(nready);
}
for (i = 0; i < nready; i++) {
if (!(events[i].events & EPOLLIN)) {
continue;
}
if (events[i].data.fd == listenfd) {
//有新客戶端連線
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
printf("received from %s at port %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
event.events = EPOLLIN;
event.data.fd = connfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &event) == -1) {
perror("client epoll_ctl error");
exit(-1);
}
} else {
//有資料可以讀取
sockfd = events[i].data.fd;
n = read(sockfd, buf, sizeof(buf));
if (n ==0) {
//讀到0,說明客戶端關閉
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
close(sockfd);
printf("client[%d] closed connection\n", sockfd);
} else if (n < 0){
//出錯
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
close(sockfd);
printf("client[%d] read error\n", sockfd);
} else {
//讀到了資料
printf("received data: %s\n", buf);
}
}
}
}
close(listenfd);
close(epfd);
return 0;
}
優點
epoll
的優點顯而易見,它解決了poll
需要遍歷所有註冊的fd
的問題,只需要關心觸發了事件的極少量fd
即可,大大提升了效率。
而更有意思 的是epoll_data_t
這個聯合體,它裡面有四個元素:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
簡單開發時,我們可以將fd
記錄在其中,但是我們注意到 這裡面還有一個void *
型別的元素,那就提供了無限可能。它可以是一個struct
,也可以是一個callback
,也可以是struct
巢狀callback
,從而實現無線的擴充套件可能。大名鼎鼎的reactor
反應堆模型就是透過這種方式完成的。
在下篇專題裡,筆者將帶大家走進reactor
模型,領略epoll
的神奇魅力。
缺點
什麼?epoll
也有缺點?當然有,我認為epoll
的最大缺點就是程式碼實現起來變得複雜了,寫起來複雜,理解起來更復雜。
而且還有一個不能算缺點的缺點,對於筆者這樣一個長期開發跨平臺應用程式的開發者來說,epoll
雖好,但無法實現一套跨平臺的介面封裝,卻過於雞肋了。