Linux I/O 多路複用之select、poll、epoll詳解與代碼示例

如果寫過最基礎的TCP服務,那就應該清楚 acceptrecv 函數是阻塞式的(默認),也就是說程序就卡在這個地方等待,直到有連接或者數據來到。單線程處理這種事情時,一旦有數據到來就會一直處理這個連接的數據,而沒法接收新的連接。這種情況可以用多線程處理,但是服務器併發量大的時候,如果每個請求都新建一個線程的話,會佔用很多系統資源。其實操作系統可以在一個線程裏分時處理這些事務,也就是常說的I/O多路複用。

selectpollepoll 都是I/O多路複用的機制。I/O多路複用就是通過一種機制,一個進程可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。select是比較早出現的技術,但是select同時處理的描述符有個數限制(默認1024)。爲了彌補其缺點出現了poll,雖然poll沒有個數限制,但是其實現機制與select類似,且隨着文件描述符變多系統性能會下降。爲了解決這些問題,後來就出現了epoll複用機制(Linux 2.6+,Richard 老爺子的 UNPv3 書沒有提到 epoll),也是現在比較常用的I/O複用機制。
下面會具體描述這三者的使用以及特點。

select

select的實現機制是先定義一個含有一共1024比特的long型數組的結構fd_set,用來“存放”監聽的文件描述符,首先使用宏FD_ZERO把這個集合清空。然後使用宏FD_SET把需要監聽的文件描述符放在這個集合中,最後調用select函數來監聽這些文件描述符,可以一直阻塞等待直到有可操作的描述符才返回,也可以設置一個超時時間。
當調用select()函數的時候,內核會根據I/O狀態修改與此描述符匹配的fd_set中的標誌位。當select函數返回的時候,返回的是所有句柄列表,並沒有告知哪個描述符準備好了。需要手動檢查哪個描述符對應的標誌位發生了變化,再對相應的描述符進行讀寫操作。

select API

int select (int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout);
  • 參數1:一般使用最大文件描述符+1
  • 參數2:關注讀狀態的描述符集,一般都用的這個
  • 參數3:關注寫狀態的描述符集,不用設置爲NULL
  • 參數4:異常狀態描述符集,沒用過,一般設置NULL
  • 參數5:設置阻塞超時時間,這個參數有3種可能。1. 設置空指針則一直等待,2. 等待timeval指定的固定時間,3. timeval結構值爲0,則每次調用都不等待。

select 示例

先看代碼把流程搞懂再來看總結性的話可能更有助於理解,所以我一直喜歡直接貼示例代碼。
這是一個完整的TCP Server代碼,可以同時處理多個客戶端連接。可以直接跳過前面socket直接看後面的select相關代碼。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <netdb.h>
#include <sys/select.h>

#define BACKLOG 5
#define BUFF_SIZE 200
#define DEFAULT_PORT 6666

typedef struct {
    int fd;                  /* client's connection descriptor */
    struct sockaddr_in addr; /* client's address */
} CLIENT;

