1.什麼是select、poll和epoll
參考:平平無奇小菜鳥 的 《搞懂select、poll、epoll》https://blog.csdn.net/m0_54356563/article/details/121029894
這篇文章講的比較清楚,直接參考即可。<br> 演進歷程詳解 (1)select的開創與侷限 select是 IO 多路複用技術的開創者。它的工作方式是,將程序關心的所有文件描述符(如網絡連接)以一個位圖集合的形式傳遞給內核,然後由內核掃描這些描述符,通知程序哪些已經就緒(例如,有數據可讀)。但這種方式存在幾個明顯的瓶頸: 數量限制:其監控的文件描述符集合(fd_set)有大小限制,通常默認為1024,這限制了程序能處理的最高併發連接數 。 性能開銷:每次調用 select都需要將整個描述符集合從用户空間拷貝到內核空間。同時,內核和程序在調用返回後都需要線性掃描整個集合以查找就緒的描述符,當管理大量連接但其中只有少數活躍時,這種O(n)複雜度的掃描會造成顯著的性能浪費 。 (2)poll的改進與未解之題 poll的出現主要是為了解決 select的文件描述符數量限制問題。它用動態數組替代了固定大小的位圖,使得能夠監控的描述符數量理論上只受系統資源限制 。然而,poll在核心工作機制上並未超越 select。它仍然需要每次調用時傳遞整個描述符列表,內核和應用程序也仍然需要遍歷整個列表,因此 poll未能解決 select固有的性能瓶頸問題 。可以將其視為對 select的一次“量變”改進。 (3)epoll的質變與工作原理 epoll是針對 select和 poll缺陷的一次根本性重構,它的設計哲學是“一次註冊,多次等待”。其高性能主要源於三大改進: 內核事件表:通過 epoll_create和 epoll_ctl系統調用,程序可以提前在內核中建立一個需要監控的描述符列表。這個列表只需在初始化時建立或修改,在後續的事件等待循環中無需再傳遞,解決了數據在用户態和內核態之間來回拷貝的開銷 。 就緒列表與事件回調:epoll內部維護了一個就緒列表。當被監控的描述符上有事件發生時(如數據到達),內核會通過回調機制直接將這個描述符加入到就緒列表中。當程序調用 epoll_wait時,它無需掃描所有監控的描述符,而只需從就緒列表中獲取實際發生事件的描述符即可。這使得 epoll的效率不會隨着監控文件描述符數量的增加而線性下降,在實際應用中近乎 O(1),特別適合連接數多但活躍連接少的場景 。 觸發模式:epoll還提供了更靈活的邊緣觸發(ET) 模式,區別於 select/poll僅有的水平觸發(LT) 模式。在ET模式下,一個事件只會被通知一次,要求應用程序必須一次性將數據讀取或寫入完畢,這有助於減少系統調用次數,在某些場景下能進一步提升性能 。 總結:簡單來説,從 select到 poll再到 epoll的演進,是 Linux 為應對高併發網絡編程需求,在 I/O 多路複用機制上不斷優化的過程。
2.其它幾個問題
2.1 select、poll和epoll是解決什麼問題的
I/O多路複用技術是高性能網絡編程的核心,它允許單個進程能夠同時監視多個文件描述符(如網絡套接字),以檢測哪一個或多個已經就緒(例如可讀、可寫或發生異常),從而避免為每個連接創建獨立線程或進程帶來的巨大開銷。 select、poll和 epoll就是解決這一問題的關鍵系統調用。要搞清楚select、poll和 epoll在其中起到的作用,就要退回到在這之前,遇到I/O多路複用技術,代碼是怎麼寫的。<br> 傳統模型如何工作 在沒有 select/poll/epoll的時代,要實現一個能同時服務多個客户端的服務器,最直觀的思路就是您提到的“每個連接創建獨立線程或進程”。其工作流程如下: 主流程準備:服務器啓動,創建監聽套接字(socket),綁定地址(bind),並開始監聽(listen)。 等待連接:主線程/進程調用 accept()函數。這個調用會阻塞,直到有新的客户端連接到來。 創建服務者:一旦有客户端連接(connect),accept()返回一個新的套接字用於和這個客户端通信。此時,服務器會立即調用 fork()創建一個新的子進程,或者調用 pthread_create()創建一個新的線程。 分工合作:
- 新創建的子進程/線程(稱為“工作進程/線程”)負責接管這個新的客户端連接,與之進行所有的數據收發(read/write)操作。
- 原來的主進程/線程(稱為“監聽進程/線程”)在創建完工作單元后,立刻折返,回到 accept()調用處,繼續阻塞以等待下一個新客户端的連接。
這樣,每個客户端都“獨佔”一個服務它的進程或線程。當這個客户端在安靜地思考或輸入時,服務它的線程就會阻塞在 read()調用上,等待數據到來,但這不會影響服務其他客户端的線程。<br> 傳統方式“巨大開銷”體現在哪裏 這種模型雖然邏輯清晰,但其開銷在連接數大增時會變得無法承受,主要體現在: 內存資源消耗:每個線程都有自己的棧空間(通常為幾MB到10MB),每個進程的資源佔用則更多。創建1000個線程,僅棧內存就可能消耗數GB,這極大地限制了服務器能支撐的連接數量。 CPU上下文切換開銷:操作系統需要在成千上萬個線程/進程之間進行調度。即使大部分線程都在休眠(阻塞等待I/O),內核也需要不斷地保存和恢復它們的執行上下文(如寄存器狀態、內存映射等)。當連接數達到萬級時,CPU資源將大量浪費在這種無效的切換上,而不是處理實際業務邏輯,導致系統吞吐量急劇下降。這就是著名的 C10K問題(即單機同時處理1萬個連接的問題)的根源。
2.2 select、poll和epoll是不是隻能用於網絡IO
I/O多路複用技術最主要、最經典的應用場景就是同時監視大量的網絡套接字(socket),這也是它為何是高併發網絡服務器(如Nginx、Redis)核心技術的直接原因。 然而,它的能力並不僅限於網絡套接字。任何在Unix/Linux系統中可以表示為“文件描述符(File Descriptor)”的I/O資源,都可以成為I/O多路複用的監控對象。
| 應用場景 | 描述 | 文件描述符類型舉例 |
|---|---|---|
| 終端或控制枱輸入 | 監控用户從標準輸入(如鍵盤)的交互指令,同時處理其他I/O任務。 | STDIN_FILENO(標準輸入,文件描述符通常是0) |
| 管道和FIFO | 用於進程間通信。父進程可以監控多個子進程通過管道發來的數據。 | 匿名管道(pipe)或命名管道(FIFO) |
| 偽終端設備 | 處理如ssh、telnet等網絡登錄會話或xterm等終端模擬器的輸入輸出。 | 偽終端(pty)的主從設備 |
| 磁盤文件(有侷限) | 理論上可以監控常規文件,但由於文件總是處於“就緒”狀態,實用性不高,通常需配合非阻塞I/O。 | 常規文件(regular file)的描述符 |
| 信號通信 | 通過一種特殊的文件描述符(如signalfd)來接收信號,從而將信號事件像普通I/O事件一樣處理。 | 信號描述符(signalfd) |
| 定時器管理 | 通過特殊的文件描述符(如timerfd)來接收定時器到期通知,將其統一到I/O多路複用循環中。 | 定時器描述符(timerfd) |
2.3 磁盤IO也是IO,為什麼編程的時候讀寫磁盤文件不用select、poll和epoll
簡單來説,造成這種差異的根本原因在於數據就緒的確定性。文件I/O的數據通常是“立即可用”的,而鍵盤和網絡I/O的數據是“未知且需要等待的”。 程序無法預知用户何時按鍵,也無法預知網絡對端何時發送數據、發送多少數據。如果直接對一個代表鍵盤或網絡套接字的文件描述符調用 read,內核會因為無數據可讀而無限期地阻塞當前進程/線程,導致程序失去響應。 多路複用的價值:select, poll, epoll這些I/O多路複用技術正是為了解決這個問題而生。它們允許一個進程同時監視多個文件描述符(如多個網絡連接)。其核心作用是: 集中等待:告訴內核:“我關心這幾個描述符的讀/寫事件,你幫我盯着點,只要其中任何一個就緒了,就通知我。” 高效通知:內核會阻塞在 epoll_wait這類調用上,直到一個或多個被監視的描述符真正就緒(例如,網絡數據包已到達網卡並拷貝至內核緩衝區)。然後,它精確地返回哪些描述符就緒,避免了無效的輪詢。 針對性處理:應用程序收到通知後,再針對就緒的描述符調用 read或 write,此時由於數據已準備就緒,這些操作會立刻完成,不會阻塞。 這使得單個線程可以高效地管理成百上千的網絡連接或設備輸入,這是構建高性能、高併發服務器的基石。Nginx這類服務器就採用此模型。
2.4 對於select函數,已經提供了readfds參數指明需要等待哪些fd了,為什麼還需要第一個參數max_fd+1?select這麼設計的出發點是什麼?
簡單來説,第一個參數 max_fd+1的本質是告訴內核需要檢查的文件描述符的範圍上限,是一種為了提升性能而設定的“掃描邊界”。select內部使用 fd_set位圖(一個固定長度的二進制數組,如1024位)來標記文件描述符。內核需要知道這個位圖中哪些位是有效的,max_fd+1就標明瞭有效位的右邊界。 用户態程序通常已經維護着所有活動文件描述符的列表或知道最大描述符的值。讓內核再去做一次全位圖掃描來確定範圍是一種浪費。傳入 nfds避免了內核的重複勞動。
3.示例代碼
結合網絡IO的服務器端,來看看Linux環境下select、poll和epoll三個系統函數是怎麼用的,以加深印象。
3.1 select例子
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <errno.h>
#define MAX_CLIENTS 10
#define BUFFER_SIZE 256
#define PORT 8888
int main() {
int server_sock, client_sock[MAX_CLIENTS], max_sd, activity, i, valread, sd;
struct sockaddr_in address;
char buffer[BUFFER_SIZE];
fd_set readfds;
int addrlen = sizeof(address);
int opt = 1;
// 初始化客户端套接字數組
for (i = 0; i < MAX_CLIENTS; i++) {
client_sock[i] = 0;
}
// 創建服務器套接字
if ((server_sock = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 設置套接字選項,允許地址和端口重用
if (setsockopt(server_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 綁定套接字到端口
if (bind(server_sock, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 開始監聽,設置最大掛起連接數為5
if (listen(server_sock, 5) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
while (1) {
// 清空讀文件描述符集合
FD_ZERO(&readfds);
// 將服務器套接字加入集合
FD_SET(server_sock, &readfds);
max_sd = server_sock;
// 將所有活躍的客户端套接字加入集合,並找出最大的文件描述符
for (i = 0; i < MAX_CLIENTS; i++) {
sd = client_sock[i];
if (sd > 0) {
FD_SET(sd, &readfds);
}
if (sd > max_sd) {
max_sd = sd;
}
}
// 使用select等待活動發生,無限期阻塞
activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
perror("select error");
}
// 檢查是否有新的連接到來
if (FD_ISSET(server_sock, &readfds)) {
int new_socket;
if ((new_socket = accept(server_sock, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("New connection, socket fd is %d, IP is : %s, port : %d\n",
new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 將新套接字添加到客户端數組中的空位
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_sock[i] == 0) {
client_sock[i] = new_socket;
printf("Adding to list of sockets as %d\n", i);
break;
}
}
if (i == MAX_CLIENTS) {
printf("Maximum clients reached. Connection rejected.\n");
close(new_socket);
}
}
// 檢查是哪個客户端套接字有IO活動
for (i = 0; i < MAX_CLIENTS; i++) {
sd = client_sock[i];
if (FD_ISSET(sd, &readfds)) {
// 讀取客户端發送的數據
if ((valread = read(sd, buffer, BUFFER_SIZE)) == 0) {
// 客户端斷開連接
getpeername(sd, (struct sockaddr*)&address, (socklen_t*)&addrlen);
printf("Host disconnected, IP %s, port %d\n",
inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(sd);
client_sock[i] = 0;
} else {
// 回顯收到的數據
buffer[valread] = '\0';
printf("Received from client %d: %s\n", i, buffer);
send(sd, buffer, strlen(buffer), 0);
}
}
}
}
return 0;
}
注意:上面使用了read()而沒有使用recv()來讀取網絡IO內容,這兩者區別有哪些?
- 簡單場景下可互換:在你的示例代碼這樣的簡單TCP服務器中,目標僅僅是讀取客户端發送的普通數據,並且使用默認的阻塞模式,那麼將 read替換為 flags參數設置為 0的 recv,其行為是基本一致的。這也是為什麼示例代碼可以使用 read的原因。
- 優先考慮 recv:由於 recv是專門為 socket 設計的,並且提供了更豐富的控制選項,在 socket 編程中更專業、更具表達力。只要你的程序明確只處理 socket,使用 recv通常是更規範的做法。
- 需要特殊控制時必須用 recv:當你需要實現如下功能時,必須使用 recv並設置相應的標誌位。
3.2 poll例子
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#define MAX_CLIENTS 10
#define BUFFER_SIZE 256
#define PORT 8888
int main() {
int server_sock, client_sock, i;
struct sockaddr_in address;
char buffer[BUFFER_SIZE];
struct pollfd fds[MAX_CLIENTS + 1]; // +1 用於服務器監聽套接字
int nfds = 1; // 當前被監控的文件描述符數量,初始為1(只有服務器套接字)
int addrlen = sizeof(address);
int timeout = 5000; // 超時時間設置為5秒(5000毫秒),-1表示無限阻塞
// 初始化 pollfd 結構數組
for (i = 0; i < (MAX_CLIENTS + 1); i++) {
fds[i].fd = -1; // 初始化為無效描述符
fds[i].events = 0;
fds[i].revents = 0;
}
// 創建服務器套接字
if ((server_sock = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 設置套接字選項,允許地址和端口重用
int opt = 1;
if (setsockopt(server_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
close(server_sock);
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 綁定套接字到端口
if (bind(server_sock, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_sock);
exit(EXIT_FAILURE);
}
// 開始監聽
if (listen(server_sock, 5) < 0) {
perror("listen");
close(server_sock);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 將服務器監聽套接字加入到 pollfd 數組的第一個位置[4,6](@ref)
fds[0].fd = server_sock;
fds[0].events = POLLIN; // 我們關心它是否有新的連接(即可讀事件)
fds[0].revents = 0;
while (1) {
// 調用 poll,阻塞等待事件發生。nfds 告訴內核需要檢查數組中的前 nfds 個元素[2,6](@ref)
int poll_count = poll(fds, nfds, timeout);
if (poll_count < 0) {
perror("poll error");
break;
} else if (poll_count == 0) {
printf("Poll timeout, no activity after 5 seconds.\n");
continue;
}
// 檢查所有被監控的文件描述符(從0到nfds-1)
int current_size = nfds; // 先記錄當前數量,因為循環內nfds可能變化
for (i = 0; i < current_size; i++) {
if (fds[i].revents == 0) { // 該描述符上沒有事件發生
continue;
}
// 如果有事件發生,但不是POLLIN(比如錯誤),則處理錯誤並關閉連接[7](@ref)
if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
printf("Error event on fd %d, closing connection.\n", fds[i].fd);
close(fds[i].fd);
fds[i].fd = -1;
// 這裏可以優化數組,將後續有效的fd前移
continue;
}
// 處理服務器監聽套接字上的新連接事件[4](@ref)
if (fds[i].fd == server_sock && (fds[i].revents & POLLIN)) {
if ((client_sock = accept(server_sock, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
continue;
}
printf("New connection, socket fd is %d, IP: %s, port: %d\n",
client_sock, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 找到 pollfd 數組中的空位添加新的客户端套接字[4,6](@ref)
if (nfds < MAX_CLIENTS + 1) {
fds[nfds].fd = client_sock;
fds[nfds].events = POLLIN; // 監聽客户端的數據可讀事件
fds[nfds].revents = 0;
nfds++;
printf("Adding new client fd %d at index %d. Total monitored fds: %d\n", client_sock, nfds-1, nfds);
} else {
printf("Maximum clients reached. Rejecting connection.\n");
close(client_sock);
}
}
// 處理客户端套接字上的數據可讀事件[4](@ref)
else if (fds[i].revents & POLLIN) {
// 客户端發送了數據或關閉了連接
int valread = read(fds[i].fd, buffer, BUFFER_SIZE - 1);
if (valread <= 0) {
// 客户端斷開連接或出錯
getpeername(fds[i].fd, (struct sockaddr*)&address, (socklen_t*)&addrlen);
printf("Host disconnected, IP %s, port %d. Closing fd %d.\n",
inet_ntoa(address.sin_addr), ntohs(address.sin_port), fds[i].fd);
close(fds[i].fd);
fds[i].fd = -1; // 標記該位置為空
// 一個簡單的優化:如果被關閉的是數組最後一個有效fd,則直接減少nfds
// 更復雜的做法可以將數組後面的有效fd前移,以保持數組緊湊[4](@ref)
if (i == nfds - 1) {
nfds--;
}
} else {
// 成功讀到數據,回顯給客户端
buffer[valread] = '\0';
printf("Received from client (fd=%d): %s", fds[i].fd, buffer);
send(fds[i].fd, buffer, valread, 0);
}
}
}
}
// 關閉所有連接(通常不會執行到這裏)
for (i = 0; i < nfds; i++) {
if (fds[i].fd >= 0) {
close(fds[i].fd);
}
}
return 0;
}
3.3 epoll例子
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <errno.h>
#define MAX_EVENTS 10
#define BUFFER_SIZE 256
#define SERV_PORT 8888
int main() {
int listen_fd, conn_fd, epoll_fd, nfds, i;
struct sockaddr_in serv_addr, cli_addr;
socklen_t cli_len = sizeof(cli_addr);
char buffer[BUFFER_SIZE];
struct epoll_event ev, events[MAX_EVENTS];
// 1. 創建監聽套接字
if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 設置 SO_REUSEADDR 選項,避免重啓時地址佔用問題
int opt = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
perror("setsockopt");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 2. 綁定服務器地址
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 監聽所有本地IP
serv_addr.sin_port = htons(SERV_PORT);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 3. 開始監聽
if (listen(listen_fd, 5) == -1) {
perror("listen");
close(listen_fd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", SERV_PORT);
// 4. 創建 epoll 實例
if ((epoll_fd = epoll_create1(0)) == -1) {
perror("epoll_create1");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 5. 將監聽套接字添加到 epoll 實例,監聽可讀事件(新連接)
ev.events = EPOLLIN; // 監聽可讀事件,默認為水平觸發(LT)模式
ev.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl: listen_fd");
close(listen_fd);
close(epoll_fd);
exit(EXIT_FAILURE);
}
// 6. 主事件循環
while (1) {
// 阻塞等待事件發生,超時時間設為 -1 表示永久阻塞
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds == -1) {
if (errno == EINTR) {
continue; // 被信號中斷,繼續等待
}
perror("epoll_wait");
break;
}
// 7. 處理所有就緒的事件
for (i = 0; i < nfds; i++) {
int current_fd = events[i].data.fd;
// 處理錯誤事件
if (events[i].events & (EPOLLERR | EPOLLHUP)) {
fprintf(stderr, "epoll error on fd %d\n", current_fd);
close(current_fd);
continue;
}
// 7.1 監聽套接字就緒,表示有新連接到來
if (current_fd == listen_fd) {
conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
if (conn_fd == -1) {
perror("accept");
continue;
}
// 打印新客户端信息
char cli_ip[INET_ADDRSTRLEN];
printf("New connection from %s:%d, assigned fd: %d\n",
inet_ntop(AF_INET, &cli_addr.sin_addr, cli_ip, INET_ADDRSTRLEN),
ntohs(cli_addr.sin_port), conn_fd);
// 將新的連接套接字添加到 epoll 實例
ev.events = EPOLLIN; // 監聽這個連接上的數據可讀
ev.data.fd = conn_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
perror("epoll_ctl: conn_fd");
close(conn_fd);
}
}
// 7.2 已連接的客户端套接字就緒,表示有數據可讀
else if (events[i].events & EPOLLIN) {
ssize_t n = read(current_fd, buffer, BUFFER_SIZE - 1);
if (n == -1) {
perror("read");
close(current_fd);
} else if (n == 0) {
// 客户端關閉了連接
printf("Client on fd %d disconnected.\n", current_fd);
close(current_fd);
// 注意:epoll 會自動移除已關閉的 fd,無需顯式調用 EPOLL_CTL_DEL
} else {
// 成功讀到數據,實現回顯功能
buffer[n] = '\0';
printf("Received from fd %d: %s", current_fd, buffer);
// 將數據發回給客户端
if (write(current_fd, buffer, n) == -1) {
perror("write");
close(current_fd);
}
}
}
}
}
// 8. 清理資源(通常不會執行到這裏)
close(listen_fd);
close(epoll_fd);
return 0;
}