網絡服務器傳統上採用每個連接使用一個進程/線程的方式實現。但是由於資源使用和上下文切換時間等因素的影響,限制了服務器的併發能力,因此這種實現方式不適合那些需要處理併發的大量客戶端請求的高性能應用。一個解決辦法是在單線程上使用非阻塞I/O,以及準備就緒通知方法,它在可以從套接字上讀或寫更多數據時通知你。
本文介紹Linux的 epoll 機制,它是Linux下最好的準備就緒通知機制。我們將用C給出一個完整的TCP服務器實現。我假定你有C編程經驗,知道在Linux上如何編譯和運行程序,並能夠在Man手冊查看用到的各種C函數。
epoll 是在Linux 2.6中引入的,在其他的類UNIX操作系統中不可用。它提供了和 select 、 poll 類似的機制:
- select 可以最多同時監視 FD_SETSIZE 個描述符,通常是一個較小的數。
- poll 沒有同時監視的描述符個數的限制,但是它在每次檢查準備就緒的通知時需要掃描所有的描述符,這是O(n)的而且比較慢。
epoll 沒有固定的限制,也不執行線性檢查,因此它的效率更高,可以處理更多的事件。
用 epoll_create 或 epoll_create1 創建 epoll 實例。 epoll_ctl 用來添加/刪除需要觀察的描述符。用 epoll_wait 等待觀察集合上的事件,它阻塞直到有事件發生。更多的相關信息請見Man手冊。
當描述符添加到 epoll 實例中時,有兩種模式:水平觸發和邊緣觸發。當你使用水平觸發模式時,如果數據可讀, epoll_wait 會總是返回準備好的事件。如果數據沒有讀完,再次調用 epoll_wait ,它會再次返回這個描述符的準備好的事件,因爲數據可讀。而邊緣觸發模式中,只能得到一次準備就緒通知。如果你沒有讀取全部數據,然後再次調用 epoll_wait 來查看該描述符,它將阻塞,因爲準備就緒事件已經發送過了。
傳遞給 epoll_ctl 的 epoll 事件結構如下。每個被觀察的描述符可以關聯一個整型變量或指針作爲用戶數據。
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 */ };
現在讓我們開始寫代碼。我們將實現一個微型服務器,它將打印所有發送到套接字的數據到標準輸出。我們將從寫一個創建並綁定TCP套接字的函數 create_and_bind 開始:
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; }
create_and_bind包括了一段可移植的獲取IPv4或IPv6套接字的標準代碼塊。它以字符串形式接受一個端口參數,可以用 argv[1] 來傳遞。 getaddrinfo 函數返回一串和 hints 參數兼容的 addrinfo 結構。
addrinfo 結構如下:
struct addrinfo { int ai_flags; int ai_family; int ai_socktype; int ai_protocol; size_t ai_addrlen; struct sockaddr *ai_addr; char *ai_canonname; struct addrinfo *ai_next; };
我們逐個遍歷這些結構,嘗試用它們創建套接字。如果成功, create_and_bind 函數返回套接字描述符,否則返回-1。
接下來,我們寫一個函數將套接字修改爲非阻塞。 make_socket_non_blocking 函數設置描述符的 O_NONBLOCK 標誌:
static 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; }
現在再來看main函數,它包含事件循環,是程序的主體:
#define MAXEVENTS 64 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 not ready 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, which means 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 incoming connections. */ 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 the list 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 and display it. We must read whatever data is available completely, as we are running in edge-triggered mode and won't get a notification again for the same data. */ 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 all data. 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 the connection. */ 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 it from the set of descriptors which are monitored. */ close (events[i].data.fd); } } } } free (events); close (sfd); return EXIT_SUCCESS; }
main 函數首先調用 create_and_bind 函數來建立套接字。然後將套接字設置爲非阻塞,然後調用 listen 函數。接下來創建一個 epoll 實例 efd ,以邊緣觸發模式向它添加監聽套接字 sfd 來觀察輸入事件。
外面的 while 循環是主事件循環。調用 epoll_wait 函數阻塞線程來等待事件。當有事件發生時, epoll_wait 函數通過 events 參數返回事件。
當我們添加新的要觀察的連接,以及移除已經終止的連接時, epoll 實例在事件循環中不斷更新。
當有事件發生時,有三種類型:
- 出錯。當一個錯誤條件發生時,或者事件不是一個有關數據可讀的通知,關閉關聯的描述符。關閉描述符會自動將它從 epoll 實例的觀察集合中移除。
- 新連接。當監聽描述符 sfd 可讀時,意味着有一個或多個新的連接到達。調用 accept 函數接受這些連接,打印一條消息,然後將套接字修改爲非阻塞並將它添加到 epoll 實例的觀察集合中。
- 客戶端數據。當任何客戶端描述符上有數據可讀時,我們使用 read 函數讀取數據。我們必須讀取全部可讀數據,因爲在邊緣觸發模式下只產生一個事件。讀取的數據用 write 函數寫到標準輸出。如果 read 函數返回0,代表遇到 EOF ,可以關閉客戶端連接了。如果爲-1並且 errno 爲 EAGAIN ,表示這個事件的所有數據都已經讀完,可以回到主循環。
就是這樣。不斷循環,向觀察集合中添加和刪除描述符。