聊聊select, poll 和 epoll_wait

SolidMango發表於2020-05-03

聊聊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,可以在一定程度上改善大量連線的效能問題。

 

相關文章