I/O 多路複用技術是為了解決程序或線程阻塞到某個 I/O 系統調用而出現的技術,使程序不阻塞于某個特定的 I/O 系統調用。
select(),poll(),epoll()都是I/O多路複用的機制。I/O多路複用通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒,就是這個檔案描述符進行讀寫操作之前),能夠通知程式進行相應的讀寫操作。但select(),poll(),epoll()本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實作會負責把資料從核心拷貝到使用者空間。什麼是同步或異步,詳情請看《同步和異步的差別》。
與多線程和多程序相比,I/O 多路複用的最大優勢是系統開銷小,系統不需要建立新的程序或者線程,也不必維護這些線程和程序。
select()的使用
所需頭檔案:
#include <sys/select.h>
#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);
功能:
監視并等待多個檔案描述符的屬性變化(可讀、可寫或錯誤異常)。select()函數監視的檔案描述符分 3 類,分别是writefds、readfds、和 exceptfds。調用後 select() 函數會阻塞,直到有描述符就緒(有資料可讀、可寫、或者有錯誤異常),或者逾時( timeout 指定等待時間),函數才傳回。當 select()函數傳回後,可以通過周遊 fdset,來找到就緒的描述符。
參數:
nfds: 要監視的檔案描述符的範圍,一般取監視的描述符數的最大值+1,如這裡寫 10, 這樣的話,描述符 0,1, 2 …… 9 都會被監視,在 Linux 上最大值一般為1024。
readfd: 監視的可讀描述符集合,隻要有檔案描述符即将進行讀操作,這個檔案描述符就存儲到這。
writefds: 監視的可寫描述符集合。
exceptfds: 監視的錯誤異常描述符集合
中間的三個參數 readfds、writefds 和 exceptfds 指定我們要讓核心監測讀、寫和異常條件的描述字。如果不需要使用某一個的條件,就可以把它設為空指針( NULL )。集合fd_set 中存放的是檔案描述符,可通過以下四個宏進行設定:
timeout: 逾時時間,它告知核心等待所指定描述字中的任何一個就緒可花多少時間。其 timeval 結構用于指定這段時間的秒數和微秒數。//清空集合
void FD_ZERO(fd_set *fdset);
//将一個給定的檔案描述符加入集合之中
void FD_SET(int fd, fd_set *fdset);
//将一個給定的檔案描述符從集合中删除
void FD_CLR(int fd, fd_set *fdset);
// 檢查集合中指定的檔案描述符是否可以讀寫
int FD_ISSET(int fd, fd_set *fdset);
struct timeval
{
time_t tv_sec; /* 秒 */suseconds_t tv_usec; /* 微秒 */};這個參數有三種可能:
1)永遠等待下去:僅在有一個描述字準備好 I/O 時才傳回。為此,把該參數設定為空指針 NULL。
2)等待固定時間:在指定的固定時間( timeval 結構中指定的秒數和微秒數)内,在有一個描述字準備好 I/O 時傳回,如果時間到了,就算沒有檔案描述符發生變化,這個函數會傳回 0。
3)根本不等待(不阻塞):檢查描述字後立即傳回,這稱為輪詢。為此,struct timeval變量的時間值指定為 0 秒 0 微秒,檔案描述符屬性無變化傳回 0,有變化傳回準備好的描述符數量。
傳回值:
成功:就緒描述符的數目,逾時傳回 0,
出錯:-1
我們寫這麼一個例子,同時循環讀取标準輸入的内容,讀取有名管道的内容,預設的情況下,标準輸入沒有内容,read()時會阻塞,同樣的,有名管道如果沒有内容,read()也會阻塞,我們如何實作循環讀取這兩者的内容呢?最簡單的方法是,開兩個線程,一個線程循環讀标準輸入的内容,一個線程循環讀有名管道的内容。而在這裡,我們通過 select() 函數實作這個功能:
#include <sys/select.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
fd_set rfds;
struct timeval tv;
int ret;
int fd;
ret = mkfifo("test_fifo", 0666); // 建立有名管道
if(ret != 0){
perror("mkfifo:");
}
fd = open("test_fifo", O_RDWR); // 讀寫方式打開管道
if(fd < 0){
perror("open fifo");
return -1;
}
ret = 0;
while(1){
// 這部分内容,要放在while(1)裡面
FD_ZERO(&rfds); // 清空
FD_SET(0, &rfds); // 标準輸入描述符 0 加入集合
FD_SET(fd, &rfds); // 有名管道描述符 fd 加入集合
// 逾時設定
tv.tv_sec = 1;
tv.tv_usec = 0;
// 監視并等待多個檔案(标準輸入,有名管道)描述符的屬性變化(是否可讀)
// 沒有屬性變化,這個函數會阻塞,直到有變化才往下執行,這裡沒有設定逾時
// FD_SETSIZE 為 <sys/select.h> 的宏定義,值為 1024
ret = select(FD_SETSIZE, &rfds, NULL, NULL, NULL);
//ret = select(FD_SETSIZE, &rfds, NULL, NULL, &tv);
if(ret == -1){ // 出錯
perror("select()");
}else if(ret > 0){ // 準備就緒的檔案描述符
char buf[100] = {0};
if( FD_ISSET(0, &rfds) ){ // 标準輸入
read(0, buf, sizeof(buf));
printf("stdin buf = %s\n", buf);
}else if( FD_ISSET(fd, &rfds) ){ // 有名管道
read(fd, buf, sizeof(buf));
printf("fifo buf = %s\n", buf);
}
}else if(0 == ret){ // 逾時
printf("time out\n");
}
}
return 0;
}
目前終端運作此程式,另一終端運作一個往有名管道寫内容的程式,運作結果如下:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI0gTMx81dsQWZ4lmZf1GLlpXazVmcvwFciV2dsQXYtJ3bm9CX9s2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xCMy81dvRWYoNHLwEzX5xCMx8FesU2cfdGLwMzX0xiRGZkRGZ0Xy9GbvNGLpZTY1EmMZVDUSFTU4VFRR9Fd4VGdsYTMfVmepNHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-cGcq5SO4MDMzQGZ1M2N1YDNlV2YxYzX3EzNwkTMxMzLcJTMxIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.jpg)
下面為上面例子的往有名管道寫内容的示例代碼:
#include <sys/select.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[])
{
//select_demo(8);
fd_set rfds;
struct timeval tv;
int ret;
int fd;
ret = mkfifo("test_fifo", 0666); // 建立有名管道
if(ret != 0){
perror("mkfifo:");
}
fd = open("test_fifo", O_RDWR); // 讀寫方式打開管道
if(fd < 0){
perror("open fifo");
return -1;
}
while(1){
char *str = "this is for test";
write(fd, str, strlen(str)); // 往管道裡寫内容
printf("after write to fifo\n");
sleep(5);
}
return 0;
}
運作結果如下:
select()目前幾乎在所有的平台上支援,其良好跨平台支援也是它的一個優點。
select()的缺點在于:
1)每次調用 select(),都需要把 fd 集合從使用者态拷貝到核心态,這個開銷在 fd 很多時會很大,同時每次調用 select() 都需要在核心周遊傳遞進來的所有 fd,這個開銷在 fd 很多時也很大。
2)單個程序能夠監視的檔案描述符的數量存在最大限制,在 Linux 上一般為 1024,可以通過修改宏定義甚至重新編譯核心的方式提升這一限制,但是這樣也會造成效率的降低。
poll()的使用
select() 和 poll() 系統調用的本質一樣,前者在 BSD UNIX 中引入的,後者在 System V 中引入的。poll() 的機制與 select() 類似,與 select() 在本質上沒有多大差别,管理多個描述符也是進行輪詢,根據描述符的狀态進行處理,但是 poll() 沒有最大檔案描述符數量的限制(但是數量過大後性能也是會下降)。poll() 和 select() 同樣存在一個缺點就是,包含大量檔案描述符的數組被整體複制于使用者态和核心的位址空間之間,而不論這些檔案描述符是否就緒,它的開銷随着檔案描述符數量的增加而線性增大。
所需頭檔案:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能:
監視并等待多個檔案描述符的屬性變化。
參數:
fds: 不同與 select() 使用三個位圖來表示三個 fdset 的方式,poll() 使用一個 pollfd 的指針實作。一個 pollfd 結構體數組,其中包括了你想測試的檔案描述符和事件, 事件由結構中事件域 events 來确定,調用後實際發生的時間将被填寫在結構體的 revents 域。struct pollfd{int fd; /* 檔案描述符 */short events; /* 等待的事件 */short revents; /* 實際發生了的事件 */};
fd:每一個 pollfd 結構體指定了一個被監視的檔案描述符,可以傳遞多個結構體,訓示 poll() 監視多個檔案描述符。
events:每個結構體的 events 域是監視該檔案描述符的事件掩碼,由使用者來設定這個域。events 等待事件的掩碼取值如下:
處理輸入:
POLLIN 普通或優先級帶資料可讀
POLLRDNORM 普通資料可讀
POLLRDBAND 優先級帶資料可讀
POLLPRI 高優先級資料可讀
處理輸出:POLLOUT 普通或優先級帶資料可寫
POLLWRNORM 普通資料可寫
POLLWRBAND 優先級帶資料可寫
處理錯誤:poll() 處理三個級别的資料,普通 normal,優先級帶 priority band,高優先級 high priority,這些都是出于流的實作。 POLLIN | POLLPRI 等價于 select() 的讀事件,POLLOUT | POLLWRBAND 等價于 select() 的寫事件。POLLIN 等價于 POLLRDNORM | POLLRDBAND,而 POLLOUT 則等價于 POLLWRNORM 。例如,要同時監視一個檔案描述符是否可讀和可寫,我們可以設定 events 為 POLLIN | POLLOUT。POLLERR發生錯誤POLLHUP發生挂起POLLVAL 描述字不是一個打開的檔案revents:revents 域是檔案描述符的操作結果事件掩碼,核心在調用傳回時設定這個域。events 域中請求的任何事件都可能在 revents 域中傳回。
每個結構體的 events 域是由使用者來設定,告訴核心我們關注的是什麼,而 revents 域是傳回時核心設定的,以說明對該描述符發生了什麼事件。
nfds: 用來指定第一個參數數組元素個數。
timeout: 指定等待的毫秒數,無論 I/O 是否準備好,poll() 都會傳回。當等待時間為 0 時,poll() 函數立即傳回,為 -1 則使 poll() 一直阻塞直到一個指定事件發生。
傳回值:
成功時,poll() 傳回結構體中 revents 域不為 0 的檔案描述符個數;如果在逾時前沒有任何事件發生,poll()傳回 0;
失敗時,poll() 傳回 -1,并設定 errno 為下列值之一:
EBADF:一個或多個結構體中指定的檔案描述符無效。
EFAULT:fds 指針指向的位址超出程序的位址空間。
EINTR:請求的事件之前産生一個信号,調用可以重新發起。
EINVAL:nfds 參數超出 PLIMIT_NOFILE 值。
ENOMEM:可用記憶體不足,無法完成請求。
我們将上面的例子,改為用 poll() 實作:
#include <poll.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
int ret;
int fd;
struct pollfd fds[2]; // 監視檔案描述符結構體,2 個元素
ret = mkfifo("test_fifo", 0666); // 建立有名管道
if(ret != 0){
perror("mkfifo:");
}
fd = open("test_fifo", O_RDWR); // 讀寫方式打開管道
if(fd < 0){
perror("open fifo");
return -1;
}
ret = 0;
fds[0].fd = 0; // 标準輸入
fds[1].fd = fd; // 有名管道
fds[0].events = POLLIN; // 普通或優先級帶資料可讀
fds[1].events = POLLIN; // 普通或優先級帶資料可讀
while(1){
// 監視并等待多個檔案(标準輸入,有名管道)描述符的屬性變化(是否可讀)
// 沒有屬性變化,這個函數會阻塞,直到有變化才往下執行,這裡沒有設定逾時
ret = poll(fds, 2, -1);
//ret = poll(&fd, 2, 1000);
if(ret == -1){ // 出錯
perror("poll()");
}else if(ret > 0){ // 準備就緒的檔案描述符
char buf[100] = {0};
if( ( fds[0].revents & POLLIN ) == POLLIN ){ // 标準輸入
read(0, buf, sizeof(buf));
printf("stdin buf = %s\n", buf);
}else if( ( fds[1].revents & POLLIN ) == POLLIN ){ // 有名管道
read(fd, buf, sizeof(buf));
printf("fifo buf = %s\n", buf);
}
}else if(0 == ret){ // 逾時
printf("time out\n");
}
}
return 0;
}
poll() 的實作和 select() 非常相似,隻是描述 fd 集合的方式不同,poll() 使用 pollfd 結構而不是 select() 的 fd_set 結構,其他的都差不多。
epoll的使用
epoll 是在 2.6 核心中提出的,是之前的 select() 和 poll() 的增強版本。相對于 select() 和 poll() 來說,epoll 更加靈活,沒有描述符限制。epoll 使用一個檔案描述符管理多個描述符,将使用者關系的檔案描述符的事件存放到核心的一個事件表中,這樣在使用者空間和核心空間的 copy 隻需一次。
epoll 操作過程需要三個接口,分别如下:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct
int epoll_create(int size);
功能:
該函數生成一個 epoll 專用的檔案描述符(建立一個 epoll 的句柄)。
參數:
size: 用來告訴核心這個監聽的數目一共有多大,參數 size 并不是限制了 epoll 所能監聽的描述符最大個數,隻是對核心初始配置設定内部資料結構的一個建議。自從 linux 2.6.8 之後,size 參數是被忽略的,也就是說可以填隻有大于 0 的任意值。需要注意的是,當建立好 epoll 句柄後,它就是會占用一個 fd 值,在 linux 下如果檢視 /proc/ 程序 id/fd/,是能夠看到這個 fd 的,是以在使用完 epoll 後,必須調用 close() 關閉,否則可能導緻 fd 被耗盡。
傳回值:
成功:epoll 專用的檔案描述符
失敗:-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:
epoll 的事件注冊函數,它不同于 select() 是在監聽事件時告訴核心要監聽什麼類型的事件,而是在這裡先注冊要監聽的事件類型。
參數:
epfd: epoll 專用的檔案描述符,epoll_create()的傳回值
op: 表示動作,用三個宏來表示:
EPOLL_CTL_ADD:注冊新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件;
EPOLL_CTL_DEL:從 epfd 中删除一個 fd;
fd: 需要監聽的檔案描述符
event: 告訴核心要監聽什麼事件,struct epoll_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 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 隊列裡
傳回值:
成功:0
失敗:-1
int epoll_wait( int epfd, struct epoll_event * events, int maxevents, int timeout );
功能:
等待事件的産生,收集在 epoll 監控的事件中已經發送的事件,類似于 select() 調用。
參數:
epfd: epoll 專用的檔案描述符,epoll_create()的傳回值
events: 配置設定好的 epoll_event 結構體數組,epoll 将會把發生的事件指派到events 數組中(events 不可以是空指針,核心隻負責把資料複制到這個 events 數組中,不會去幫助我們在使用者态中配置設定記憶體)。
maxevents: maxevents 告之核心這個 events 有多大 。
timeout: 逾時時間,機關為毫秒,為 -1 時,函數為阻塞
傳回值:
成功:傳回需要處理的事件數目,如傳回 0 表示已逾時。
失敗:-1
epoll 對檔案描述符的操作有兩種模式:LT(level trigger)和 ET(edge trigger)。LT 模式是預設模式,LT 模式與 ET 模式的差別如下:
LT 模式:當 epoll_wait 檢測到描述符事件發生并将此事件通知應用程式,應用程式可以不立即處理該事件。下次調用 epoll_wait 時,會再次響應應用程式并通知此事件。
ET 模式:當 epoll_wait 檢測到描述符事件發生并将此事件通知應用程式,應用程式必須立即處理該事件。如果不處理,下次調用 epoll_wait 時,不會再次響應應用程式并通知此事件。
ET 模式在很大程度上減少了 epoll 事件被重複觸發的次數,是以效率要比 LT 模式高。epoll 工作在 ET 模式的時候,必須使用非阻塞套接口,以避免由于一個檔案句柄的阻塞讀/阻塞寫操作把處理多個檔案描述符的任務餓死。
接下來,我們将上面的例子,改為用 epoll 實作:
#include <sys/epoll.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int ret;
int fd;
ret = mkfifo("test_fifo", 0666); // 建立有名管道
if(ret != 0){
perror("mkfifo:");
}
fd = open("test_fifo", O_RDWR); // 讀寫方式打開管道
if(fd < 0){
perror("open fifo");
return -1;
}
ret = 0;
struct epoll_event event; // 告訴核心要監聽什麼事件
struct epoll_event wait_event;
int epfd = epoll_create(10); // 建立一個 epoll 的句柄,參數要大于 0, 沒有太大意義
if( -1 == epfd ){
perror ("epoll_create");
return -1;
}
event.data.fd = 0; // 标準輸入
event.events = EPOLLIN; // 表示對應的檔案描述符可以讀
// 事件注冊函數,将标準輸入描述符 0 加入監聽事件
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);
if(-1 == ret){
perror("epoll_ctl");
return -1;
}
event.data.fd = fd; // 有名管道
event.events = EPOLLIN; // 表示對應的檔案描述符可以讀
// 事件注冊函數,将有名管道描述符 fd 加入監聽事件
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
if(-1 == ret){
perror("epoll_ctl");
return -1;
}
ret = 0;
while(1){
// 監視并等待多個檔案(标準輸入,有名管道)描述符的屬性變化(是否可讀)
// 沒有屬性變化,這個函數會阻塞,直到有變化才往下執行,這裡沒有設定逾時
ret = epoll_wait(epfd, &wait_event, 2, -1);
//ret = epoll_wait(epfd, &wait_event, 2, 1000);
if(ret == -1){ // 出錯
close(epfd);
perror("epoll");
}else if(ret > 0){ // 準備就緒的檔案描述符
char buf[100] = {0};
if( ( 0 == wait_event.data.fd )
&& ( EPOLLIN == wait_event.events & EPOLLIN ) ){ // 标準輸入
read(0, buf, sizeof(buf));
printf("stdin buf = %s\n", buf);
}else if( ( fd == wait_event.data.fd )
&& ( EPOLLIN == wait_event.events & EPOLLIN ) ){ // 有名管道
read(fd, buf, sizeof(buf));
printf("fifo buf = %s\n", buf);
}
}else if(0 == ret){ // 逾時
printf("time out\n");
}
}
close(epfd);
return 0;
}
在 select/poll中,程序隻有在調用一定的方法後,核心才對所有監視的檔案描述符進行掃描,而 epoll() 事先通過 epoll_ctl() 來注冊一個檔案描述符,一旦基于某個檔案描述符就緒時,核心會采用類似 callback 的回調機制(軟體中斷 ),迅速激活這個檔案描述符,當程序調用 epoll_wait() 時便得到通知。
epoll 的優點主要是一下幾個方面:
1)監視的描述符數量不受限制,它所支援的 FD 上限是最大可以打開檔案的數目,這個數字一般遠大于 2048,舉個例子,在 1GB 記憶體的機器上大約是 10 萬左右,具體數目可以 cat /proc/sys/fs/file-max 察看,一般來說這個數目和系統記憶體關系很大。select() 的最大缺點就是程序打開的 fd 是有數量限制的。這對于連接配接數量比較大的伺服器來說根本不能滿足。雖然也可以選擇多程序的解決方案( Apache 就是這樣實作的),不過雖然 Linux 上面建立程序的代價比較小,但仍舊是不可忽視的,加上程序間資料同步遠比不上線程間同步的高效,是以也不是一種完美的方案。
2)I/O 的效率不會随着監視 fd 的數量的增長而下降。select(),poll() 實作需要自己不斷輪詢所有 fd 集合,直到裝置就緒,期間可能要睡眠和喚醒多次交替。而 epoll 其實也需要調用 epoll_wait() 不斷輪詢就緒連結清單,期間也可能多次睡眠和喚醒交替,但是它是裝置就緒時,調用回調函數,把就緒 fd 放入就緒連結清單中,并喚醒在 epoll_wait() 中進入睡眠的程序。雖然都要睡眠和交替,但是 select() 和 poll() 在“醒着”的時候要周遊整個 fd 集合,而 epoll 在“醒着”的時候隻要判斷一下就緒連結清單是否為空就行了,這節省了大量的 CPU 時間。這就是回調機制帶來的性能提升。