聊聊select, poll 和 epoll
假設項目上需要實現一個TCP的客戶端和服務器從而進行跨機器的數據收發,我們很可能翻閱一些資料,然后寫出如下的代碼。
服務端
void func(int sockfd) { char buff[MAX]; int n; // infinite loop for chat
for (;;) { bzero(buff, MAX); // read the message from client and copy it in buffer
read(sockfd, buff, sizeof(buff)); // print buffer which contains the client contents
printf("From client: %s\t To client : ", buff); bzero(buff, MAX); n = 0; // copy server message in the buffer
while ((buff[n++] = getchar()) != '\n') ; // and send that buffer to client
write(sockfd, buff, sizeof(buff)); // if msg contains "Exit" then server exit and chat ended.
if (strncmp("exit", buff, 4) == 0) { printf("Server Exit...\n"); break; } } } // Driver function
int main() { int sockfd, connfd, len; struct sockaddr_in servaddr, cli; // socket create and verification
sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { printf("socket creation failed...\n"); exit(0); } else printf("Socket successfully created..\n"); bzero(&servaddr, sizeof(servaddr)); // assign IP, PORT
servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(PORT); // Binding newly created socket to given IP and verification
if ((bind(sockfd, (SA*)&servaddr, sizeof(servaddr))) != 0) { printf("socket bind failed...\n"); exit(0); } else printf("Socket successfully binded..\n"); // Now server is ready to listen and verification
if ((listen(sockfd, 5)) != 0) { printf("Listen failed...\n"); exit(0); } else printf("Server listening..\n"); len = sizeof(cli); // Accept the data packet from client and verification
connfd = accept(sockfd, (SA*)&cli, &len); if (connfd < 0) { printf("server acccept failed...\n"); exit(0); } else printf("server acccept the client...\n"); // Function for chatting between client and server
func(connfd); // After chatting close the socket
close(sockfd); }
客戶端
void func(int sockfd) { char buff[MAX]; int n; for (;;) { bzero(buff, sizeof(buff)); printf("Enter the string : "); n = 0; while ((buff[n++] = getchar()) != '\n') ; write(sockfd, buff, sizeof(buff)); bzero(buff, sizeof(buff)); read(sockfd, buff, sizeof(buff)); printf("From Server : %s", buff); if ((strncmp(buff, "exit", 4)) == 0) { printf("Client Exit...\n"); break; } } } int main() { int sockfd, connfd; struct sockaddr_in servaddr, cli; // socket create and varification
sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { printf("socket creation failed...\n"); exit(0); } else printf("Socket successfully created..\n"); bzero(&servaddr, sizeof(servaddr)); // assign IP, PORT
servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); servaddr.sin_port = htons(PORT); // connect the client socket to server socket
if (connect(sockfd, (SA*)&servaddr, sizeof(servaddr)) != 0) { printf("connection with the server failed...\n"); exit(0); } else printf("connected to the server..\n"); // function for chat
func(sockfd); // close the socket
close(sockfd); }
那么問題來了,如果有一個新的需求進來,現在需要你這個服務器程序同時支持多個客戶端連接,你怎么辦么呢?對的,這就引出了本文要聊的IO多路復用技術。
select,poll,epoll
select,poll,epoll都是IO多路復用的機制。I/O多路復用就是通過一種機制,一個進程可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函數監視的文件描述符分3類,分別是writefds、readfds、和exceptfds。調用后select函數會阻塞,直到有描述符就緒(有數據可讀、可寫、或者有exception),或者超時(timeout指定等待時間,如果立即返回設為null即可),函數返回。當select函數返回后,可以 通過遍歷fdset,來找到就緒的描述符。select目前幾乎在所有的平台上支持,其良好跨平台支持也是它的一個優點。select的一個缺點在於單個進程能夠監視的文件描述符的數量存在最大限制,在Linux上一般為1024,可以通過修改宏定義甚至重新編譯內核的方式提升這一限制,但是這樣也會造成效率的降低。
服務端示例
while (1) {
/*每次調用select前都要重新設置文件描述符和時間,因為事件發生后,文件描述符和時間都被內核修改啦*/
FD_ZERO(readfds);
/*添加監聽套接字*/
FD_SET(srvfd, readfds);
s_srv_ctx->maxfd = srvfd;
tv.tv_sec = 30;
tv.tv_usec = 0;
/*添加客戶端套接字*/
for (i = 0; i < s_srv_ctx->cli_cnt; i++) {
clifd = s_srv_ctx->clifds[i];
/*去除無效的客戶端句柄*/
if (clifd != -1) {
FD_SET(clifd, readfds);
}
s_srv_ctx->maxfd = (clifd > s_srv_ctx->maxfd ? clifd : s_srv_ctx->maxfd);
}
/*開始輪詢接收處理服務端和客戶端套接字*/
retval = select(s_srv_ctx->maxfd + 1, readfds, NULL, NULL, &tv);
if (retval == -1) {
fprintf(stderr, "select error:%s.\n", strerror(errno));
return;
}
if (retval == 0) {
fprintf(stdout, "select is timeout.\n");
continue;
}
if (FD_ISSET(srvfd, readfds)) {
/*監聽客戶端請求*/
accept_client_proc(srvfd);
} else {
/*接受處理客戶端消息*/
recv_client_msg(readfds);
}
}
}
poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同於select使用三個位圖來表示三個fdset的方式,poll使用一個pollfd的指針實現。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd結構包含了要監視的event和發生的event,不再使用select“參數-值”傳遞的方式。同時,pollfd並沒有最大數量限制(但是數量過大后性能也是會下降。和select函數一樣,poll返回后,需要輪詢pollfd來獲取就緒的描述符。從上面看,select和poll都需要在返回后,通過遍歷文件描述符來獲取已經就緒的socket。事實上,同時連接的大量客戶端在一時刻可能只有很少的處於就緒狀態,因此隨着監視的描述符數量的增長,其效率也會線性下降。
服務端示例
for ( ; ; )
{
//獲取可用描述符的個數
nready = poll(clientfds,maxi+1,INFTIM);
if (nready == -1)
{
perror("poll error:");
exit(1);
}
//測試監聽描述符是否准備好
if (clientfds[0].revents & POLLIN)
{
cliaddrlen = sizeof(cliaddr);
//接受新的連接
if ((connfd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen)) == -1)
{
if (errno == EINTR)
continue;
else
{
perror("accept error:");
exit(1);
}
}
fprintf(stdout,"accept a new client: %s:%d\n", inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);
//將新的連接描述符添加到數組中
for (i = 1;i < OPEN_MAX;i++)
{
if (clientfds[i].fd < 0)
{
clientfds[i].fd = connfd;
break;
}
}
if (i == OPEN_MAX)
{
fprintf(stderr,"too many clients.\n");
exit(1);
}
//將新的描述符添加到讀描述符集合中
clientfds[i].events = POLLIN;
//記錄客戶連接套接字的個數
maxi = (i > maxi ? i : maxi);
if (--nready <= 0)
continue;
}
//處理客戶連接
handle_connection(clientfds,maxi);
}
epoll
epoll是在2.6內核中提出的,是之前的select和poll的增強版本。相對於select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個文件描述符管理多個描述符,將用戶關系的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。
epoll操作過程
epoll操作過程需要三個接口,分別如下:
int epoll_create(int size);
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的值,參數size並不是限制了epoll所能監聽的描述符最大個數,只是對內核初始分配內部數據結構的一個建議。
當創建好epoll句柄后,它就會占用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函數是對指定描述符fd執行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三個宏來表示:添加EPOLL_CTL_ADD,刪除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分別添加、刪除和修改對fd的監聽事件。
- fd:是需要監聽的fd(文件描述符)
- epoll_event:是告訴內核需要監聽什么事,struct epoll_event結構如下:
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隊列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents個事件。
參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個maxevents的值不能大於創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。
工作模式
epoll對文件描述符的操作有兩種模式:LT(level trigger)和ET(edge trigger)。LT模式是默認模式,LT模式與ET模式的區別如下:
LT模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序可以不立即處理該事件。下次調用epoll_wait時,會再次響應應用程序並通知此事件。
ET模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次響應應用程序並通知此事件。
LT模式:LT(level triggered)是缺省的工作方式,並且同時支持block和no-block socket,在這種做法中,內核告訴你一個文件描述符是否就緒了,然后你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的。
ET模式:ET(edge-triggered)是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變為就緒時,內核通過epoll告訴你。然后它會假設你知道文件描述符已經就緒,並且不會再為那個文件描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再為就緒狀態了(比如,你在發送,接收或者接收請求,或者發送接收的數據少於一定量時導致了一個EWOULDBLOCK 錯誤)。但是請注意,如果一直不對這個fd作IO操作(從而導致它再次變成未就緒),內核不會發送更多的通知(only once),ET模式在很大程度上減少了epoll事件被重復觸發的次數,因此效率要比LT模式高。epoll工作在ET模式的時候,必須使用非阻塞套接口,以避免由於一個文件句柄的阻塞讀/阻塞寫操作把處理多個文件描 述符的任務餓死。
服務端示例
for ( ; ; )
{
//獲取已經准備好的描述符事件
ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
handle_events(epollfd,events,ret,listenfd,buf);
}
select的問題
根據fd_size的定義,它的大小為32個整數大小(32位機器為32*32,所有共有1024bits可以記錄fd),每個fd一個bit,所以最大只能同時處理1024個fd每次要判斷(有哪些event發生)這件事的成本很高,因為select(polling也是)采取主動輪詢機制,
1.每一次調用select()都需要先從用戶空間把FD_SET復制到內核空間。
為什么select不能像epoll一樣,只做一次復制呢? 因為每一次調用select()前,FD_SET都可能被內核修改,而epoll提供了可共享的存儲結構,所以不需要每次的用戶態和內核態的數據復制。
2.kernel還要遍歷每個fd
假設現實中,有1百萬個客戶端同時與一個服務器保持着tcp連接,而每一個時刻,通常只有幾百上千個tcp連接是活躍的,這時候我們仍然使用select/poll機制,kernel必須在搜尋完100萬個fd之后,才能找到其中狀態是active的,這樣資源消耗大而且效率低下。
poll與select的差別
描述fd集合的方式不同,poll使用 pollfd 結構而不是select結構fd_set結構,所以poll是鏈式的,沒有最大連接數的限制,poll有一個特點是水平觸發,也就是通知程序fd就緒后,這次沒有被處理,那么下次poll的時候會再次通知同個fd已經就緒。
epoll
epoll沒有fd數量限制, epoll沒有這個限制,我們知道每個epoll監聽一個fd,所以最大數量與能打開的fd數量有關,一個g的內存的機器上,能打開10萬個左右, epoll不需要每次都從user space 將fd set復制到內核kernel, epoll在用epoll_ctl函數進行事件注冊的時候,已經將fd復制到內核中,所以不需要每次都重新復制一次。
select 和 poll 都是主動輪詢,需要遍歷每個fd,epoll是被動觸發方式,給fd注冊了相應事件的時候,我們為每一個fd指定了一個回調函數,當數據准備好之后,就會把就緒的fd加入一個就緒的隊列中,epoll_wait的工作方式實際上就是在這個就緒隊列中查看有沒有就緒的fd,如果有,就喚醒就緒隊列上的等待者,然后調用回調函數。雖然select, poll, epoll都需要查看是否有fd就緒,但是epoll之所以是被動觸發,就在於它只要去查找就緒隊列中有沒有fd,就緒的fd是主動加到隊列中,epoll不需要逐個輪詢確認。
一句話總結重點,select和poll只能通知有fd已經就緒了,但不能知道究竟是哪個fd就緒,所以select和poll就要去主動遍歷一遍找到就緒的fd。而epoll則是不但可以知道有fd可以就緒,而且還具體可以知道就緒fd的編號,所以直接找到就可以,不用循環遍歷一遍。
小結
select, poll是為了解決同時大量IO的場景,但是隨着連接數增加,性能變差,epoll是select和poll的改進方案,在linux上可以取代select和poll,可以在一定程度上改善大量連接的性能問題。
