Linux 多任務編程——I/O多路複用select、poll、epoll使用的區別

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 中存放的是文件描述符,可通過以下四個宏進行設置:

//清空集合

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); 

timeout: 超時時間,它告知內核等待所指定描述字中的任何一個就緒可花多少時間。其 timeval 結構用於指定這段時間的秒數和微秒數。

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;
}


當前終端運行此程序,另一終端運行一個往有名管道寫內容的程序,運行結果如下:


下面爲上面例子的往有名管道寫內容的示例代碼:

#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 優先級帶數據可寫

處理錯誤:

POLLERR發生錯誤

POLLHUP發生掛起

POLLVAL 描述字不是一個打開的文件

poll() 處理三個級別的數據,普通 normal,優先級帶 priority band,高優先級 high priority,這些都是出於流的實現。

POLLIN | POLLPRI 等價於 select() 的讀事件,POLLOUT | POLLWRBAND 等價於 select() 的寫事件。POLLIN 等價於 POLLRDNORM | POLLRDBAND,而 POLLOUT 則等價於 POLLWRNORM 。例如,要同時監視一個文件描述符是否可讀和可寫,我們可以設置 events 爲 POLLIN | POLLOUT。

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 epoll_event * events, int maxevents, int timeout);

 

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 時間。這就是回調機制帶來的性能提升。

3)select(),poll() 每次調用都要把 fd 集合從用戶態往內核態拷貝一次,而 epoll 只要一次拷貝,這也能節省不少的開銷。

 


--------------------- 
作者:Mike__Jiang 
來源:CSDN 
原文:https://blog.csdn.net/tennysonsky/article/details/45745887 
版權聲明:本文爲博主原創文章,轉載請附上博文鏈接!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章