目錄
I/O多路復用的介紹
多進程/多線程模型的弊端
網絡多路復用如何解決問題?
網絡多路復用的常見實現方式
常見的開源網絡庫
select詳細介紹
select函數介紹
套接字可讀事件,可寫事件,異常事件
fd_set類型介紹
select的兩次拷貝,兩次遍歷
select使用示例介紹
select服務器示例代碼
poll函數詳細介紹
poll函數介紹
pollfd類型介紹
poll的工作原理
poll的優缺點
I/O多路復用的介紹
多進程/多線程模型的弊端
在上一篇文章中我們詳細介紹了Linux中的網絡編程,使用相關API實現了多進程/多線程模型,即:
Linux網絡編程-CSDN博客
之前的客戶端—服務器端連接處理思路:每當有一個新的客戶端連接,服務器就創建一個新的進程或線程來處理它。我們之前的示例中在新創建的進程中還會使用fork來進行進一步創建一個進程,用來實現讀寫分離。
這樣就相當于每一個客戶端連接服務器,就需要多創建兩個進程來實現客戶端與服務器端的通信。
弊端:
-
資源消耗大:每個進程或線程都需要獨立的內存空間(棧、堆等),并維護自己的上下文信息。大量的進程/線程會迅速耗盡系統內存。
-
上下文切換開銷:操作系統在這么多進程/線程之間切換 CPU 時,會產生大量的上下文切換開銷,這會嚴重降低 CPU 的有效工作時間。
-
文件描述符限制:每個進程/線程都會占用一個文件描述符。系統對單個進程或整個系統的文件描述符數量有上限,容易達到瓶頸。
網絡多路復用如何解決問題?
網絡多路復用允許單個進程/線程同時監控多個文件描述符(包括套接字)。當任何一個文件描述符準備好進行 I/O 操作(例如,有數據可讀或可以寫入數據)時,多路復用機制會通知應用程序。
這樣,你的服務器就不需要為每個客戶端都創建一個獨立的進程或線程了。一個工作進程/線程就能高效地管理數百甚至上萬個并發連接。
網絡多路復用的常見實現方式
網絡多路復用的常見實現方式主要有三種:select、poll 和 epoll (Linux 特有)。它們都允許一個進程或線程同時監控多個文件描述符(包括網絡套接字),但具體機制和性能特點有所不同。
常見的開源網絡庫
在實際開發中,我們經常會選擇使用成熟的開源網絡庫或框架來構建高性能的并發服務器,而并不是會選擇使用select,poll,epoll這些來進行構建。
在 C++ 中,有很多優秀的開源網絡庫可以幫助你高效地開發網絡應用程序。這些庫封裝了底層操作系統的網絡 API(如 Linux 上的 epoll
,macOS 上的 kqueue
,Windows 上的 IOCP),提供了更高級、更易用的接口,并且通常具備高性能、跨平臺和豐富的功能。
下面介紹幾個 C++ 中常用的開源網絡庫:
Boost.Asio
Boost.Asio 是一個功能強大、設計精良的 C++ 異步 I/O 庫,是現代 C++ 網絡編程的首選。
它提供了一套統一的接口來處理各種異步 I/O 操作(包括網絡套接字、定時器、串口等)。其核心是
io_context
(或io_service
),一個事件循環,用于分發 I/O 事件。主要特點:
C++ 風格:與 C++ 標準庫和現代 C++ 特性(如模板、協程)高度融合,代碼更符合 C++ 習慣。
功能全面:不僅處理網絡通信,還支持定時器、信號等多種 I/O。
跨平臺:底層自動適配不同操作系統的高性能 I/O 多路復用機制(如 Linux 的
epoll
)。靈活性高:支持同步和異步編程模型,以及多種并發模式。
?libevent 和?libev
libevent 和 libev 是輕量級、事件驅動的 C 語言網絡庫,專注于高性能的事件通知。 (libev 是 libevent 的一個更小、更快的替代品,設計理念類似)。
它們的核心是事件循環 (event loop),通過注冊回調函數來處理文件描述符上的 I/O 事件、定時器事件和信號事件。
事件驅動:基于事件循環,當 I/O 事件發生時,通過回調函數通知應用程序,避免了阻塞。
輕量和高效:庫本身的代碼量較小,運行效率高,資源占用低。
跨平臺:支持
epoll
、kqueue
、IOCP
、poll
、select
等多種 I/O 復用機制。多種事件支持:不僅支持網絡 I/O 事件,還支持定時器、信號、文件 I/O 等事件。
適用場景: 適用于需要極致性能、資源受限或嵌入式環境下的網絡應用開發,如高性能代理服務器、聊天服務器、游戲服務器等。它們是構建自己的高性能網絡框架的理想基石。
使用開源網絡庫的好處?
- 簡化開發:提供了抽象層,你不需要直接操作 epoll_create、epoll_ctl、epoll_wait 等底層函數。
- 提高效率:這些庫通常由經驗豐富的開發者優化過,性能經過嚴格測試,并解決了許多難以發現的 bug 和邊界條件。
- 跨平臺支持:許多流行的庫支持跨平臺,底層會自動根據操作系統選擇合適的 I/O 多路復用機制(epoll、kqueue、IOCP 等)。
- 豐富的功能:除了基本的 I/O 封裝,它們往往還集成了定時器、線程池、內存管理、日志、協議編解碼等常用功能。
select詳細介紹
select函數介紹
select通過輪詢的方式檢查一組文件描述符的狀態,判斷它們是否準備好進行 I/O 操作。
函數原型
#include <sys/select.h>
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds,fd_set *restrict exceptfds, struct timeval *restrict timeout
);
參數介紹
nfds:所有文件描述符集合中最大的文件描述符值加 1。select 內部會從 0 到 nfds-1 遍歷這些文件描述符。
readfds:指向一個 fd_set 結構體的指針,用于監聽可讀事件的文件描述符集合。如果不需要監聽可讀事件,可以設置為 NULL。
writefds:指向一個 fd_set 結構體的指針,用于監聽可寫事件的文件描述符集合。如果不需要監聽可寫事件,可以設置為 NULL。
exceptfds:指向一個 fd_set 結構體的指針,用于監聽異常事件的文件描述符集合。如果不需要監聽異常事件,可以設置為 NULL。
timeout:指向一個 struct timeval 結構體的指針,用于設置 select 的超時時間。
- 如果為 NULL,select 將一直阻塞直到有文件描述符就緒。
- 如果指向一個 struct timeval 結構體,且其成員 tv_sec 和 tv_usec 都為 0,select 將立即返回,不阻塞(非阻塞輪詢)。
- 如果指向一個 struct timeval 結構體,且其成員 tv_sec 或 tv_usec 大于 0,select 將阻塞直到超時時間到達或有文件描述符就緒。
返回值:
- 成功時,返回就緒的文件描述符的數量。
- 超時時,返回 0。
- 失敗時,返回 -1,并設置 errno。
select返回值表示設置了多少位,這個位數是可讀事件+可寫事件+異常事件的總和
select 的返回值是 所有就緒的文件描述符的總數,無論是可讀、可寫還是異常事件就緒。它不會區分這些事件的類型,只是告訴你“有這么多 FD 就緒了”。
例如,如果 sock1 可讀,sock2 可寫,sock3 有異常,那么 select 將返回 3。
套接字可讀事件,可寫事件,異常事件
上面我們說到了select可以用來監控socket套接字集合的可讀事件,可寫事件,異常事件。
那么這三種事件究竟是什么呢?套接字在什么情況下會產生這些事件呢?
我們必須要搞清楚這三種事件,理解 select 何時會認為一個socket套接字的文件描述符是“可讀”或“可寫”是正確使用它的必要前提。
select中的可讀事件(檢測可讀事件最常見)
-
監聽套接字 (Listening Socket): 如果監聽套接字上發生了新的連接請求(即有客戶端嘗試連接服務器),它就會變得可讀。此時,你可以調用
accept()
來接受新的連接。
-
連接套接字 (Connected Socket):
-
接收緩沖區中有數據可讀。此時,你可以調用
read()
或recv()
來讀取數據,并且這些操作通常會立即返回而不會阻塞(除非緩沖區的數據量小于你請求讀取的量,在阻塞模式下仍可能阻塞,但通常會與非阻塞模式結合使用)。 -
連接被對端關閉(發送了 FIN 包)。此時,
read()
會返回 0,表示連接已正常關閉。 -
連接發生錯誤,導致數據不再可讀。
-
?
select中的可寫事件
-
發送緩沖區有空間: 套接字的發送緩沖區(send buffer)有足夠的空間可以容納你要發送的數據。此時,你可以調用
write()
或send()
來寫入數據,并且這些操作通常會立即返回而不會阻塞。
-
connect()
完成: 對于非阻塞的connect()
調用,當連接建立成功或失敗時,套接字會變得可寫(或在exceptfds
中報告錯誤)。你需要通過getsockopt()
結合SO_ERROR
選項來獲取連接的結果。
?
select 中的異常事件
exceptfds 用于監聽“異常事件”。在實際的網絡編程中,最常見(幾乎是唯一)的異常事件是:
TCP 帶外數據 (Out-of-Band Data, OOB):
當 TCP 套接字接收到帶外數據時,它會觸發一個異常事件。帶外數據是一種特殊的、優先級更高的數據流,它可以繞過正常的 TCP 緩沖區,用于傳輸緊急信息(例如,發送“緊急”信號來中斷遠程操作)。
接收帶外數據需要使用 recv() 并指定 MSG_OOB 標志。
fd_set類型介紹
fd_set 是一個位圖(bitmap),用來表示一組文件描述符(file descriptor,簡稱 FD)。每個位(bit)對應一個文件描述符,如果對應的位被設置(為 1),就表示這個文件描述符在這個集合中。
fd_set 集合的操作宏
為了方便用戶程序操作 fd_set 集合,標準庫提供了一組宏。這些宏實際上是對底層位圖操作的封裝:
- FD_ZERO(fd_set *set):將 fd_set 集合中所有的位清零,即清空集合。
- FD_SET(int fd, fd_set *set):將文件描述符 fd 加入到 fd_set 集合中。
- FD_CLR(int fd, fd_set *set):將文件描述符 fd 從 fd_set 集合中移除。
- FD_ISSET(int fd, fd_set *set):檢查文件描述符 fd 是否在 fd_set 集合中(即是否就緒)。
fd_set的限制
fd_set
最主要的限制是它能夠容納的文件描述符數量。這個上限由系統宏 FD_SETSIZE
定義,在大多數 Linux 系統上,其默認值通常是 1024。這意味著一個 fd_set
實例最多只能同時監聽 1024 個文件描述符。
這個限制對于處理高并發連接的服務器來說是一個嚴重的瓶頸。當需要處理超過 1024 個客戶端連接時,select
就不再適用,需要考慮使用 poll
或 epoll
等其他 I/O 多路復用機制。
fd_set 的內存與性能開銷
-
內存開銷:
fd_set
的大小是固定的,通常是FD_SETSIZE / 8
字節。例如,如果FD_SETSIZE
是 1024,那么fd_set
大約占用 128 字節 (1024 / 8 = 128
)。這部分內存開銷通常不大。
-
性能開銷(與
select
相關):-
用戶空間到內核空間的拷貝: 每次調用
select
,都需要將完整的fd_set
集合從用戶空間復制到內核空間。當FD_SETSIZE
較大時,即使實際活躍的 FD 很少,也需要復制整個fd_set
,這會帶來不必要的開銷。 -
內核遍歷: 內核需要遍歷
fd_set
中所有的FD_SETSIZE
個位,以檢查哪些 FD 已經就緒。這個過程是 O(N) 的,其中 N 是FD_SETSIZE
的值(或nfds
的值)。 -
內核空間到用戶空間的拷貝:
select
返回時,內核需要將包含就緒文件描述符的fd_set
集合(經過內核修改后的)從內核空間復制回用戶空間。這同樣是一次完整的fd_set
結構體的拷貝,帶來了額外的開銷。 -
用戶空間遍歷:
select
返回后,用戶程序也需要遍歷整個fd_set
來找出是哪個 FD 就緒,這也是一個 O(N) 的操作。
-
select的兩次拷貝,兩次遍歷
從用戶程序調用一次 select
系統調用,通常涉及到兩次數據拷貝和兩次遍歷操作。我們來詳細分解一下:
1. 第一次拷貝:用戶空間到內核空間
當你調用 select(nfds, &readfds, &writefds, &exceptfds, &timeout)
時:
-
拷貝內容:
readfds
、writefds
和exceptfds
這三個fd_set
結構體(以及timeout
結構體)的完整內容會從用戶空間復制到內核空間,注意這里是整個位圖都會被拷貝過去,并不是根據nfds來選擇部分進行拷貝。 -
原因: 內核需要知道你對哪些文件描述符的哪些事件感興趣,以便進行監控。
2. 第一次遍歷:內核空間遍歷
在內核空間:
-
遍歷過程: 內核會從
0
到nfds-1
遍歷每一個文件描述符。對于每個文件描述符,它會檢查其是否在你傳入的readfds
、writefds
或exceptfds
的副本中被設置了位。 -
檢查狀態: 如果被設置了位,內核就會去檢查這個文件描述符的實際狀態(例如,網絡緩沖區是否有數據,或者發送緩沖區是否有空間)。
-
結果記錄: 如果文件描述符就緒,內核會在其內部的一個臨時就緒
fd_set
集合中標記對應的位。
3. 第二次拷貝:內核空間到用戶空間
當 select
返回時(有就緒 FD、超時或出錯):
-
拷貝內容: 內核會將其內部維護的、只包含就緒文件描述符的臨時就緒
fd_set
集合,復制回用戶空間,覆蓋掉你傳入的readfds
、writefds
和exceptfds
。 -
原因: 這是
select
返回就緒信息給用戶程序的方式。
4. 第二次遍歷:用戶空間遍歷
select
返回后,在用戶空間:
-
遍歷過程: 用戶程序需要再次從
0
到nfds-1
(或你實際感興趣的 FD 范圍) 遍歷readfds
、writefds
和exceptfds
這三個被修改過的fd_set
集合。 -
檢查狀態: 使用
FD_ISSET(fd, &set)
宏來逐個檢查是哪些文件描述符就緒了。 -
執行操作: 根據
FD_ISSET
的結果,對就緒的文件描述符執行相應的 I/O 操作(read()
,write()
,accept()
等)。
????????大家應該會好奇一個問題,為什么不能讓程序將用戶空間中的套接字位圖fd_set的地址傳遞給內核空間呢?這樣不是可以避免拷貝嗎?為什么要拷貝一份數據過去,內核設置好了之后再將設置好的數據拷貝回用戶空間?
主要原因有如下兩點:
?1.內存保護角度:隔離用戶空間和內核空間: 這是更重要的隔離。內核擁有最高的權限,負責管理所有硬件資源和系統核心功能。如果用戶程序能直接通過一個指針訪問內核內存,或者內核能隨意訪問用戶內存,那么:
- 安全性風險: 惡意用戶程序可以修改內核數據結構,從而獲得特權,甚至破壞整個系統。
- 穩定性風險: 用戶程序的錯誤(比如空指針解引用、越界訪問)可能會直接導致內核崩潰,從而引發整個系統宕機。
- 一致性問題: 如果內核直接操作用戶數據,而用戶程序同時也在修改這些數據,會帶來復雜的數據同步和一致性問題。
2.虛擬內存差異角度:
現代操作系統都采用虛擬內存技術。
虛擬地址 vs. 物理地址: 用戶程序中使用的地址都是虛擬地址。這些虛擬地址需要通過內存管理單元(MMU)映射到實際的物理地址。每個進程都有自己的頁表,負責將本進程的虛擬地址映射到物理地址。
不同的地址空間: 內核運行在它自己的虛擬地址空間中,用戶進程運行在它們各自的虛擬地址空間中。即使一個用戶進程傳遞給內核一個它自己虛擬地址空間中的指針,對于內核來說,這個指針指向的虛擬地址是無效的,因為它不屬于內核自己的地址空間。內核需要一套機制來“翻譯”或“安全地訪問”這些用戶空間的地址。
內核訪問用戶空間數據的正確方式
既然不能直接操作,那內核如何安全地訪問用戶空間數據呢?答案是通過特定的安全機制和系統調用。
拷貝(Copy_From_User / Copy_To_User): 這是最常見且最安全的方式。當用戶程序調用
select
或poll
這樣的系統調用并傳遞數據(如fd_set
或pollfd
數組)時,內核會使用專門的函數(例如 Linux 內核中的copy_from_user()
和copy_to_user()
)來:
驗證地址: 首先,內核會驗證用戶提供的地址是否合法,是否在用戶進程的有效虛擬地址范圍內,以及是否有足夠的權限訪問。
安全拷貝: 驗證通過后,內核會將用戶空間的數據完整地拷貝到內核空間的一塊臨時緩沖區中進行操作。操作完成后,再將結果拷貝回用戶空間。 這種拷貝雖然有性能開銷,但它確保了內核不會因為用戶空間的錯誤而崩潰,也避免了用戶程序的惡意篡改。
內存映射(Memory Mapping): 對于一些需要高性能、大量數據傳輸的場景(例如文件I/O、共享內存),操作系統提供了內存映射機制(如
mmap()
)。這允許用戶空間和內核空間(或多個用戶進程)共享同一塊物理內存區域。但即使是mmap
,也需要通過系統調用來建立映射關系,并且內核會設置適當的權限和保護,確保安全。這種方式并非直接的指針傳遞,而是建立了一種受控的共享訪問機制。
select使用示例介紹
舉個例子:假設你正在編寫一個服務器程序,需要同時監聽客戶端連接請求(通過監聽套接字 listen_sock)以及已經建立的客戶端連接上的數據(通過連接套接字 client_sock1, client_sock2 等)。
這個示例將創建一個簡單的服務器,為了實現簡單,這個示例中select只檢測了socket套接字的可讀事件集合 (read_fds)
在這種情況下,你需要:
- 創建一個 fd_set? read_fds;
? - 在每次循環開始時,調用 FD_ZERO(&read_fds);
? - 將 listen_sock 和所有活動的 client_sock 使用 FD_SET 添加到 read_fds 中。
? - 調用 select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
? - select 返回后,首先檢查 FD_ISSET(listen_sock, &read_fds)。如果是,說明有新的連接請求,可以調用 accept()。
? - 然后遍歷所有 client_sock,檢查 FD_ISSET(client_sock_i, &read_fds)。如果是,說明這個客戶端有數據可讀,可以調用 read()。
? - 如果某個客戶端連接關閉了,就使用 FD_CLR 將其從 fd_set 中移除。
fd_set 是 select I/O 多路復用機制的核心數據結構,它以位圖的形式高效地管理文件描述符集合。盡管它使用簡單且具有良好的跨平臺性,但其固定的 FD_SETSIZE 限制和線性掃描的效率問題,使其在高并發場景下表現不佳。理解 fd_set 的工作原理對于掌握 select 的使用至關重要
select服務器示例代碼
#include <stdio.h> // For printf, perror
#include <stdlib.h> // For exit, EXIT_FAILURE
#include <string.h> // For memset, strlen
#include <unistd.h> // For close, read, write
#include <arpa/inet.h> // For sockaddr_in, inet_ntop
#include <sys/socket.h> // For socket, bind, listen, accept
#include <sys/select.h> // For select, FD_ZERO, FD_SET, FD_CLR, FD_ISSET
#include <errno.h> // For errno, EWOULDBLOCK#define PORT 8080 // 服務器監聽端口
#define MAX_CLIENTS 5 // 最大支持的客戶端連接數
#define BUFFER_SIZE 1024 // 數據緩沖區大小int main() {int listen_fd; // 監聽套接字文件描述符int client_fds[MAX_CLIENTS]; // 存儲已連接客戶端的套接字文件描述符int max_fd; // select 監聽的最大文件描述符 + 1int i; // 循環變量fd_set read_fds; // select 用來監聽可讀事件的文件描述符集合// 初始化客戶端文件描述符數組,設為 -1 表示空閑for (i = 0; i < MAX_CLIENTS; i++) {client_fds[i] = -1;}// --- 1. 創建監聽套接字 ---// AF_INET: IPv4協議族// SOCK_STREAM: TCP流式套接字// 0: 默認協議 (TCP)if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {perror("socket error");exit(EXIT_FAILURE);}printf("Listening socket created: %d\n", listen_fd);// 設置套接字選項:允許地址重用,防止 TIME_WAIT 狀態導致端口不能立即重用int opt = 1;if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {perror("setsockopt error");close(listen_fd);exit(EXIT_FAILURE);}// --- 2. 綁定地址和端口 ---struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr)); // 清零server_addr.sin_family = AF_INET; // IPv4server_addr.sin_addr.s_addr = INADDR_ANY; // 監聽所有可用網絡接口server_addr.sin_port = htons(PORT); // 端口號,htons 將主機字節序轉為網絡字節序if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {perror("bind error");close(listen_fd);exit(EXIT_FAILURE);}printf("Socket bound to port %d\n", PORT);// --- 3. 開啟監聽 ---// 10: 允許的最大等待連接隊列長度if (listen(listen_fd, 10) == -1) {perror("listen error");close(listen_fd);exit(EXIT_FAILURE);}printf("Server listening on port %d...\n", PORT);// --- 4. select 循環處理事件 ---while (1) {FD_ZERO(&read_fds); // 每次循環前清空文件描述符集合FD_SET(listen_fd, &read_fds); // 將監聽套接字加入可讀集合 (因為它可能接收新連接)// 確定當前需要監聽的最大文件描述符 + 1max_fd = listen_fd;for (i = 0; i < MAX_CLIENTS; i++) {if (client_fds[i] != -1) {FD_SET(client_fds[i], &read_fds); // 將每個活躍的客戶端套接字加入可讀集合if (client_fds[i] > max_fd) {max_fd = client_fds[i];}}}// 調用 select 進行 I/O 多路復用,阻塞等待事件// 第一個參數是所有要監聽的 FD 中的最大值加 1// 后三個參數分別代表監聽可讀、可寫、異常事件的 FD 集合// 最后一個參數是超時時間,NULL 表示永遠阻塞直到有事件發生printf("\nWaiting for events (max_fd = %d)...\n", max_fd);int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);if ((activity < 0) && (errno != EINTR)) { // 檢查 select 返回值perror("select error");break; // 出現錯誤則退出循環}// --- 5. 處理就緒事件 ---// (1) 檢查監聽套接字是否可讀:表示有新的客戶端連接請求if (FD_ISSET(listen_fd, &read_fds)) {struct sockaddr_in client_addr;socklen_t client_addr_len = sizeof(client_addr);// 接受新連接int new_socket = accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_len);if (new_socket == -1) {perror("accept error");continue; // 繼續下一輪循環}char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);printf("New connection accepted. Socket FD: %d, IP: %s, Port: %d\n",new_socket, client_ip, ntohs(client_addr.sin_port));// 將新連接的套接字加入到 client_fds 數組中int found_slot = 0;for (i = 0; i < MAX_CLIENTS; i++) {if (client_fds[i] == -1) { // 找到一個空閑位置client_fds[i] = new_socket;found_slot = 1;printf("Adding client socket %d to array slot %d\n", new_socket, i);break;}}if (!found_slot) {printf("Max clients reached. Rejecting new connection %d\n", new_socket);close(new_socket); // 如果沒有空閑位置,關閉新連接}activity--; // 減少一個已處理的活動事件}// (2) 檢查已連接客戶端套接字是否可讀:表示有數據到來或連接關閉for (i = 0; i < MAX_CLIENTS; i++) {int client_fd = client_fds[i];if (client_fd != -1 && FD_ISSET(client_fd, &read_fds)) {char buffer[BUFFER_SIZE];memset(buffer, 0, BUFFER_SIZE); // 清空緩沖區// 從客戶端讀取數據ssize_t bytes_read = read(client_fd, buffer, BUFFER_SIZE - 1);if (bytes_read == 0) {// 對端關閉了連接printf("Client %d disconnected.\n", client_fd);close(client_fd); // 關閉套接字client_fds[i] = -1; // 將數組中的位置標記為空閑} else if (bytes_read == -1) {// 讀取錯誤perror("read error");close(client_fd);client_fds[i] = -1;} else {// 成功讀取到數據buffer[bytes_read] = '\0'; // 確保字符串以 null 結尾printf("Received from client %d: %s\n", client_fd, buffer);// 可選:將收到的數據回顯給客戶端if (write(client_fd, buffer, bytes_read) == -1) {perror("write error");}}activity--; // 減少一個已處理的活動事件}// 如果所有活動事件都已處理,可以提前退出循環if (activity == 0) {break;}}}// --- 6. 清理資源 (通常不會到達這里,除非發生嚴重錯誤) ---close(listen_fd);for (i = 0; i < MAX_CLIENTS; i++) {if (client_fds[i] != -1) {close(client_fds[i]);}}return 0;
}
poll函數詳細介紹
poll函數介紹
函數原型:
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
參數介紹:
fds:這是一個指向 struct pollfd 結構體數組的指針。每個 struct pollfd 結構體都代表一個我們希望監視的文件描述符及其感興趣的事件。
nfds:這是 fds 數組中元素的個數,即我們要監視的文件描述符的總數。
timeout:這是一個整數,指定 poll 函數的等待時間(毫秒)。
- 大于0的整數: poll 將等待指定毫秒數,如果在此期間沒有事件發生,poll 將返回0。
- 0: poll 不會等待,立即返回。它會檢查當前文件描述符的狀態,并返回已經準備好的文件描述符的數量。
- -1: poll 將無限期等待,直到有事件發生或被信號中斷。
返回值
poll 函數的返回值表示就緒的文件描述符的數量,即 revents 字段非零的 struct pollfd 結構體的數量。
-
大于0: 表示有指定數量的文件描述符就緒。
-
0: 表示在 timeout 期間沒有文件描述符就緒。
-
-1: 表示 poll 函數調用失敗,此時可以通過 errno 變量獲取具體的錯誤信息。
pollfd類型介紹
struct pollfd {int fd; /* 文件描述符 */short events; /* 監視的事件 */short revents; /* 實際發生的事件 */
};
-
fd
:要監視的文件描述符。 -
events
:這是一個位掩碼,表示我們感興趣的事件。可以是一個或多個事件的按位或組合。常用的事件標志包括:-
POLLIN
:文件描述符上有數據可讀。 -
POLLOUT
:文件描述符上可以寫入數據。 -
POLLERR
:文件描述符上發生錯誤。 -
POLLHUP
:對端掛斷連接(通常是EOF)。 -
POLLNVAL
:無效的文件描述符請求。
-
-
revents
:這是一個位掩碼,由poll
函數返回,表示在文件描述符上實際發生的事件。它的取值與events
類似,可以包含上述事件標志。
poll的工作原理
當調用 poll 函數時,內核會遍歷 fds 數組中的每個 struct pollfd 結構體,檢查其對應的文件描述符上是否發生了 events 中指定的事件。
- 如果事件發生,內核會在該 struct pollfd 的 revents 字段中設置相應的位,并將其標記為就緒。
- 如果沒有事件發生,并且 timeout 尚未到期,poll 函數會進入睡眠狀態,直到事件發生或 timeout 到期。
- 當 poll 返回時,程序可以遍歷 fds 數組,檢查每個 struct pollfd 的 revents 字段,以確定哪些文件描述符已經就緒,然后對這些文件描述符進行相應的I/O操作。
poll的優缺點
poll
的優點
-
沒有文件描述符數量限制: 解決了
select
的FD_SETSIZE
限制問題,可以監視任意數量的文件描述符,只受限于系統內存。 -
更清晰的事件表示:
struct pollfd
結構體使得事件的設置和檢查更加直觀。 -
更好的性能: 尤其在文件描述符數量較多時,
poll
的性能優于select
。 -
可重用性:
fds
數組可以在多次poll
調用中重用,而select
的fd_set
每次調用后都需要重新初始化。 -
只拷貝實際監視的
nfds
個struct pollfd
結構體,數據更緊湊。
poll
的缺點
-
仍然需要遍歷: 盡管
poll
沒有文件描述符數量限制,但在poll
返回后,仍然需要遍歷整個fds
數組來查找哪些文件描述符就緒,當文件描述符數量非常龐大時,這會成為一個性能瓶頸。