好的,我們以 Linux 中經典的多路復用 I/O 函數——select
為例,進行一次完整、深入且包含全部代碼的解析。
<摘要>
select
是 Unix/Linux 系統中傳統的多路復用 I/O 系統調用。它允許一個程序同時監視多個文件描述符(通常是套接字),阻塞等待直到一個或多個描述符就緒(如變得可讀、可寫或發生異常),或者等待超時。它是構建能夠處理多個客戶端連接的服務器(如早期的 Web 服務器、聊天室)的基礎方法。雖然性能上不如 epoll
,但其跨平臺特性(POSIX 標準)使其仍有廣泛應用價值。
<解析>
select
函數是處理并發 I/O 的“老將”。它的核心思想是:“告訴我一組你關心的文件描述符,我來幫你盯著,一旦其中有任何一個有動靜(可讀、可寫、出錯),或者等到你指定的時間,我就醒來通知你。” 這樣,單個線程就可以管理多個連接。
1) 函數的概念與用途
- 功能:同步地監視多組(可讀、可寫、異常)文件描述符的狀態變化。它會使進程阻塞,直到有描述符就緒或超時。
- 場景:
- 管理多個網絡客戶端連接的服務器。
- 需要同時監聽標準輸入和網絡套接字的客戶端(如聊天程序)。
- 需要設置精確超時的 I/O 操作。
- 跨平臺程序(Windows 也支持
select
)。
2) 函數聲明與出處
select
定義在 <sys/select.h>
頭文件中。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
3) 返回值含義與取值范圍
- 成功:返回就緒的文件描述符的總數。如果超時則返回
0
。 - 失敗:返回
-1
,并設置相應的錯誤碼errno
。EBADF
:在某一個集合中傳入了無效的文件描述符。EINTR
:這個調用在阻塞期間被信號中斷。通常需要重新調用select
。EINVAL
:參數nfds
為負數或超時時間值無效。
4) 參數的含義與取值范圍
-
int nfds
- 作用:指定所有被監控的文件描述符集合中最大值加 1。內核通過這個值來線性掃描哪些描述符就緒,從而提高效率。
- 取值范圍:通常是
max_fd + 1
(max_fd
是所有監聽描述符中最大的那個)。
-
fd_set *readfds
- 作用:指向一個
fd_set
類型的對象,該對象中包含了我們關心是否可讀的文件描述符集合。傳入時是“我們關心的”,返回時是“就緒的”。 - 取值范圍:
NULL
表示不關心可讀事件。
- 作用:指向一個
-
fd_set *writefds
- 作用:指向一個
fd_set
類型的對象,該對象中包含了我們關心是否可寫的文件描述符集合。 - 取值范圍:
NULL
表示不關心可寫事件。
- 作用:指向一個
-
fd_set *exceptfds
- 作用:指向一個
fd_set
類型的對象,該對象中包含了我們關心是否發生異常的文件描述符集合。異常通常指帶外數據(OOB data)到達。 - 取值范圍:
NULL
表示不關心異常事件。
- 作用:指向一個
-
struct timeval *timeout
- 作用:指定
select
等待的超時時間。這是一個結構體指針,可以精確到微秒。 - 結構體定義:
struct timeval {long tv_sec; /* seconds (秒)*/long tv_usec; /* microseconds (微秒)*/ };
- 取值范圍:
NULL
:無限阻塞。直到有描述符就緒。{0, 0}
:非阻塞輪詢。立即返回,檢查描述符狀態。{n, m}
:等待最多 n 秒 m 微秒。
- 作用:指定
fd_set
相關操作宏(非常重要):
void FD_ZERO(fd_set *set); // 清空一個 fd_set
void FD_SET(int fd, fd_set *set); // 將一個 fd 加入 set
void FD_CLR(int fd, fd_set *set); // 將一個 fd 從 set 中移除
int FD_ISSET(int fd, fd_set *set); // 檢查一個 fd 是否在 set 中(就緒)
5) 函數使用案例
示例 1:基礎用法 - 監聽標準輸入(阻塞等待)
此示例演示如何使用 select
監聽標準輸入(STDIN_FILENO
),實現一個帶超時等待的輸入提示符。
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <unistd.h>int main() {fd_set read_fds;struct timeval timeout;int retval;char buf[256];printf("You have 5 seconds to type something...\n");while(1) {// 1. 設置超時時間(每次循環都需要重新設置,因為select調用后會修改timeout)timeout.tv_sec = 5;timeout.tv_usec = 0;// 2. 清空并設置要監視的描述符集合(每次循環都需要重新設置,因為select調用后會修改read_fds)FD_ZERO(&read_fds);FD_SET(STDIN_FILENO, &read_fds); // STDIN_FILENO is 0// 3. 調用select,nfds是最大fd+1retval = select(STDIN_FILENO + 1, &read_fds, NULL, NULL, &timeout);if (retval == -1) {perror("select()");exit(EXIT_FAILURE);} else if (retval == 0) {printf("\nTimeout! No data within 5 seconds.\n");printf("Waiting again...\n");} else {// 檢查我們關心的描述符是否真的就緒if (FD_ISSET(STDIN_FILENO, &read_fds)) {// 從標準輸入讀取數據ssize_t count = read(STDIN_FILENO, buf, sizeof(buf) - 1);if (count > 0) {buf[count] = '\0'; // Null-terminate the stringprintf("You typed: %s", buf);} else {perror("read");break;}}}}return 0;
}
示例 2:監聽多個套接字(服務器端模型)
此示例展示一個簡易的單線程回顯服務器,可以同時處理監聽新連接和已連接客戶端的讀事件。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>#define PORT 8080
#define MAX_CLIENTS 10
#define BUF_SIZE 1024int main() {int server_fd, new_socket, client_sockets[MAX_CLIENTS];fd_set read_fds;int max_sd, sd, activity, i, valread;struct sockaddr_in address;int addrlen = sizeof(address);char buffer[BUF_SIZE];// 初始化客戶端套接字數組for (i = 0; i < MAX_CLIENTS; i++) {client_sockets[i] = 0;}// 創建服務器套接字if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {perror("socket failed");exit(EXIT_FAILURE);}address.sin_family = AF_INET;address.sin_addr.s_addr = INADDR_ANY;address.sin_port = htons(PORT);// 綁定套接字到端口if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {perror("bind failed");exit(EXIT_FAILURE);}// 開始監聽if (listen(server_fd, 3) < 0) {perror("listen");exit(EXIT_FAILURE);}printf("Server listening on port %d\n", PORT);while(1) {// 清空描述符集FD_ZERO(&read_fds);// 添加服務器監聽套接字FD_SET(server_fd, &read_fds);max_sd = server_fd;// 添加所有有效的客戶端套接字for (i = 0; i < MAX_CLIENTS; i++) {sd = client_sockets[i];if (sd > 0) {FD_SET(sd, &read_fds);}if (sd > max_sd) {max_sd = sd;}}// 等待活動,無限超時activity = select(max_sd + 1, &read_fds, NULL, NULL, NULL);if ((activity < 0) && (errno != EINTR)) {perror("select error");}// 1. 檢查是否有新的連接到來(監聽套接字是否可讀)if (FD_ISSET(server_fd, &read_fds)) {if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {perror("accept");exit(EXIT_FAILURE);}printf("New connection, socket fd is %d, IP: %s, Port: %d\n",new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));// 將新套接字添加到客戶端數組for (i = 0; i < MAX_CLIENTS; i++) {if (client_sockets[i] == 0) {client_sockets[i] = new_socket;printf("Adding to list of sockets as %d\n", i);break;}}if (i == MAX_CLIENTS) {printf("Too many clients. Rejected.\n");close(new_socket);}}// 2. 檢查是哪個客戶端套接字有數據可讀for (i = 0; i < MAX_CLIENTS; i++) {sd = client_sockets[i];if (FD_ISSET(sd, &read_fds)) {// 讀取數據if ((valread = read(sd, buffer, BUF_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_sockets[i] = 0; // 從數組中清除} else {// 回顯數據buffer[valread] = '\0';printf("Received from client %d: %s", sd, buffer);send(sd, buffer, valread, 0); // Echo back}}}}return 0;
}
使用 telnet 127.0.0.1 8080
命令可以測試此服務器。
示例 3:非阻塞檢查可寫性
此示例演示如何用 select
檢查一個套接字是否可寫,這在連接建立后首次發送數據或處理阻塞寫時有用。
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <errno.h>int main() {int sockfd = socket(AF_INET, SOCK_STREAM, 0);fd_set write_fds;struct timeval timeout;int retval;// 這里我們嘗試連接一個可能不響應SYN的地址來演示struct sockaddr_in serv_addr;serv_addr.sin_family = AF_INET;serv_addr.sin_port = htons(80); // HTTP port// Let's assume we have an address that might block (e.g., a slow server)// inet_pton(AF_INET, "93.184.216.34", &serv_addr.sin_addr); // example.com// 設置為非阻塞模式 (對于這個演示很重要)int flags = fcntl(sockfd, F_GETFL, 0);fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);// 發起非阻塞連接connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));FD_ZERO(&write_fds);FD_SET(sockfd, &write_fds);timeout.tv_sec = 3; // 設置3秒連接超時timeout.tv_usec = 0;printf("Waiting for socket to become writable (connected)...\n");retval = select(sockfd + 1, NULL, &write_fds, NULL, &timeout);if (retval == -1) {perror("select()");} else if (retval == 0) {printf("Timeout! Socket connection timed out after 3 seconds.\n");} else {if (FD_ISSET(sockfd, &write_fds)) {int error_code;socklen_t error_len = sizeof(error_code);// 檢查套接字上是否有錯誤getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error_code, &error_len);if (error_code == 0) {printf("Socket is writable! Connection established successfully.\n");// Now you can send data// send(sockfd, "GET / HTTP/1.0\r\n\r\n", 18, 0);} else {printf("Connection failed with error: %s\n", strerror(error_code));}}}close(sockfd);return 0;
}
6) 編譯方式與注意事項
編譯命令:
# 編譯示例1
gcc -o select_stdin select_stdin.c
# 編譯示例2 (需要鏈接網絡庫)
gcc -o select_server select_server.c
# 編譯示例3
gcc -o select_connect select_connect.c
注意事項:
- 參數會被修改:
select
返回后,readfds
、writefds
、exceptfds
和timeout
參數的值都會被內核修改。它們表示的是就緒的描述符集合和剩余時間。因此,每次調用select
前都必須重新初始化這些參數。 - 性能問題:
select
采用線性掃描的方式,其效率與最大文件描述符的值nfds
相關。當需要監視大量描述符時,性能會急劇下降。這是它被epoll
取代的主要原因。 - 描述符數量限制:
fd_set
有大小限制,通常是FD_SETSIZE
(通常是 1024)。這意味著一個進程通過select
最多只能同時監視 1024 個文件描述符。 - 無法得知具體數量:
select
返回后,你只知道有多少描述符就緒,但不知道是哪幾個。你必須通過FD_ISSET
遍歷整個初始集合來找出就緒的描述符,這在集合很大但就緒描述符很少時效率很低。
7) 執行結果說明
- 示例1:運行后,程序會等待5秒。如果你在5秒內輸入文字并回車,它會立即打印你的輸入。如果5秒內無輸入,它會打印超時信息并繼續等待。
- 示例2:運行后,服務器啟動。使用
telnet 127.0.0.1 8080
連接后,你在 telnet 中輸入的任何文字都會被服務器回顯給你。服務器日志會打印所有連接和接收到的數據活動。 - 示例3:運行后,程序會嘗試連接
example.com
的80端口。如果網絡通暢,3秒內會打印連接成功;如果網絡不通或目標不響應,3秒后會打印超時。