小米C++軟開二面:select、poll、epoll 三者之間的區(qū)別,epoll 為什么高效?
IO 多路復(fù)用技術(shù)允許一個(gè)線程同時(shí)監(jiān)視多個(gè)文件描述符(可以理解為每個(gè)客人的 “服務(wù)需求信號(hào)”),當(dāng)其中任何一個(gè)或多個(gè)文件描述符就緒(有數(shù)據(jù)可讀、可寫或發(fā)生異常,就好比客人有新的需求,如點(diǎn)菜、加飲料等)時(shí),內(nèi)核就會(huì)通知應(yīng)用程序進(jìn)行相應(yīng)的處理 。這樣,應(yīng)用程序就無需為每個(gè)文件描述符單獨(dú)創(chuàng)建線程,避免了線程資源的浪費(fèi)和頻繁的上下文切換,大大提高了系統(tǒng)的效率和性能。
在 Linux 系統(tǒng)中,常見的 IO 多路復(fù)用技術(shù)有 select、poll 和 epoll。它們就像是不同等級(jí)的 “服務(wù)助手”,雖然都能實(shí)現(xiàn)一個(gè)線程處理多個(gè) I/O 流的功能,但在性能、使用方式和適用場(chǎng)景上卻各有千秋。接下來,就讓我們深入了解一下這三位 “服務(wù)助手”。
一、生活實(shí)例舉例
在正式介紹 select、poll、epoll 之前,我們先來設(shè)想一個(gè)場(chǎng)景。假如你是一個(gè)便利店老板,你的店里有很多顧客進(jìn)進(jìn)出出,你需要關(guān)注每個(gè)顧客的行為,比如是否有新顧客進(jìn)門,是否有顧客需要結(jié)賬,是否有顧客存在偷竊行為。在這個(gè)場(chǎng)景中,顧客就相當(dāng)于文件描述符(fd),而你關(guān)注的這些行為就相當(dāng)于文件描述符上發(fā)生的事件。
- select 方式:select 就像是你雇了一個(gè)保安,這個(gè)保安會(huì)幫你盯著所有的顧客。他會(huì)告訴你有顧客發(fā)生了某些事件,但是他不會(huì)告訴你具體是哪個(gè)顧客發(fā)生了什么事件。所以,當(dāng)保安通知你有事件發(fā)生時(shí),你就需要一個(gè)個(gè)地去問每個(gè)顧客 “你是不是要結(jié)賬了?”“你是不是有什么問題?”,直到找到那個(gè)發(fā)生事件的顧客。而且,這個(gè)保安一次最多只能幫你盯著 1024 個(gè)顧客。
- poll 方式:poll 也像是一個(gè)保安,他和 select 保安的功能差不多,也是會(huì)告訴你有顧客發(fā)生了事件,但同樣不會(huì)告訴你具體是哪個(gè)顧客。不過,poll 保安比 select 保安厲害的地方在于,他沒有顧客數(shù)量的限制,理論上可以幫你盯著無數(shù)個(gè)顧客。但是,當(dāng)他通知你有事件發(fā)生時(shí),你還是得像問 select 保安那樣,一個(gè)個(gè)地去問顧客發(fā)生了什么事。
- epoll 方式:epoll 則像是一個(gè)超級(jí)智能監(jiān)控系統(tǒng),這個(gè)系統(tǒng)不僅能監(jiān)控?zé)o數(shù)個(gè)顧客,而且當(dāng)有顧客發(fā)生事件時(shí),它會(huì)直接告訴你是哪個(gè)顧客發(fā)生了什么事件。比如,它會(huì)直接告訴你 “3 號(hào)顧客要結(jié)賬了”“5 號(hào)顧客有偷竊行為”,你可以直接根據(jù)這些信息去處理相應(yīng)的事件,而不需要再一個(gè)個(gè)地去詢問顧客。
二、select:基礎(chǔ)但有限的 “監(jiān)控”
2.1 select的工作原理
select 是最早被廣泛使用的 I/O 多路復(fù)用技術(shù),它就像是一個(gè)基礎(chǔ)款的 “服務(wù)助手”,在網(wǎng)絡(luò)編程的歷史長河中,曾發(fā)揮過重要的作用 ;select 函數(shù)的使用需要傳入三個(gè)文件描述符集合(可讀集合 readfds、可寫集合 writefds、異常集合 exceptfds),分別用于監(jiān)聽文件描述符的讀、寫和異常事件。同時(shí),還需要傳入一個(gè)超時(shí)時(shí)間參數(shù) timeout,用于控制 select 函數(shù)的阻塞時(shí)間。
在使用 select 之前,我們需要先初始化這三個(gè)文件描述符集合,將需要監(jiān)聽的文件描述符添加到相應(yīng)的集合中。例如,在一個(gè)簡單的 TCP 服務(wù)器中,我們可能會(huì)將監(jiān)聽套接字添加到讀集合中,以監(jiān)聽客戶端的連接請(qǐng)求。
#include <sys/select.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#define PORT 8888
#define MAX_CLIENTS 100
int main() {
int server_fd, new_socket, client_fds[MAX_CLIENTS];
struct sockaddr_in address;
int addrlen = sizeof(address);
fd_set read_fds, temp_fds;
int max_sd;
// 創(chuàng)建套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 綁定套接字
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
// 監(jiān)聽連接
listen(server_fd, 3);
// 初始化客戶端文件描述符數(shù)組
for (int i = 0; i < MAX_CLIENTS; i++) {
client_fds[i] = -1;
}
// 初始化文件描述符集合
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
max_sd = server_fd;
while (1) {
temp_fds = read_fds;
// 調(diào)用select函數(shù),等待事件發(fā)生
int activity = select(max_sd + 1, &temp_fds, NULL, NULL, NULL);
if (activity < 0) {
perror("select error");
break;
} else if (activity > 0) {
// 檢查是否有新的連接請(qǐng)求
if (FD_ISSET(server_fd, &temp_fds)) {
new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) {
client_fds[i] = new_socket;
break;
}
}
FD_SET(new_socket, &read_fds);
if (new_socket > max_sd) {
max_sd = new_socket;
}
}
// 檢查已連接客戶端是否有數(shù)據(jù)可讀
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_fds[i];
if (sd != -1 && FD_ISSET(sd, &temp_fds)) {
char buffer[1024] = {0};
int valread = read(sd, buffer, 1024);
if (valread == 0) {
// 客戶端關(guān)閉連接
close(sd);
FD_CLR(sd, &read_fds);
client_fds[i] = -1;
} else {
// 處理客戶端數(shù)據(jù)
printf("Received from client: %s\n", buffer);
}
}
}
}
}
close(server_fd);
return 0;
}當(dāng)我們調(diào)用 select 函數(shù)時(shí),它會(huì)將這三個(gè)文件描述符集合從用戶空間拷貝到內(nèi)核空間,然后內(nèi)核會(huì)遍歷這些文件描述符,檢查是否有任何一個(gè)文件描述符上有事件發(fā)生(例如,是否有數(shù)據(jù)可讀、是否可以寫入數(shù)據(jù)、是否發(fā)生異常等)。如果有事件發(fā)生,內(nèi)核會(huì)將對(duì)應(yīng)的文件描述符在相應(yīng)的集合中標(biāo)記出來,然后將這三個(gè)集合從內(nèi)核空間拷貝回用戶空間 。select 函數(shù)返回后,我們需要再次遍歷這三個(gè)文件描述符集合,使用 FD_ISSET 宏來檢查哪些文件描述符上有事件發(fā)生,并進(jìn)行相應(yīng)的處理。例如,如果某個(gè)文件描述符在可讀集合中被標(biāo)記,那么我們就可以對(duì)該文件描述符進(jìn)行讀操作。
select 的工作方式就像是一個(gè)大管家,它通過檢查一組文件描述符(fd)的狀態(tài),來判斷是否有 I/O 事件發(fā)生。它維護(hù)了三個(gè)集合:讀集合、寫集合和異常集合。在調(diào)用 select 時(shí),我們把需要監(jiān)控的 fd 分別加入到這三個(gè)集合中,然后 select 就會(huì)去檢查這些 fd 是否有可讀、可寫或異常的事件發(fā)生。如果有,select 就會(huì)返回,告訴我們哪些 fd 有事件發(fā)生。為了更形象地理解,我們可以把 fd 想象成一個(gè)個(gè)的小房間,每個(gè)房間都可能發(fā)生一些事件,比如有人敲門(可讀事件)、可以把東西送進(jìn)去(可寫事件)或者房間里發(fā)生了異常情況(異常事件)。select 就像是一個(gè)保安,它會(huì)在所有的房間外面巡邏,檢查每個(gè)房間的情況。如果有房間發(fā)生了事件,保安就會(huì)回來告訴我們是哪些房間發(fā)生了事件。
2.2 select 的特點(diǎn)與局限性
盡管 select 曾經(jīng)在網(wǎng)絡(luò)編程中占據(jù)重要地位,但隨著高并發(fā)需求的不斷增長,它的缺點(diǎn)也逐漸暴露出來,就像一位能力有限的 “服務(wù)助手”,在面對(duì)大規(guī)模的 “客人” 時(shí),顯得力不從心。
首先,select 對(duì)單個(gè)進(jìn)程可監(jiān)視的文件描述符數(shù)量存在限制,這個(gè)限制通常是 1024 個(gè)(在 Linux 系統(tǒng)中,可通過修改宏定義 FD_SETSIZE 來改變這個(gè)值,但這并不是一個(gè)理想的解決方案,因?yàn)樗鼤?huì)帶來兼容性和維護(hù)性的問題)。在如今的高并發(fā)場(chǎng)景下,一個(gè)服務(wù)器可能需要同時(shí)處理成千上萬的連接,1024 個(gè)文件描述符的限制顯然無法滿足需求。例如,一個(gè)大型的在線游戲服務(wù)器,可能需要同時(shí)處理數(shù)萬玩家的連接,如果使用 select,根本無法實(shí)現(xiàn)如此大規(guī)模的連接管理。
其次,select 每次調(diào)用都需要將整個(gè)文件描述符集合從用戶空間拷貝到內(nèi)核空間,在內(nèi)核檢查完事件后,又需要將結(jié)果從內(nèi)核空間拷貝回用戶空間。這種頻繁的數(shù)據(jù)拷貝操作會(huì)帶來較大的開銷,降低系統(tǒng)的性能。想象一下,每次服務(wù)員都要將所有客人的信息在兩個(gè)地方來回傳遞,不僅浪費(fèi)時(shí)間,還容易出錯(cuò)。
最后,select 返回后,我們需要遍歷整個(gè)文件描述符集合,才能找到哪些文件描述符上有事件發(fā)生,這個(gè)過程的時(shí)間復(fù)雜度為 O (n)。當(dāng)文件描述符數(shù)量較多時(shí),遍歷的時(shí)間開銷會(huì)非常大,導(dǎo)致系統(tǒng)的響應(yīng)速度變慢。例如,當(dāng)有 1000 個(gè)文件描述符時(shí),select 返回后,我們需要進(jìn)行 1000 次檢查,才能確定哪些文件描述符就緒,這無疑會(huì)消耗大量的時(shí)間和資源。
2.3 select的適用場(chǎng)景
由于 select 的局限性,它更適用于小規(guī)模連接和處理簡單的并發(fā)場(chǎng)景。比如一些簡單的服務(wù)器程序,并發(fā)連接數(shù)較少,對(duì)性能要求不是特別高,這時(shí)使用 select 就可以滿足需求,因?yàn)樗a實(shí)現(xiàn)簡單,易于理解和使用 。就像一個(gè)小商店,顧客數(shù)量不多,老板自己就可以輕松地照顧到每個(gè)顧客的需求,不需要太復(fù)雜的管理方式。
三、poll:改進(jìn)但仍有不足的 “監(jiān)控”
3.1 poll 的工作原理
poll 是對(duì) select 的改進(jìn),它就像是 select 的進(jìn)階版 “服務(wù)助手”,在功能上與 select 有許多相似之處,但也有一些自己的特點(diǎn);poll 同樣用于監(jiān)聽多個(gè)文件描述符上的事件,以確定哪些文件描述符可以進(jìn)行讀、寫或發(fā)生了異常等操作 。與 select 不同的是,poll 使用一個(gè) pollfd 結(jié)構(gòu)體數(shù)組來表示文件描述符集合。每個(gè) pollfd 結(jié)構(gòu)體包含三個(gè)成員:fd(文件描述符)、events(表示需要監(jiān)聽的事件,如 POLLIN 表示可讀事件,POLLOUT 表示可寫事件等)和 revents(表示實(shí)際發(fā)生的事件,由內(nèi)核在 poll 函數(shù)返回時(shí)設(shè)置)。
下面是一個(gè)使用 poll 的簡單示例代碼,展示了如何使用 poll 監(jiān)聽套接字的讀事件:
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 8888
#define MAX_CLIENTS 100
int main() {
int server_fd, new_socket, client_fds[MAX_CLIENTS];
struct sockaddr_in address;
int addrlen = sizeof(address);
struct pollfd fds[MAX_CLIENTS + 1];
int num_fds = 1;
// 創(chuàng)建套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 綁定套接字
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
// 監(jiān)聽連接
listen(server_fd, 3);
// 初始化客戶端文件描述符數(shù)組
for (int i = 0; i < MAX_CLIENTS; i++) {
client_fds[i] = -1;
}
// 初始化pollfd數(shù)組
fds[0].fd = server_fd;
fds[0].events = POLLIN;
while (1) {
// 調(diào)用poll函數(shù),等待事件發(fā)生
int activity = poll(fds, num_fds, -1);
if (activity < 0) {
perror("poll error");
break;
} else if (activity > 0) {
// 檢查是否有新的連接請(qǐng)求
if (fds[0].revents & POLLIN) {
new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) {
client_fds[i] = new_socket;
fds[num_fds].fd = new_socket;
fds[num_fds].events = POLLIN;
num_fds++;
break;
}
}
}
// 檢查已連接客戶端是否有數(shù)據(jù)可讀
for (int i = 1; i < num_fds; i++) {
int sd = fds[i].fd;
if (fds[i].revents & POLLIN) {
char buffer[1024] = {0};
int valread = read(sd, buffer, 1024);
if (valread == 0) {
// 客戶端關(guān)閉連接
close(sd);
for (int j = i; j < num_fds - 1; j++) {
fds[j] = fds[j + 1];
client_fds[j - 1] = client_fds[j];
}
num_fds--;
} else {
// 處理客戶端數(shù)據(jù)
printf("Received from client: %s\n", buffer);
}
}
}
}
}
close(server_fd);
return 0;
}在使用 poll 時(shí),我們將需要監(jiān)聽的文件描述符及其感興趣的事件填充到 pollfd 數(shù)組中,然后調(diào)用 poll 函數(shù)。poll 函數(shù)會(huì)將這個(gè)數(shù)組從用戶空間拷貝到內(nèi)核空間,內(nèi)核會(huì)遍歷這個(gè)數(shù)組,檢查每個(gè)文件描述符上的事件是否發(fā)生 。如果有事件發(fā)生,內(nèi)核會(huì)將對(duì)應(yīng)的文件描述符在 revents 成員中標(biāo)記出來,然后將數(shù)組從內(nèi)核空間拷貝回用戶空間 ;poll 函數(shù)返回后,我們通過檢查 pollfd 數(shù)組中每個(gè)元素的 revents 成員,來確定哪些文件描述符上有事件發(fā)生,并進(jìn)行相應(yīng)的處理。例如,如果某個(gè)文件描述符的 revents 成員中設(shè)置了 POLLIN 標(biāo)志,就表示該文件描述符有數(shù)據(jù)可讀。
poll的工作方式是,將用戶傳入的pollfd數(shù)組拷貝到內(nèi)核空間,然后內(nèi)核會(huì)遍歷這個(gè)數(shù)組,查詢每個(gè)fd對(duì)應(yīng)的設(shè)備狀態(tài)。如果設(shè)備狀態(tài)滿足events中設(shè)置的事件(比如可讀、可寫、異常等),就會(huì)在revents中標(biāo)記該事件。當(dāng)所有的fd都檢查完后,poll函數(shù)返回,返回值表示有多少個(gè)fd的狀態(tài)發(fā)生了變化。
我們還是以便利店的例子來說明,poll就像是一個(gè)升級(jí)版的保安,他手里拿著一個(gè)顧客信息表(pollfd數(shù)組),上面記錄了每個(gè)顧客的信息以及需要關(guān)注的事件(events)。保安會(huì)逐個(gè)檢查每個(gè)顧客的情況,然后把發(fā)生了事件的顧客信息記錄在表格的另一欄(revents)中。最后,保安把這個(gè)表格交還給老板,老板可以根據(jù)表格上的信息知道哪些顧客發(fā)生了什么事件。
3.2 poll 相對(duì) select 的改進(jìn)
poll 相對(duì)于 select 有一些明顯的改進(jìn),這使得它在處理高并發(fā)場(chǎng)景時(shí)比 select 更具優(yōu)勢(shì),就像一位經(jīng)過培訓(xùn)提升的 “服務(wù)助手”,能力有所增強(qiáng)。
首先,poll 解決了 select 文件描述符數(shù)量的限制問題。在 select 中,由于使用固定大小的 fd_set 來表示文件描述符集合,導(dǎo)致單個(gè)進(jìn)程可監(jiān)視的文件描述符數(shù)量受到 FD_SETSIZE 的限制(通常為 1024)。而 poll 使用 pollfd 結(jié)構(gòu)體數(shù)組,理論上對(duì)文件描述符的數(shù)量沒有限制(實(shí)際受限于系統(tǒng)資源,如內(nèi)存等),這使得它能夠處理更多的并發(fā)連接。例如,在一個(gè)需要支持大量并發(fā)連接的網(wǎng)絡(luò)服務(wù)器中,poll 可以輕松應(yīng)對(duì)成千上萬的客戶端連接,而 select 則會(huì)因?yàn)槲募枋龇麛?shù)量的限制而無法滿足需求。
其次,poll 在編程接口上更加靈活和簡潔。select 需要分別處理讀、寫和異常三個(gè)文件描述符集合,并且每次調(diào)用 select 之前都需要重新設(shè)置這些集合。而 poll 將監(jiān)聽事件和返回事件合并在一個(gè) pollfd 結(jié)構(gòu)體中,只需要維護(hù)一個(gè)數(shù)組,使用起來更加方便。例如,在添加或修改一個(gè)文件描述符的監(jiān)聽事件時(shí),poll 只需要修改 pollfd 數(shù)組中對(duì)應(yīng)元素的 events 成員即可,而 select 則需要分別在三個(gè)文件描述符集合中進(jìn)行操作,代碼更加繁瑣。
3.3 poll的特點(diǎn)與局限性
poll 相比于 select 有一些優(yōu)點(diǎn):
- 無連接數(shù)限制:理論上對(duì)文件描述符的數(shù)量沒有限制,不像 select 受限于FD_SETSIZE。這就好比保安可以管理無數(shù)個(gè)顧客,而沒有數(shù)量上限。
- 接口使用方便:pollfd結(jié)構(gòu)體中包含了要監(jiān)視的events和發(fā)生的revents,對(duì)于監(jiān)聽集合的輸入輸出是分離的,不需要在調(diào)用poll函數(shù)之前重新對(duì)參數(shù)進(jìn)行設(shè)置 。不像 select,每次調(diào)用都要重新設(shè)置fd_set集合。這就好比保安每次巡邏回來,不需要重新整理顧客信息表,只需要查看哪些顧客發(fā)生了事件即可。
然而,poll 也存在一些局限性:
- 效率問題:雖然 poll 沒有了文件描述符數(shù)量的限制,但它和 select 一樣,每次調(diào)用都需要遍歷整個(gè)文件描述符集合,時(shí)間復(fù)雜度為 O (n)。當(dāng)文件描述符數(shù)量很多時(shí),效率仍然較低。就像保安檢查顧客時(shí),還是需要一個(gè)個(gè)地檢查,不管顧客是否有事件發(fā)生,都要檢查一遍,當(dāng)顧客數(shù)量很多時(shí),這個(gè)過程會(huì)非常耗時(shí)。
- 數(shù)據(jù)復(fù)制開銷:每次調(diào)用 poll 都需要將pollfd數(shù)組從用戶態(tài)拷貝到內(nèi)核態(tài),當(dāng)fd很多時(shí),這個(gè)開銷也不容小覷。這就好比每次保安巡邏都需要把顧客信息表從老板那里拿到自己手里,這個(gè)過程也會(huì)消耗一定的時(shí)間和精力。
- 觸發(fā)方式單一:poll 只支持水平觸發(fā)(LT),這意味著只要文件描述符上還有未處理的數(shù)據(jù),它就會(huì)一直通知應(yīng)用程序,可能會(huì)導(dǎo)致應(yīng)用程序重復(fù)處理一些事件。而在一些場(chǎng)景下,邊緣觸發(fā)(ET)會(huì)更加高效,它只會(huì)在文件描述符狀態(tài)發(fā)生變化時(shí)通知應(yīng)用程序。
3.4 poll 的適用場(chǎng)景
由于 poll 沒有連接數(shù)限制且使用相對(duì)簡單,適用于連接數(shù)較多但活躍連接比例不確定的場(chǎng)景 。例如,一些即時(shí)通訊服務(wù)器,可能會(huì)有大量的用戶連接,但并不是每個(gè)用戶都會(huì)頻繁發(fā)送消息,這時(shí)使用 poll 就可以較好地管理這些連接。在這種場(chǎng)景下,poll 的無連接數(shù)限制可以滿足大量用戶連接的需求,而水平觸發(fā)雖然可能會(huì)導(dǎo)致一些重復(fù)通知,但對(duì)于即時(shí)通訊這種對(duì)實(shí)時(shí)性要求較高的場(chǎng)景來說,影響相對(duì)較小 。就像便利店在節(jié)假日可能會(huì)迎來大量顧客,雖然不是每個(gè)顧客都會(huì)馬上有需求,但使用 poll 這種方式可以有效地管理這些顧客,及時(shí)響應(yīng)有需求的顧客。
四、epoll:高效強(qiáng)大的 “監(jiān)控”
在 select 和 poll 逐漸難以滿足高并發(fā)需求的情況下,epoll 這位 “王者選手” 閃亮登場(chǎng),它就像是一位擁有超能力的 “超級(jí)服務(wù)助手”,專為解決高并發(fā)場(chǎng)景下的 I/O 處理難題而生 。
epoll 是 Linux 內(nèi)核為處理大批量文件描述符而改進(jìn)的 poll,是 select/poll 的增強(qiáng)版本,誕生于 Linux 2.5.44 內(nèi)核版本 。它能顯著提高程序在大量并發(fā)連接中只有少量活躍的情況下的系統(tǒng) CPU 利用率 。epoll 采用了與 select 和 poll 截然不同的設(shè)計(jì)理念,摒棄了傳統(tǒng)的輪詢方式,采用事件驅(qū)動(dòng)機(jī)制,使得它在高并發(fā)場(chǎng)景下能夠如魚得水,高效地處理大量的 I/O 請(qǐng)求。
4.1 epoll的工作方式
①水平觸發(fā)(LT):持續(xù)通知的 “貼心管家”
水平觸發(fā)(LT)可是 epoll 的默認(rèn)工作模式,就像一位貼心管家,時(shí)刻關(guān)注著文件描述符的狀態(tài)。當(dāng)某個(gè)文件描述符處于就緒狀態(tài),比如有數(shù)據(jù)可讀或者可寫,內(nèi)核就會(huì)通知應(yīng)用程序。要是應(yīng)用程序這次沒處理完數(shù)據(jù),或者沒來得及處理,別擔(dān)心,下次調(diào)用 epoll_wait 時(shí),內(nèi)核依舊會(huì)不厭其煩地再次通知,直到數(shù)據(jù)被處理完或者緩沖區(qū)里沒數(shù)據(jù)可讀、可寫了為止。
舉個(gè)例子,在處理 HTTP 報(bào)文時(shí),數(shù)據(jù)可能是一段段陸續(xù)到達(dá)的。使用 LT 模式,只要緩沖區(qū)還有沒讀完的報(bào)文片段,每次 epoll_wait 都會(huì)把對(duì)應(yīng)的文件描述符事件返回,讓應(yīng)用程序可以分次從容地解析報(bào)文,不用擔(dān)心錯(cuò)過任何數(shù)據(jù),大大降低了編程復(fù)雜度,對(duì)新手程序員那是相當(dāng)友好,就像有個(gè)老師在旁邊,不停提醒你還有作業(yè)沒做完呢。
②邊緣觸發(fā)(ET):高效靈敏的 “情報(bào)員”
邊緣觸發(fā)(ET)模式則像一位高效靈敏的情報(bào)員,奉行 “只報(bào)新事” 原則。只有在文件描述符的狀態(tài)發(fā)生改變時(shí),比如從無數(shù)據(jù)變?yōu)橛袛?shù)據(jù)可讀,或者從不可寫變?yōu)榭蓪?,?nèi)核才會(huì)觸發(fā)事件通知應(yīng)用程序。一旦通知了,它就默認(rèn)你知曉此事,后續(xù)除非狀態(tài)再次改變,否則不會(huì)重復(fù)通知。這意味著應(yīng)用程序得打起十二分精神,在收到通知后,必須立刻、馬上處理數(shù)據(jù),而且要盡可能把當(dāng)前就緒的數(shù)據(jù)一次性處理完。
比如說讀取大型文件,使用 ET 模式,一旦檢測(cè)到文件描述符可讀,就得趕緊用 while 循環(huán)一股腦把數(shù)據(jù)全讀完,不然下次 epoll_wait 可不會(huì)再提醒你還有剩余數(shù)據(jù)。要是讀數(shù)據(jù)時(shí)遇到 EAGAIN 或 EWOULDBLOCK 錯(cuò)誤,那就說明這次數(shù)據(jù)真讀完了。這種模式雖然編程難度稍高,需要精細(xì)處理數(shù)據(jù),但減少了不必要的喚醒次數(shù),系統(tǒng)開銷小,在追求極致性能的場(chǎng)景下,那可是 “利器”,能讓數(shù)據(jù)如閃電般高效流轉(zhuǎn)。
當(dāng)然,在 LT 模式下開發(fā)基于 epoll 的應(yīng)用要簡單一些,不太容易出錯(cuò),而在 ET 模式下事件發(fā)生時(shí),如果沒有徹底地將緩沖區(qū)的數(shù)據(jù)處理完,則會(huì)導(dǎo)致緩沖區(qū)的用戶請(qǐng)求得不到響應(yīng)。注意,默認(rèn)情況下 Nginx 采用 ET 模式使用 epoll 的。
4.2 epoll核心數(shù)據(jù)結(jié)構(gòu)
(1)紅黑樹 —— 精準(zhǔn)管理的 “魔法樹”
紅黑樹在 epoll 里可是扮演著 “大管家” 的關(guān)鍵角色,它專門負(fù)責(zé)存儲(chǔ)和管理海量的文件描述符。這棵樹有著獨(dú)特的 “魔力”,它是一種自平衡的二叉搜索樹,意味著無論插入、刪除還是查找操作,時(shí)間復(fù)雜度都能穩(wěn)穩(wěn)地保持在 O (log n)。
想象一下,在高并發(fā)場(chǎng)景下,每秒有成千上萬個(gè)新連接涌入,每個(gè)連接對(duì)應(yīng)一個(gè)文件描述符。要是沒有紅黑樹,查找一個(gè)特定的文件描述符就如同大海撈針,效率極其低下。而有了紅黑樹,就好比給每個(gè)文件描述符都安排了一個(gè)專屬的智能導(dǎo)航。當(dāng)需要添加新連接(即新文件描述符)時(shí),它能快速指引插入位置;要關(guān)閉某個(gè)連接刪除對(duì)應(yīng)描述符,也能迅速定位并移除,絲毫不亂。舉個(gè)例子,在大型在線游戲服務(wù)器里,同時(shí)在線玩家眾多,網(wǎng)絡(luò)連接頻繁變動(dòng),紅黑樹就能高效管理這些連接,確保游戲運(yùn)行順暢,玩家操作即時(shí)響應(yīng),不會(huì)因連接管理混亂而卡頓。
(2)就緒鏈表 —— 即時(shí)響應(yīng)的 “情報(bào)站”
就緒鏈表就像是 epoll 的 “情報(bào)收集站”。當(dāng)某個(gè)被監(jiān)聽的文件描述符狀態(tài)發(fā)生變化,比如有數(shù)據(jù)可讀、可寫,內(nèi)核立馬知曉,并通過回調(diào)機(jī)制,閃電般地將這個(gè)就緒的文件描述符添加到就緒鏈表中。這個(gè)鏈表通常是用雙向鏈表實(shí)現(xiàn),插入和刪除操作那叫一個(gè)快,時(shí)間復(fù)雜度僅 O (1)。
打個(gè)比方,這就好比快遞驛站收到了你的包裹(數(shù)據(jù)就緒),立馬把你的取件碼(文件描述符)放到一個(gè)專門的 “待取貨架”(就緒鏈表)上,你一來就能快速拿到包裹。對(duì)于應(yīng)用程序而言,當(dāng)調(diào)用 epoll_wait 時(shí),根本不用費(fèi)時(shí)費(fèi)力去遍歷所有文件描述符,直接到這個(gè) “情報(bào)站”—— 就緒鏈表瞅一眼,就能瞬間獲取所有已就緒的文件描述符,第一時(shí)間進(jìn)行數(shù)據(jù)讀寫操作,大大提升了響應(yīng)速度,讓數(shù)據(jù)處理快如閃電。
(3)mmap—— 高效傳輸?shù)?“隱形橋梁”
mmap 堪稱 epoll 實(shí)現(xiàn)高效的幕后英雄,它搭建起了內(nèi)核空間與用戶空間的 “隱形橋梁”—— 共享內(nèi)存。在傳統(tǒng)的 I/O 操作里,數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū),這個(gè)過程就像搬運(yùn)工來回搬貨,費(fèi)時(shí)費(fèi)力,還增加系統(tǒng)開銷。
但有了 mmap 就不一樣了,它直接讓內(nèi)核空間和用戶空間共享同一塊內(nèi)存區(qū)域,數(shù)據(jù)來了,雙方都能直接訪問,減少了數(shù)據(jù)拷貝次數(shù)。這就好比圖書館有個(gè)公共書架,管理員(內(nèi)核)和讀者(用戶程序)都能直接在上面取放書籍(數(shù)據(jù)),無需來回搬運(yùn)。像是視頻流處理應(yīng)用,大量視頻數(shù)據(jù)頻繁傳輸,mmap 使得數(shù)據(jù)能快速從內(nèi)核流向用戶空間,減少傳輸延遲,讓視頻播放流暢無卡頓,極大提升了系統(tǒng)整體性能。
4.3 三個(gè)核心 API
epoll 提供了三個(gè)核心 API,它們是 epoll 實(shí)現(xiàn)高效 I/O 多路復(fù)用的關(guān)鍵工具,就像 “超級(jí)服務(wù)助手” 的三件 “法寶”,各司其職,協(xié)同工作。
(1)epoll_create:用于創(chuàng)建一個(gè) epoll 實(shí)例,并返回一個(gè)文件描述符(epfd),這個(gè)文件描述符將用于后續(xù)對(duì) epoll 實(shí)例的操作 。在創(chuàng)建 epoll 實(shí)例時(shí),內(nèi)核會(huì)為其分配相應(yīng)的數(shù)據(jù)結(jié)構(gòu),包括紅黑樹和就緒鏈表等,并初始化相關(guān)的參數(shù)。例如:
int epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}(2)epoll_ctl:用于控制 epoll 實(shí)例,它可以向 epoll 實(shí)例中添加、刪除或修改文件描述符及其感興趣的事件 。epoll_ctl 函數(shù)有四個(gè)參數(shù):epfd(epoll 實(shí)例的文件描述符)、op(操作類型,如 EPOLL_CTL_ADD 表示添加,EPOLL_CTL_DEL 表示刪除,EPOLL_CTL_MOD 表示修改)、fd(要操作的文件描述符)和 event(指向 epoll_event 結(jié)構(gòu)體的指針,用于指定事件類型和關(guān)聯(lián)的數(shù)據(jù))。例如,當(dāng)我們要將一個(gè)監(jiān)聽套接字添加到 epoll 實(shí)例中,監(jiān)聽讀事件時(shí),可以這樣使用:
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = listen_sock;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &event) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}(3)epoll_wait:用于等待文件描述符上的事件發(fā)生,它會(huì)阻塞調(diào)用線程,直到有事件觸發(fā)或超時(shí) 。epoll_wait 函數(shù)有四個(gè)參數(shù):epfd(epoll 實(shí)例的文件描述符)、events(指向 epoll_event 結(jié)構(gòu)體數(shù)組的指針,用于返回發(fā)生的事件)、maxevents(events 數(shù)組的大小,即一次最多處理的事件數(shù))和 timeout(等待事件發(fā)生的超時(shí)時(shí)間,以毫秒為單位,如果設(shè)置為 - 1,則表示無限等待)。當(dāng)有事件發(fā)生時(shí),epoll_wait 會(huì)將就緒的事件從就緒鏈表中取出,填充到 events 數(shù)組中,并返回就緒事件的數(shù)量。例如:
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int n = 0; n < nfds; ++n) {
if (events[n].events & EPOLLIN) {
handle_incoming_data(events[n].data.fd);
}
}4.4 epoll實(shí)現(xiàn)原理
我們以 linux 內(nèi)核 2.6 為例,說明一下 epoll 是如何高效的處理事件的。當(dāng)某一個(gè)進(jìn)程調(diào)用 epoll_create 方法的時(shí)候,Linux 內(nèi)核會(huì)創(chuàng)建一個(gè) eventpoll 結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體中有兩個(gè)重要的成員。
- 第一個(gè)是 rb_root rbr,這是紅黑樹的根節(jié)點(diǎn),存儲(chǔ)著所有添加到 epoll 中的事件,也就是這個(gè) epoll 監(jiān)控的事件。
- 第二個(gè)是 list_head rdllist 這是一個(gè)雙向鏈表,保存著將要通過 epoll_wait 返回給用戶的、滿足條件的事件。
每一個(gè) epoll 對(duì)象都有一個(gè)獨(dú)立的 eventpoll 結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體會(huì)在內(nèi)核空間中創(chuàng)造獨(dú)立的內(nèi)存,用于存儲(chǔ)使用 epoll_ctl 方法向 epoll 對(duì)象中添加進(jìn)來的事件。這些事件都會(huì)掛到 rbr 紅黑樹中,這樣就能夠高效的識(shí)別重復(fù)添加的節(jié)點(diǎn)。
所有添加到 epoll 中的事件都會(huì)與設(shè)備(如網(wǎng)卡等)驅(qū)動(dòng)程序建立回調(diào)關(guān)系,也就是說,相應(yīng)的事件發(fā)生時(shí)會(huì)調(diào)用這里的方法。這個(gè)回調(diào)方法在內(nèi)核中叫做 ep_poll_callback,它把這樣的事件放到 rdllist 雙向鏈表中。在 epoll 中,對(duì)于每一個(gè)事件都會(huì)建立一個(gè) epitem 結(jié)構(gòu)體。
當(dāng)調(diào)用 epoll_wait 檢查是否有發(fā)生事件的連接時(shí),只需要檢查 eventpoll 對(duì)象中的 rdllist 雙向鏈表中是否有 epitem 元素,如果 rdllist 鏈表不為空,則把這里的事件復(fù)制到用戶態(tài)內(nèi)存中的同時(shí),將事件數(shù)量返回給用戶。通過這種方法,epoll_wait 的效率非常高。epoll-ctl 在向 epoll 對(duì)象中添加、修改、刪除事件時(shí),從 rbr 紅黑樹中查找事件也非??臁_@樣,epoll 就能夠輕易地處理百萬級(jí)的并發(fā)連接。
五、epoll 高效的原因
epoll 在高并發(fā)場(chǎng)景下的高效表現(xiàn),得益于其多方面的優(yōu)化設(shè)計(jì),就像一位擁有多項(xiàng)絕技的 “超級(jí)英雄”,在應(yīng)對(duì)高并發(fā)挑戰(zhàn)時(shí)游刃有余。
5.1 事件驅(qū)動(dòng)機(jī)制
epoll 摒棄了 select 和 poll 的傳統(tǒng)輪詢方式,采用了高效的事件驅(qū)動(dòng)機(jī)制 。在傳統(tǒng)的輪詢方式中,就好比在一個(gè)巨大的倉庫里,不管貨物有沒有變化,都要逐個(gè)去查看,在連接數(shù)量眾多時(shí),大量的時(shí)間和資源就浪費(fèi)在了這些無效的檢查上。而 epoll 采用事件驅(qū)動(dòng)機(jī)制,當(dāng)文件描述符狀態(tài)發(fā)生變化,比如有數(shù)據(jù)可讀或可寫時(shí),內(nèi)核會(huì)主動(dòng)發(fā)出通知,應(yīng)用程序只需關(guān)注這些有事件發(fā)生的文件描述符即可,這大大減少了無效操作。例如,在一個(gè)擁有大量并發(fā)連接的服務(wù)器中,epoll 能夠精準(zhǔn)地定位到那些有數(shù)據(jù)傳輸?shù)幕钴S連接,避免對(duì)眾多無事件連接的遍歷,從而顯著提高了系統(tǒng)的效率。
5.2 數(shù)據(jù)結(jié)構(gòu)優(yōu)勢(shì)
epoll 的數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)堪稱精妙絕倫,為其高效運(yùn)行提供了有力支持 。紅黑樹用于管理注冊(cè)的文件描述符,其插入、刪除和查找操作的時(shí)間復(fù)雜度僅為 O (log N),遠(yuǎn)優(yōu)于傳統(tǒng)線性結(jié)構(gòu),這使得 epoll 在處理大量文件描述符時(shí)能夠快速定位和操作,不會(huì)因?yàn)槲募枋龇麛?shù)量的增加而導(dǎo)致性能大幅下降。
當(dāng)文件描述符就緒時(shí),內(nèi)核將其從紅黑樹移至就緒鏈表,epoll_wait 只需遍歷該鏈表,就能獲取就緒事件,提升了事件獲取效率 。相比之下,select 和 poll 在獲取就緒事件時(shí)需要遍歷整個(gè)文件描述符集合,時(shí)間復(fù)雜度為 O (n),當(dāng)文件描述符數(shù)量較多時(shí),效率會(huì)顯著降低。例如,在一個(gè)處理數(shù)萬并發(fā)連接的服務(wù)器中,epoll 通過就緒鏈表能夠快速獲取就緒事件,而 select 和 poll 則需要花費(fèi)大量時(shí)間遍歷所有連接,導(dǎo)致系統(tǒng)響應(yīng)速度變慢。
5.3 數(shù)據(jù)傳輸優(yōu)化
epoll 借助 mmap 技術(shù)在內(nèi)核與用戶空間建立共享內(nèi)存,減少了數(shù)據(jù)在內(nèi)核緩沖區(qū)與用戶空間應(yīng)用程序緩沖區(qū)之間的拷貝次數(shù),提高了傳輸效率 。在傳統(tǒng)的數(shù)據(jù)傳輸過程中,數(shù)據(jù)從內(nèi)核緩沖區(qū)到用戶空間應(yīng)用程序緩沖區(qū),往往需要多次拷貝,這無疑增加了時(shí)間和資源開銷。而 epoll 通過共享內(nèi)存,讓數(shù)據(jù)傳輸更直接高效,減少了拷貝次數(shù),加快了數(shù)據(jù)傳輸速度,就像開辟了一條數(shù)據(jù)傳輸?shù)?“高速公路”。
epoll 支持水平觸發(fā)(LT)和邊緣觸發(fā)(ET)兩種模式 。水平觸發(fā)是默認(rèn)的工作方式,只要文件描述符上有指定的事件(如數(shù)據(jù)可讀),每次調(diào)用 epoll_wait 都會(huì)返回此事件,除非事件被處理(如數(shù)據(jù)被讀走)。邊緣觸發(fā)則更為高效,僅在文件描述符狀態(tài)變化瞬間通知一次應(yīng)用程序,促使應(yīng)用程序一次性處理完相關(guān)數(shù)據(jù),減少了 epoll_wait 的調(diào)用次數(shù),進(jìn)一步提升了效率 。例如,在一些對(duì)實(shí)時(shí)性要求較高的場(chǎng)景中,如網(wǎng)絡(luò)監(jiān)控系統(tǒng),使用邊緣觸發(fā)模式可以減少不必要的系統(tǒng)調(diào)用,提高系統(tǒng)的響應(yīng)速度。
六、I/O多路復(fù)用高頻面試題
問題 1:什么是 I/O 多路復(fù)用?
I/O 多路復(fù)用是一種同步 I/O 模型,允許一個(gè)線程監(jiān)視多個(gè)文件句柄,一旦某個(gè)文件句柄就緒(通常是可讀、可寫或出現(xiàn)異常等情況),就能通知應(yīng)用程序進(jìn)行相應(yīng)的讀寫操作。沒有文件句柄就緒時(shí)會(huì)阻塞應(yīng)用程序,交出 CPU。其可讓服務(wù)器用單線程支持更多并發(fā)連接請(qǐng)求。
問題 2:為什么需要 I/O 多路復(fù)用機(jī)制?
無此機(jī)制時(shí),處理并發(fā) I/O 存在問題。例如,同步阻塞 I/O(BIO)用單線程無法處理并發(fā),多線程方式在請(qǐng)求增多時(shí),大量線程占用內(nèi)存且切換開銷大。同步非阻塞 I/O(NIO)需輪詢所有文件描述符,即便無讀寫事件也輪詢,浪費(fèi) CPU 資源。I/O 多路復(fù)用通過 select、epoll 等系統(tǒng)調(diào)用獲取有事件的文件描述符列表處理請(qǐng)求,減少資源消耗,提升并發(fā)處理能力。
問題 3:I/O 多路復(fù)用的常見實(shí)現(xiàn)方式有哪些?
主要有 select、poll、epoll 和 kqueue 等。其中 select 幾乎各平臺(tái)都支持,但文件描述符數(shù)量常限制為 1024。poll 類似 select 但無文件描述符數(shù)量限制。epoll 是 Linux 特有,高效且無文件描述符限制,支持水平與邊緣觸發(fā)。kqueue 用于 FreeBSD、Mac OS X 等,能處理多種事件,性能出色。
問題 4:select 有哪些缺點(diǎn)?
- 單個(gè)進(jìn)程可監(jiān)視的文件描述符數(shù)量受限,由 FD_SETSIZE 設(shè)置,默認(rèn) 1024。
- 每次調(diào)用都需把文件描述符集合從用戶態(tài)拷貝至內(nèi)核態(tài),文件描述符多時(shí)開銷大。
- 掃描 socket 是線性輪詢,高并發(fā)場(chǎng)景下效率低。
- 僅支持水平觸發(fā)模式。
問題 5:poll 和 select 的區(qū)別是什么?
poll 功能與 select 基本類似,主要區(qū)別是 poll 通過基于鏈表存儲(chǔ)數(shù)據(jù),沒有文件描述符數(shù)量的限制。不過,它每次調(diào)用時(shí),仍需把文件描述符集合從用戶態(tài)拷貝到內(nèi)核態(tài),且掃描時(shí)也是線性掃描,高并發(fā)時(shí)效率低。
問題 6:epoll 為什么效率高?
- 事件通知機(jī)制:采用事件驅(qū)動(dòng),通過 epoll_ctl 注冊(cè)文件描述符,就緒時(shí)內(nèi)核用類似回調(diào)的機(jī)制激活,epoll_wait 接收通知,不必像 select/poll 輪詢,時(shí)間復(fù)雜度達(dá) O (1)。
- 減少內(nèi)存拷貝:借助 mmap () 文件映射內(nèi)存加速與內(nèi)核空間消息傳遞,減少數(shù)據(jù)拷貝開銷。
- 無顯著連接限制:可打開的文件描述符上限較高,1G 內(nèi)存機(jī)器可監(jiān)聽約 10 萬個(gè)端口。
問題 7:epoll 的 LT 和 ET 模式區(qū)別是什么?
LT(水平觸發(fā))是默認(rèn)模式,只要文件描述符有數(shù)據(jù)可讀或可寫,每次 epoll_wait 都會(huì)返回其事件提醒操作。ET(邊緣觸發(fā))是高速模式,僅在文件描述符狀態(tài)從不可讀 / 寫變?yōu)榭勺x / 寫時(shí)通知一次。ET 效率高,但需應(yīng)用程序一次處理完數(shù)據(jù),常需讀到 EAGAIN 錯(cuò)誤確保讀完緩沖區(qū)數(shù)據(jù)。
問題 8:I/O 多路復(fù)用適用于什么場(chǎng)景?
適用于需處理大量并發(fā)連接,且每個(gè)連接讀寫操作不頻繁的場(chǎng)景,像 Redis、Nginx 等常處理大量并發(fā)客戶端請(qǐng)求的服務(wù)器程序。它們通過 I/O 多路復(fù)用機(jī)制,用少量線程高效管理眾多客戶端連接。
問題 9:epoll 存在缺點(diǎn)嗎?
epoll 僅能在 Linux 操作系統(tǒng)上使用,限制了基于其開發(fā)的程序的跨平臺(tái)能力。若程序需跨平臺(tái),用 select 或考慮封裝支持多平臺(tái)的 I/O 多路復(fù)用抽象層更合適。
問題 10:select、poll、epoll 是同步 I/O 還是異步 I/O?
它們均屬同步 I/O。因即便 select、poll、epoll 可幫獲取就緒的文件描述符,但實(shí)際讀寫數(shù)據(jù)過程仍需應(yīng)用程序自行負(fù)責(zé),讀寫操作會(huì)阻塞線程,而異步 I/O 是內(nèi)核完成數(shù)據(jù)從內(nèi)核空間到用戶空間的拷貝等工作后通知應(yīng)用程序。




























