文章目錄
- 1 什么是IO多路復用
- 2 解決什么問題
- 說在前面
- I/O模型
- 阻塞I/O
- 非阻塞I/O
- IO多路復用
- 信號驅動IO
- 異步IO
- 3 目前有哪些IO多路復用的方案
- 解決方案總覽
- 常見軟件的IO多路復用方案
- 4 具體怎么用
- select
- poll
- epoll
- level-triggered and edge-triggered
- 狀態變化通知(edge-triggered)模式下的epoll
- 5 不同IO多路復用方案優缺點
- poll vs select
- epoll vs poll&select
1 什么是IO多路復用
一句話解釋:單線程或單進程同時監測若干個文件描述符是否可以執行I/O操作。
2 解決什么問題
說在前面
應用程序通常需要處理來自多條事件流中的事件,比如我現在用的電腦,需要同時處理鍵盤鼠標的輸入、中斷信號等等事件,再比如web服務器如nginx,需要同時處理來來自N個客戶端的事件。
邏輯控制流在時間上的重疊叫做 并發
而CPU單核在同一時刻只能做一件事情,一種解決辦法是對CPU進行時分復用(多個事件流將CPU切割成多個時間片,不同事件流的時間片交替進行)。在計算機系統中,我們用線程或者進程來表示一條執行流,通過不同的線程或進程在操作系統內部的調度,來做到對CPU處理的時分復用。這樣多個事件流就可以并發進行,不需要一個等待另一個太久,在用戶看起來他們似乎就是并行在做一樣。
但凡事都是有成本的。線程/進程也一樣,有這么幾個方面:
- 線程/進程創建成本
- CPU切換不同線程/進程成本 Context Switch
- 多線程的資源競爭
有沒有一種可以在單線程/進程中處理多個事件流的方法呢?一種答案就是IO多路復用。
因此IO多路復用解決的本質問題是在用更少的資源完成更多的事。
為了更全面的理解,先介紹下在Linux系統下所有IO模型。
I/O模型
目前Linux系統中提供了5中IO處理模型
- 阻塞IO
- 非阻塞IO
- IO多路復用
- 信號驅動IO
- 異步IO
阻塞I/O
這是最常用的簡單的IO模型。阻塞IO意味著當我們發起一次IO操作后一直等待成功或失敗之后才返回,在這期間程序不能做其它的事情。阻塞IO操作只能對單個文件描述符進行操作,詳見read或write。
非阻塞I/O
我們在發起IO時,通過對文件描述符設置O_NONBLOCK flag來指定該文件描述符的IO操作為非阻塞。非阻塞IO通常發生在一個for循環當中,因為每次進行IO操作時要么IO操作成功,要么當IO操作會阻塞時返回錯誤EWOULDBLOCK/EAGAIN,然后再根據需要進行下一次的for循環操作,這種類似輪詢的方式會浪費很多不必要的CPU資源,是一種糟糕的設計。和阻塞IO一樣,非阻塞IO也是通過調用read或write來進行操作的,也只能對單個描述符進行操作。
IO多路復用
IO多路復用在Linux下包括了三種,select、poll、epoll,抽象來看,他們功能是類似的,但具體細節各有不同:首先都會對一組文件描述符進行相關事件的注冊,然后阻塞等待某些事件的發生或等待超時。更多細節詳見下面的 “具體怎么用”。IO多路復用都可以關注多個文件描述符,但對于這三種機制而言,不同數量級文件描述符對性能的影響是不同的,下面會詳細介紹。
信號驅動IO
信號驅動IO是利用信號機制,讓內核告知應用程序文件描述符的相關事件。這里有一個信號驅動IO相關的例子。
但信號驅動IO在網絡編程的時候通常很少用到,因為在網絡環境中,和socket相關的讀寫事件太多了,比如下面的事件都會導致SIGIO信號的產生:
- TCP連接建立
- 一方斷開TCP連接請求
- 斷開TCP連接請求完成
- TCP連接半關閉
- 數據到達TCP socket
- 數據已經發送出去(如:寫buffer有空余空間)
上面所有的這些都會產生SIGIO信號,但我們沒辦法在SIGIO對應的信號處理函數中區分上述不同的事件,SIGIO只應該在IO事件單一情況下使用,比如說用來監聽端口的socket,因為只有客戶端發起新連接的時候才會產生SIGIO信號。
異步IO
異步IO和信號驅動IO差不多,但它比信號驅動IO可以多做一步:相比信號驅動IO需要在程序中完成數據從用戶態到內核態(或反方向)的拷貝,異步IO可以把拷貝這一步也幫我們完成之后才通知應用程序。我們使用 aio_read 來讀,aio_write 寫。
同步IO vs 異步IO 1. 同步IO指的是程序會一直阻塞到IO操作如read、write完成 2.異步IO指的是IO操作不會阻塞當前程序的繼續執行
所以根據這個定義,上面阻塞IO當然算是同步的IO,非阻塞IO也是同步IO,因為當文件操作符可用時我們還是需要阻塞的讀或寫,同理IO多路復用和信號驅動IO也是同步IO,只有異步IO是完全完成了數據的拷貝之后才通知程序進行處理,沒有阻塞的數據讀寫過程。
3 目前有哪些IO多路復用的方案
解決方案總覽
Linux: select、poll、epoll
MacOS/FreeBSD: kqueue
Windows/Solaris: IOCP
常見軟件的IO多路復用方案
redis: Linux下 epoll(level-triggered),沒有epoll用select
nginx: Linux下 epoll(edge-triggered),沒有epoll用select
4 具體怎么用
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);void FD_CLR(int fd, fd_set *set);int FD_ISSET(int fd, fd_set *set);void FD_SET(int fd, fd_set *set);void FD_ZERO(fd_set *set);#include <sys/select.h>int pselect(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timespec *timeout,const sigset_t *sigmask);
select的調用會阻塞到有文件描述符可以進行IO操作或被信號打斷或者超時才會返回。
select將監聽的文件描述符分為三組,每一組監聽不同的需要進行的IO操作,readfds是需要進行讀操作的文件描述符,writefds是需要進行寫操作的文件描述符,exceptfds是需要進行異常事件處理的文件描述符。這三個參數可以用NULL來表示對應的事件不需要監聽。
nfds: 要監視的文件描述符的范圍,一般取監視的描述符數的最大值+1,如這里寫 10, 這樣的話,描述符 0,1, 2 …… 9 都會被監視
當select返回時,每組文件描述符會被select過濾,只留下可以進行對應IO操作的文件描述符,也就是說能進行IO操作的文件描述符會被設置。成功:就緒描述符的數目,超時返回 0,出錯:-1 。
FD_xx系統函數時用來操作文件描述符組合文件描述符的關系。
FD_ZERO用來清空文件描述符組。每次調用select前都需要清空一次。
fd_set writefds;
FD_ZERO(&writefds)
FD_SET添加一個文件描述符到組中,FD_CLR對應將一個文件描述符移出組中
FD_SET(fd, &writefds);
FD_CLR(fd, &writefds);
FD_ISSET檢測一個文件描述符是否在組中,我們用這個來檢測一次select調用之后有哪些文件描述符可以進行IO操作
if (FD_ISSET(fd, &readfds)){/* fd可讀 */
}
select可同時監聽的文件描述符數量是通過FS_SETSIZE來限制的,在Linux系統中,該值為1024,當然我們可以增大這個值,但隨著監聽的文件描述符數量增加,select的效率會降低,我們會在『不同IO多路復用方案優缺點』一節中展開。
打開鏈接查看完整的使用select的例子
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>#define TIMEOUT 5 /* select timeout in seconds */
#define BUF_LEN 1024 /* read buffer in bytes */int main (void) {struct timeval tv;fd_set readfds;int ret;/* Wait on stdin for input. */FD_ZERO(&readfds);FD_SET(STDIN_FILENO, &readfds);/* Wait up to five seconds. */tv.tv_sec = TIMEOUT;tv.tv_usec = 0;/* All right, now block! */ret = select (STDIN_FILENO + 1, &readfds,NULL,NULL, &tv);if (ret == ?1) {perror ("select");return 1; } else if (!ret) {printf ("%d seconds elapsed.\n", TIMEOUT);return 0; }/** Is our file descriptor ready to read?* (It must be, as it was the only fd that* we provided and the call returned* nonzero, but we will humor ourselves.)*/if (FD_ISSET(STDIN_FILENO, &readfds)) {char buf[BUF_LEN+1];int len;/* guaranteed to not block */len = read (STDIN_FILENO, buf, BUF_LEN);if (len == ?1) {perror ("read");return 1; }if (len) {buf[len] = '\0';printf ("read: %s\n", buf);}return 0; }fprintf (stderr, "This should not happen!\n");return 1;
}
poll
相關函數定義
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);#define _GNU_SOURCE /* See feature_test_macros(7) */#include <signal.h>#include <poll.h>int ppoll(struct pollfd *fds, nfds_t nfds,const struct timespec *tmo_p, const sigset_t *sigmask);struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */};
poll函數監視在fds數組指明的一組文件描述符上發生的動作,當滿足條件或者超時的時候會退出
- 參數fds是一個指向pollfd數組的指針,監視的文件描述符和條件放在里面
- 參數nfds是比監視的最大描述符的值大于1的值
- 參數timeout是超時時間,單位為毫秒,當為負值時,表示永遠等待
結構體struct pollfd的成員含義如下:
- 成員fd表示監視的文件描述符
- 成員events表示輸入的監視事件
- 成員revents表示返回的監視事件,即返回時發生的事件
和select用三組文件描述符不同的是,poll只有一個pollfd數組,數組中的每個元素都表示一個需要監聽IO操作事件的文件描述符。events參數是我們等待的事件,revents是實際發生了的事件。
打開鏈接查看完整的使用poll的例子
#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#define TIMEOUT 5 /* poll timeout, in seconds */
int main (void) {struct pollfd fds[2];int ret;/* watch stdin for input */fds[0].fd = STDIN_FILENO;fds[0].events = POLLIN;/* watch stdout for ability to write (almost always true) */fds[1].fd = STDOUT_FILENO;fds[1].events = POLLOUT;/* All set, block! */ret = poll (fds, 2, TIMEOUT * 1000);if (ret == ?1) {perror ("poll");return 1; }if (!ret) {printf ("%d seconds elapsed.\n", TIMEOUT);return 0; }if (fds[0].revents & POLLIN)printf ("stdin is readable\n");if (fds[1].revents & POLLOUT)printf ("stdout is writable\n");return 0;
}/*$ ./pollstdout is writable$ ./poll < some_filestdin is readablestdout is writable
*/
epoll
相關函數定義如下
#include <sys/epoll.h>int epoll_create(int size);int epoll_create1(int flags);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);int epoll_pwait(int epfd, struct epoll_event *events,int maxevents, int timeout,const sigset_t *sigmask);
// 保存觸發事件的某個文件描述符相關的數據(與具體使用方式有關)
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 */};
epoll_create&epoll_create1用于創建一個epoll實例,而epoll_ctl用于往epoll實例中增刪改要監測的文件描述符,epoll_wait則用于阻塞的等待可以執行IO操作的文件描述符直到超時。
epoll_create:自從 linux 2.6.8 之后,size 參數是被忽略的,也就是說可以填只有大于 0 的任意值。
epoll_ctl:epfd: epoll 專用的文件描述符,epoll_create()的返回值;op: 表示動作,用三個宏來表示:
EPOLL_CTL_ADD:注冊新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件;
EPOLL_CTL_DEL:從 epfd 中刪除一個 fd;
fd: 需要監聽的文件描述符;
結構體epoll_event 成員含義如下:
- 成員 events 代表要監聽的 epoll 事件類型,有讀事件,寫事件,有如下取值。
- data:用戶數據變量
epoll_wait等待事件的產生,類似于select()調用。參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個 maxevents的值不能大于創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。如果返回–1,則表示出現錯誤,需要檢查 errno錯誤碼判斷錯誤類型。
-
第1個參數 epfd是 epoll的描述符。
-
第2個參數 events則是分配好的 epoll_event結構體數組,epoll將會把發生的事件復制到 events數組中(events不可以是空指針,內核只負責把數據復制到這個 events數組中,不會去幫助我們在用戶態中分配內存。內核這種做法效率很高)。
-
第3個參數 maxevents表示本次可以返回的最大事件數目,通常 maxevents參數與預分配的events數組的大小是相等的。
-
第4個參數 timeout表示在沒有檢測到事件發生時最多等待的時間(單位為毫秒),如果 timeout為0,則表示 epoll_wait在 rdllist鏈表中為空,立刻返回,不會等待。
打開鏈接查看完整的使用epoll的例子
//https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/epoll-example.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <errno.h>#define MAXEVENTS 64static int
make_socket_non_blocking (int sfd)
{int flags, s;flags = fcntl (sfd, F_GETFL, 0);if (flags == -1){perror ("fcntl");return -1;}flags |= O_NONBLOCK;s = fcntl (sfd, F_SETFL, flags);if (s == -1){perror ("fcntl");return -1;}return 0;
}static int
create_and_bind (char *port)
{struct addrinfo hints;struct addrinfo *result, *rp;int s, sfd;memset (&hints, 0, sizeof (struct addrinfo));hints.ai_family = AF_UNSPEC; /* Return IPv4 and IPv6 choices */hints.ai_socktype = SOCK_STREAM; /* We want a TCP socket */hints.ai_flags = AI_PASSIVE; /* All interfaces */s = getaddrinfo (NULL, port, &hints, &result);if (s != 0){fprintf (stderr, "getaddrinfo: %s\n", gai_strerror (s));return -1;}for (rp = result; rp != NULL; rp = rp->ai_next){sfd = socket (rp->ai_family, rp->ai_socktype, rp->ai_protocol);if (sfd == -1)continue;s = bind (sfd, rp->ai_addr, rp->ai_addrlen);if (s == 0){/* We managed to bind successfully! */break;}close (sfd);}if (rp == NULL){fprintf (stderr, "Could not bind\n");return -1;}freeaddrinfo (result);return sfd;
}int
main (int argc, char *argv[])
{int sfd, s;int efd;struct epoll_event event;struct epoll_event *events;if (argc != 2){fprintf (stderr, "Usage: %s [port]\n", argv[0]);exit (EXIT_FAILURE);}sfd = create_and_bind (argv[1]);if (sfd == -1)abort ();s = make_socket_non_blocking (sfd);if (s == -1)abort ();s = listen (sfd, SOMAXCONN);if (s == -1){perror ("listen");abort ();}efd = epoll_create1 (0);if (efd == -1){perror ("epoll_create");abort ();}event.data.fd = sfd;event.events = EPOLLIN | EPOLLET;s = epoll_ctl (efd, EPOLL_CTL_ADD, sfd, &event);if (s == -1){perror ("epoll_ctl");abort ();}/* Buffer where events are returned */events = calloc (MAXEVENTS, sizeof event);/* The event loop */while (1){int n, i;n = epoll_wait (efd, events, MAXEVENTS, -1);for (i = 0; i < n; i++){if ((events[i].events & EPOLLERR) ||(events[i].events & EPOLLHUP) ||(!(events[i].events & EPOLLIN))){/* An error has occured on this fd, or the socket is notready for reading (why were we notified then?) */fprintf (stderr, "epoll error\n");close (events[i].data.fd);continue;}else if (sfd == events[i].data.fd){/* We have a notification on the listening socket, whichmeans one or more incoming connections. */while (1){struct sockaddr in_addr;socklen_t in_len;int infd;char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];in_len = sizeof in_addr;infd = accept (sfd, &in_addr, &in_len);if (infd == -1){if ((errno == EAGAIN) ||(errno == EWOULDBLOCK)){/* We have processed all incomingconnections. */break;}else{perror ("accept");break;}}s = getnameinfo (&in_addr, in_len,hbuf, sizeof hbuf,sbuf, sizeof sbuf,NI_NUMERICHOST | NI_NUMERICSERV);if (s == 0){printf("Accepted connection on descriptor %d ""(host=%s, port=%s)\n", infd, hbuf, sbuf);}/* Make the incoming socket non-blocking and add it to thelist of fds to monitor. */s = make_socket_non_blocking (infd);if (s == -1)abort ();event.data.fd = infd;event.events = EPOLLIN | EPOLLET;s = epoll_ctl (efd, EPOLL_CTL_ADD, infd, &event);if (s == -1){perror ("epoll_ctl");abort ();}}continue;}else{/* We have data on the fd waiting to be read. Read anddisplay it. We must read whatever data is availablecompletely, as we are running in edge-triggered modeand won't get a notification again for the samedata. */int done = 0;while (1){ssize_t count;char buf[512];count = read (events[i].data.fd, buf, sizeof buf);if (count == -1){/* If errno == EAGAIN, that means we have read alldata. So go back to the main loop. */if (errno != EAGAIN){perror ("read");done = 1;}break;}else if (count == 0){/* End of file. The remote has closed theconnection. */done = 1;break;}/* Write the buffer to standard output */s = write (1, buf, count);if (s == -1){perror ("write");abort ();}}if (done){printf ("Closed connection on descriptor %d\n",events[i].data.fd);/* Closing the descriptor will make epoll remove itfrom the set of descriptors which are monitored. */close (events[i].data.fd);}}}}free (events);close (sfd);return EXIT_SUCCESS;
}
level-triggered and edge-triggered
這兩種底層的事件通知機制通常被稱為水平觸發和邊沿觸發,真是翻譯的詞不達意,如果我來翻譯,我會翻譯成:狀態持續通知和狀態變化通知。
這兩個概念來自電路,triggered代表電路激活,也就是有事件通知給程序,level-triggered表示只要有IO操作可以進行比如某個文件描述符有數據可讀,每次調用epoll_wait都會返回以通知程序可以進行IO操作,edge-triggered表示只有在文件描述符狀態發生變化時,調用epoll_wait才會返回,如果第一次沒有全部讀完該文件描述符的數據而且沒有新數據寫入,再次調用epoll_wait都不會有通知給到程序,因為文件描述符的狀態沒有變化。
select和poll都是狀態持續通知的機制,且不可改變,只要文件描述符中有IO操作可以進行,那么select和poll都會返回以通知程序。而epoll兩種通知機制可選。
狀態變化通知(edge-triggered)模式下的epoll
在epoll狀態變化通知機制下,有一些的特殊的地方需要注意。考慮下面這個例子
- 服務端文件描述符rfd代表要執行read操作的TCP socket,rfd已被注冊到一個epoll實例中
- 客戶端向rfd寫了2kb數據
- 服務端調用epoll_wait返回,rfd可執行read操作
- 服務端從rfd中讀取了1kb數據
- 服務端又調用了一次epoll_wait
在第5步的epoll_wait調用不會返回,而對應的客戶端會因為服務端沒有返回對應的response而超時重試,原因就是我上面所說的,epoll_wait只會在狀態變化時才會通知程序進行處理。第3步epoll_wait會返回,是因為客戶端寫了數據,導致rfd狀態被改變了,第3步的epoll_wait已經消費了這個事件,所以第5步的epoll_wait不會返回。
我們需要配合非阻塞IO來解決上面的問題:
- 對需要監聽的文件描述符加上非阻塞IO標識
- 只在read或者write返回EAGAIN或EWOULDBLOCK錯誤時,才調用epoll_wait等待下次狀態改變發生
通過上述方式,我們可以確保每次epoll_wait返回之后,我們的文件描述符中沒有讀到一半或寫到一半的數據。
5 不同IO多路復用方案優缺點
poll vs select
poll和select基本上是一樣的,poll相比select好在如下幾點:
- poll傳參對用戶更友好。比如不需要和select一樣計算很多奇怪的參數比如nfds(值最大的文件描述符+1),再比如不需要分開三組傳入參數。
- poll會比select性能稍好些,因為select是每個bit位都檢測,假設有個值為1000的文件描述符,select會從第一位開始檢測一直到第1000個bit位。但poll檢測的是一個數組。
- select的時間參數在返回的時候各個系統的處理方式不統一,如果希望程序可移植性更好,需要每次調用select都初始化時間參數。
而select比poll好在下面幾點
- 支持select的系統更多,兼容更強大,有一些unix系統不支持poll
- select提供精度更高(到microsecond)的超時時間,而poll只提供到毫秒的精度。
但總體而言 select和poll基本一致。
epoll vs poll&select
epoll優于select&poll在下面幾點:
- 在需要同時監聽的文件描述符數量增加時,select&poll是O(N)的復雜度,epoll是O(1),在N很小的情況下,差距不會特別大,但如果N很大的前提下,一次O(N)的循環可要比O(1)慢很多,所以高性能的網絡服務器都會選擇epoll進行IO多路復用。
- epoll內部用一個文件描述符掛載需要監聽的文件描述符,這個epoll的文件描述符可以在多個線程/進程共享,所以epoll的使用場景要比select&poll要多。