int main(int argc, char *argv[])
{
    int SERVER_PORT = DEFAULT_PORT;

    if (argc > 2)
        printf("param err:\nUsage:\n\t%s port | %s\n\n", argv[0], argv[0]);
    if (argc == 2)
        SERVER_PORT = atoi(argv[1]);

    int i, maxi, maxfd, nready, nbytes;
    int servSocket, cliSocket;
    
    // 定義fd_set集合
    fd_set allset, rset;
    
    socklen_t addrLen;
    char buffer[BUFF_SIZE];
    CLIENT client[FD_SETSIZE]; /* FD_SETSIZE == 1024 */
    struct sockaddr_in servAddr, cliAddr;

    if ((servSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket");
        exit(1);
    }

    int optval = 1;
    if (setsockopt(servSocket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
        perror("setsockopt");
        exit(0);
    }

    bzero(&servAddr, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_port = htons(SERVER_PORT);
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(servSocket, (struct sockaddr *)&servAddr, sizeof(servAddr)) < 0) {
        printf("bind");
        exit(1);
    }

    if (listen(servSocket, BACKLOG) < 0) {
        printf("listen err");
        exit(1);
    }
    printf("Listen Port: %d\nListening ...\n", SERVER_PORT);
	
    maxi = -1;
    maxfd = servSocket;
    // 把自定義的client數組中的fd都初始化爲-1
    for (i = 0; i < FD_SETSIZE; i++)
        client[i].fd = -1; /* -1 indicates available entry */

	// 清空allset集合的標誌位
    FD_ZERO(&allset);
    // 把監聽socket放入這個集合中
    FD_SET(servSocket, &allset);
    
    for (;;) {
        rset = allset;

		// 定義兩秒的超時時間
        struct timeval timeout;
        timeout.tv_sec = 2;
        timeout.tv_usec = 0;

		// 這個只關注可讀狀態的描述符,並設置固定的超時時間
        nready = select(maxfd + 1, &rset, NULL, NULL, &timeout);
        // 出錯返回-1
        if (nready < 0) {
            perror("select");
            break;
        }

        // 超時時間到了返回0
        else if (nready == 0) {
            printf("select time out\n");
            continue;
        }

        // 關注的描述符可操作,返回值>0
        // select返回的是整個集合,檢查監聽的socket是否可讀
        if (FD_ISSET(servSocket, &rset)) {
            addrLen = sizeof(cliAddr);
            // 監聽的socket可讀,直接調用accept接收請求
            if ((cliSocket = accept(servSocket, (struct sockaddr *)&cliAddr, &addrLen)) < 0) {
                perror("accept");
                exit(1);
            }

            printf("\nNew client connections %s:%d\n", inet_ntoa(cliAddr.sin_addr),
                   ntohs(cliAddr.sin_port));

            // 保存客戶端連接的socket,放在之前定義的client數組中
            for (i = 0; i < FD_SETSIZE; i++) {
                if (client[i].fd < 0) {
                    client[i].fd = cliSocket;
                    client[i].addr = cliAddr;
                    break;
                }
            }

            if (i == FD_SETSIZE)
                perror("too many clients");

            // 把剛剛接收的鏈接描述符放在關注集合中
            FD_SET(cliSocket, &allset);
            if (cliSocket > maxfd)
                maxfd = cliSocket; /* for select */

            if (i > maxi)
                maxi = i; /* max index in client[] array */

            if (--nready <= 0)
                continue; /* no more readable descriptors */
        }

		// 上一步處理了新連接,這裏處理已有連接可讀的socket
		// 遍歷所有的客戶連接socket
        for (i = 0; i <= maxi; i++)
        {
            if ((cliSocket = client[i].fd) < 0)
                continue;
			
			// 依次檢查每一個客戶連接是否可讀
            if (FD_ISSET(cliSocket, &rset)) {
                memset(buffer, 0, BUFF_SIZE);
                
                // 當前客戶連接可讀則直接使用recv接收數據
                nbytes = recv(cliSocket, buffer, sizeof(buffer), 0);
                if (nbytes < 0) {
                    perror("recv");
                    continue;
                } 
                // recv返回0表示客戶端斷開連接
                else if (nbytes == 0) {
                    printf("\nDisconnect %s:%d\n", inet_ntoa(client[i].addr.sin_addr),
                           ntohs(client[i].addr.sin_port));
                    close(cliSocket);
                    // 把此客戶端連接從關注集合中清除
                    FD_CLR(cliSocket, &allset);
                    client[i].fd = -1;
                } else {
                    printf("\nFrom %s:%d\n", inet_ntoa(client[i].addr.sin_addr),
                           ntohs(client[i].addr.sin_port));
                    printf("Recv: %sLength: %d\n\n", buffer, nbytes);
                }

                if (--nready <= 0)
                    break; /* no more readable descriptors */
            }
        }
    }
}

源碼下載:
https://github.com/lmshao/snippets/blob/master/c/Select_TcpServer.c

poll

上面說到select最多隻能支持1024個描述符,因爲它是使用含有1024比特的long型數組的結構fd_set來“存放”監聽的文件描述符,雖然可以在內核中修改此參數但是非常不方便。
爲了解決這個個數限制,後來就有了poll這個模型。select的結構fd_set是固定大小的,poll使用pollfd結構的數組來傳遞描述符,這個數組長度可以由用戶自己定義,其中一個結構標誌一個描述符,這下就解決了select的個數限制問題。

poll API

int poll (struct pollfd *__fds, unsigned long __nfds, int __timeout);
  • 參數1:指向一個結構數組第一個元素的指針,每個數組元素都是一個pollfd結構。
  • 參數2:上面數組中元素的個數
  • 參數3:這個超時時間和select的不太一樣。這裏直接使用整數值來表示等待的毫秒數,0表示立即返回不阻塞,UNP書上說INFTIM表示永遠等待,但是最新的Ubuntu上面函數定義就是讓設置爲-1,那就設置-1吧。
  • 返回值:>0 就緒的描述符個數,=0 等待超時, <0 出錯。
struct pollfd {
    int fd;			/* File descriptor to poll.  */
    short int events;		/* Types of events poller cares about.  */
    short int revents;		/* Types of events that actually occurred.  */
};

上面是這個pollfd的結構,每個描述符使用一個此結構來標誌。測試條件由events指定,函數在revents中返回該描述符的狀態。即你關注什麼狀態就把events設置相應的值,返回的時候系統使用revents告訴用戶發生了什麼事情。這個狀態值在系統中有宏定義。常見宏如下所示,正規TCP數據和UDP數據都被認爲是普通數據。

#define POLLRDNORM	0x040		/* 普通數據可讀 */
#define POLLWRNORM	0x100		/* 可以寫數據 */
#define POLLERR		0x008		/* 發生錯誤  */

poll 示例

還是TCP Server的示例。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <poll.h>

#define BACKLOG 5
#define BUFF_SIZE 200
#define DEFAULT_PORT 6666
#define OPEN_MAX 1024 // 這個值可以更大
int main(int argc, char **argv)
{
    int SERV_PORT = DEFAULT_PORT;

    if (argc > 2)
        printf("param err:\nUsage:\n\t%s port | %s\n\n", argv[0], argv[0]);
    if (argc == 2)
        SERV_PORT = atoi(argv[1]);

    int i, maxi, nready;
    int servSocket, cliSocket;
    ssize_t nbytes;
    char buf[BUFF_SIZE];
    socklen_t addrLen;
    struct pollfd client[OPEN_MAX];  // 定義一個很大的 pollfd 數組
    struct sockaddr_in cliAddr, servAddr;

    if ((servSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket");
        exit(1);
    }

    int optval = 1;
    if (setsockopt(servSocket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
        perror("setsockopt");
        exit(0);
    }

    bzero(&servAddr, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_port = htons(SERV_PORT);
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(servSocket, (struct sockaddr *)&servAddr, sizeof(servAddr)) < 0) {
        perror("bind");
        exit(1);
    }

    if (listen(servSocket, BACKLOG) < 0) {
        perror("listen err");
        exit(1);
    }
    printf("Listen Port: %d\nListening ...\n", SERV_PORT);

    // 先把listen的描述符放進數組
    client[0].fd = servSocket;
    client[0].events = POLLRDNORM;  // 關注可讀狀態
    // 初始化此數組
    for (i = 1; i < OPEN_MAX; i++)
        client[i].fd = -1; /* -1 indicates available entry */
    maxi = 0;              /* max index into client[] array */

    for (;;) {
        // 開始監聽啦~
        nready = poll(client, maxi + 1, -1);
        if (nready < 0) { // 報錯了
            printf("poll err");
            exit(1);
        }
        // servSocket可讀,說明有新鏈接來了
        if (client[0].revents & POLLRDNORM)
        {
            addrLen = sizeof(cliAddr);
            if ((cliSocket = accept(servSocket, (struct sockaddr *)&cliAddr, &addrLen)) < 0) {
                printf("accept err");
                exit(1);
            }

            for (i = 1; i < OPEN_MAX; i++){
                if (client[i].fd < 0) {
                    client[i].fd = cliSocket; // 保存客戶端連接的描述符,按順序放在數組中
                    client[i].events = POLLRDNORM;  // 還是關注是否可讀
                    break;
                }
            }

            printf("\nNew client connections client[%d] %s:%d\n", i, inet_ntoa(cliAddr.sin_addr),
                   ntohs(cliAddr.sin_port));

            if (i == OPEN_MAX)
                printf("too many clients");

            if (i > maxi)
                maxi = i; /* max index in client[] array */

            if (--nready <= 0)
                continue; /* no more readable descriptors */
        }

        // 循環檢查所有的客戶端連接
        for (i = 1; i <= maxi; i++)
        {
            if ((cliSocket = client[i].fd) < 0)
                continue;

            if (client[i].revents & (POLLRDNORM | POLLERR)) {
                memset(buf, 0, BUFF_SIZE);
                nbytes = recv(cliSocket, buf, BUFF_SIZE, 0);
                if (nbytes < 0) {
                    printf("recv err");
                    continue;
                } else if (nbytes == 0) {
                    printf("client[%d] closed connection\n", i);
                    close(cliSocket);
                    client[i].fd = -1;  // 客戶端斷開連接,重置標誌位
                } else {
                    printf("\nFrom client[%d]\n", i);
                    printf("Recv: %sLength: %d\n\n", buf, (int)nbytes);
                }

                if (--nready <= 0)
                    break; /* no more readable descriptors */
            }
        }
    }
}

源碼下載:
https://github.com/lmshao/snippets/blob/master/c/Poll_TcpServer.c

epoll

雖然poll解決了select的描述符個數限制,但是實現機制都是把用戶態的描述符copy到內核態,然後全部吐出來,用戶手動去遍歷查詢。且隨着數量增長,其性能也會大幅下降。於是各個平臺就搞了新的I/O複用機制,Linux的是epoll,Windows的是IOCP,Unix的是Kqueue。

epoll 模型中一個重要的概念是epoll instance,epoll實例是一種內核數據結構,從用戶空間來看的話,可以理解爲兩個list。

  • interest list (epoll set): 進程註冊要監視的一組文件描述符。
  • ready list: 是 interest list 中處於準備狀態的一組文件描述符,由內核動態地把準備好的文件描述符放倒這個集合中。

這麼看的話epoll是用一個描述符來管理多個描述符,先來看看epoll的API。

epoll API

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

epoll_event 爲用戶傳入的參數結構體,用戶標誌一個描述符。
events標誌關注的epoll事件,在sys.epoll.henum EPOLL_EVENTS中有宏定義。常見宏如下

  • EPOLLIN :文件描述符可以讀(包括對端SOCKET正常關閉);
  • EPOLLOUT:文件描述符可以寫;
  • EPOLLPRI:文件描述符有緊急的數據可讀(這裏應該表示有帶外數據到來);
  • EPOLLERR:文件描述符發生錯誤;
  • EPOLLHUP:文件描述符被掛起;
  • EPOLLET: 將EPOLL設爲**邊緣觸發(Edge Triggered)**模式,這是相對於水平觸發(Level Triggered)來說的。關於觸發模式接下來再細說。
  • EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列裏

epoll_create

int epoll_create (int __size);
int epoll_create1 (int __flags);

epoll_create 創建一個新的epoll實例並返回此實例的描述符,epoll_create1與前一個功能一樣,使用 FLAGS 代替未使用的 SIZE。此後的用戶關心的描述符結構與此描述符進行綁定或者解綁就可以了。

epoll_ctl

int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);

epoll_ctl 註冊感興趣的文件描述符,把文件描述符添加到epoll實例的interest list感興趣列表中。

epoll_wait

int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);

epoll_wait 監聽 epoll 實例_epfd中I/O事件,如果當前沒有可用事件將阻塞當前線程。函數返回值爲可操作的ready事件個數,第二個參數__eventsready事件結構數組指針。第三個參數一般爲第二個參數的數組長度。
也就是說epoll只返回可操作性的文件描述符,而不是把所有的描述符都返回來讓用戶去遍歷哪個可操作。

epoll 的觸發模式

水平觸發(LT,Level Triggered)
epoll_wait() 會通知你某個描述符上有數據可讀寫,如果你不處理,下次調用的時候還會通知你,直到你處理爲止。如果有大量不關心的文件描述符出現可讀寫狀態,就會一直通知你,嚴重影響你檢查關心的文件描述符的效率。

邊緣觸發(ET, Edge Triggered)
與水平觸發模式相反,調用epoll_wait()的時候會通知你哪個文件描述符可讀寫,如果你不處理或者沒處理完下次也不通知你,只通知你這一次,愛咋咋地。直到第二次有數據可讀寫的時候再次通知你。這種效率比較高,但是不能保證數據的完整性,如果一次處理不完就不告訴你了。

epoll 示例

同上,也是個TCP Server示例。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <sys/epoll.h>

#define BACKLOG 5
#define BUFF_SIZE 200
#define DEFAULT_PORT 6666
#define MAX_EVENTS 10

int main(int argc, char *argv[])
{
    int SERVER_PORT = DEFAULT_PORT;

    if (argc > 2)
        printf("param err:\nUsage:\n\t%s port | %s\n\n", argv[0], argv[0]);
    if (argc == 2)
        SERVER_PORT = atoi(argv[1]);

    int nbytes;
    char buffer[BUFF_SIZE];

    int servSocket, cliSocket;
    socklen_t addrLen;
    struct sockaddr_in servAddr, cliAddr;

    struct epoll_event ev, readyEvents[MAX_EVENTS];
    int nfds, epollfd;

    if ((servSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        printf("socket err");
        exit(1);
    }

    int optval = 1;
    if (setsockopt(servSocket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
        perror("setsockopt");
        exit(0);
    }

    bzero(&servAddr, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_port = htons(SERVER_PORT);
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(servSocket, (struct sockaddr *)&servAddr, sizeof(servAddr)) < 0) {
        perror("bind");
        exit(1);
    }

    if (listen(servSocket, BACKLOG) < 0) {
        perror("listen");
        exit(1);
    }
    printf("Listen Port: %d\nListening ...\n", SERVER_PORT);

    // 創建一個epoll實例
    if ((epollfd = epoll_create1(0)) == -1) {
        perror("epoll_create");
        exit(1);
    }

    // ev是一個臨時的變量,設置關心的描述符和關心的事件,然後把此結構與epoll實例綁定
    ev.events = EPOLLIN;
    ev.data.fd = servSocket;
    // 給epoll實例感興趣列表添加一個事件
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, servSocket, &ev) == -1) {
        perror("epoll_ctl");
        exit(1);
    }

    for (;;) {
        // 等待epollfd表示的epoll實例中的事件變化,返回準備好的事件集合readyEvents
        if ((nfds = epoll_wait(epollfd, readyEvents, MAX_EVENTS, -1)) == -1) {
            perror("epoll_wait");
            exit(1);
        }

        for (int n = 0; n < nfds; n++) {
            // 有新連接到來了
            if (readyEvents[n].data.fd == servSocket) {
                cliSocket = accept(servSocket, (struct sockaddr *)&cliAddr, &addrLen);
                if (cliSocket == -1) {
                    perror("accept");
                    exit(1);
                }

                printf("\nNew client connections client[%d] %s:%d\n", cliSocket,
                       inet_ntoa(cliAddr.sin_addr), ntohs(cliAddr.sin_port));

                ev.events = EPOLLIN | EPOLLET;  // 設置關心可讀狀態和邊緣觸發模式
                ev.data.fd = cliSocket;
                // 把心連接描述符加到epoll實例感興趣列表
                if (epoll_ctl(epollfd, EPOLL_CTL_ADD, cliSocket, &ev) == -1) {
                    perror("epoll_ctl: cliSocket");
                    exit(1);
                }
            } else {
                // 已有連接發數據過來了,開始接收數據~
                cliSocket = readyEvents[n].data.fd;

                memset(buffer, 0, BUFF_SIZE);
                /* recv data */
                nbytes = recv(cliSocket, buffer, sizeof(buffer), 0);
                if (nbytes < 0) {
                    perror("recv");
                    continue;
                } else if (nbytes == 0) {
                    printf("\nDisconnect fd[%d]\n", cliSocket);
                    close(cliSocket);
                    // 關閉文件描述符epoll實例會自動移除此描述符,
                    // 也可以使用EPOLL_CTL_DEL手動移除
                } else {
                    printf("\nFrom fd[%d]", cliSocket);
                    printf("\nRecv: %sLength: %d\n\n", buffer, nbytes);
                }
            }
        }
    }

    //    return 0;
}

源碼下載:
https://github.com/lmshao/snippets/blob/master/c/Epoll_TcpServer.c

總結

select

優點
出現的比較早,很多平臺都支持,應用廣泛。

缺點
文件描述符有默認1024的個數限制。
每次都把描述符從用戶態copy到內核態,發生變化後然後再copy出來,調用者遍歷檢查所有描述符的可讀寫狀態。

poll

優點
沒有描述符個數限制。但是個數多的時候性能也會下降。

缺點
select除了個數限制外的缺點他都有。

epoll

優點
使用一個文件描述符管理多個描述符,沒有描述符個數限制。
事件驅動模式,每次調用只返回狀態改變的文件描述符。
也算是目前應用最廣泛的I/O複用類型,libevent libuv等異步事件庫都是使用的epoll。
缺點
沒查到。

因個人理解有限,文中有說得不對的地方請留言。

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