我们继续学习 Linux 系统编程中的重要函数。这次我们介绍 select 函数,它是一种经典的 I/O 多路复用机制,允许一个进程监视多个文件描述符,等待其中任何一个或多个文件描述符变为“就绪”状态(例如可读、可写或发生异常)。
data-ad-format="fluid"
data-ad-layout-key="-7k+ex-4a-9w+4a">
注意:虽然 select 功能强大且历史悠久,但在处理大量文件描述符时,poll 和更现代的 epoll (Linux 特有) 通常性能更好。不过,select 因其可移植性(在多种 Unix 系统上都可用)和教学价值,仍然是需要了解的重要函数。
1. 函数介绍 select 是一个 Linux 系统调用(实际上在很多类 Unix 系统上都可用),用于实现 I/O 多路复用 (I/O multiplexing)。它的核心思想是让进程能够同时检查多个文件描述符(如套接字、管道、终端等)的状态,看它们是否准备好进行 I/O 操作(例如读取、写入),而无需对每个文件描述符都进行阻塞式等待。
在没有 select(或 poll、epoll)的情况下,如果一个程序需要同时处理多个网络连接或文件,它可能需要创建多个线程或进程,或者在一个文件描述符上阻塞等待,这会非常低效或复杂。select 允许一个线程/进程在一个调用中“监听”所有感兴趣的文件描述符,当其中任何一个准备好时,select 返回,程序就可以处理那个就绪的文件描述符。
你可以把它想象成一个“服务员”,同时照看多张餐桌(文件描述符)。服务员不需要一直站在某一张餐桌旁等客人点菜(数据),而是可以走一圈看看哪张餐桌的客人举手了(数据就绪),然后去为那张餐桌服务。
2. 函数原型 1 2 3 4 5 6 7 8 9 #include <sys/select.h> // 必需 // 标准形式 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); // pselect 是 select 的变体,增加了信号掩码参数,这里暂不讨论 // int pselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, // const struct timespec *timeout, const sigset_t *sigmask);
3. 功能
监视文件描述符集合: select 会检查 readfds, writefds, exceptfds 这三个集合中列出的文件描述符的状态。
等待就绪: 调用 select 的进程会阻塞(挂起),直到以下情况之一发生:
readfds, writefds, exceptfds 中指定的至少一个文件描述符变为“就绪”状态。
调用被信号中断(返回 -1,并设置 errno 为 EINTR)。
达到指定的超时时间 timeout(如果 timeout 不为 NULL)。
返回就绪数量: 当 select 返回时,它会报告有多少个文件描述符已就绪。
更新集合: 关键点:select 会修改 readfds, writefds, exceptfds 这三个集合作为输出。调用返回后,这些集合中只保留那些已就绪的文件描述符,所有未就绪的文件描述符都会从集合中被清除。因此,如果需要在下一次 select 调用中继续监视相同的文件描述符集合,必须在每次调用 select 之前重新设置这些集合。
4. 参数
int nfds: 这是需要监视的文件描述符中的最大值加 1。它用于确定内核需要检查的文件描述符范围。例如,如果监视的文件描述符是 0, 1, 4, 7,那么 nfds 应该是 8 (7 + 1)。在现代实现中,这个参数可能不那么关键,但为了兼容性和正确性,应始终正确设置。
fd_set *readfds: 指向一个 fd_set 类型的集合。调用者将所有关心可读性的文件描述符加入此集合(使用 FD_SET 宏)。select 返回时,此集合会被修改,仅保留那些已准备好读取的文件描述符。
fd_set *writefds: 指向一个 fd_set 类型的集合。调用者将所有关心可写性的文件描述符加入此集合。select 返回时,此集合会被修改,仅保留那些已准备好写入的文件描述符。
fd_set *exceptfds: 指向一个 fd_set 类型的集合。用于监视“异常条件”(如带外数据)。在很多应用中(特别是 TCP 网络编程),这个参数可以设为 NULL。
struct timeval *timeout: 指定 select 调用阻塞等待的超时时间。timeout == NULL: select 会无限期阻塞,直到至少一个文件描述符就绪或被信号中断。
timeout->tv_sec == 0 && timeout->tv_usec == 0: select 执行非阻塞检查,立即返回,报告当前有多少文件描述符已就绪。
timeout->tv_sec > 0 || timeout->tv_usec > 0: select 最多阻塞 tv_sec 秒 + tv_usec 微秒。如果在超时前没有文件描述符就绪,则返回 0。struct timeval 定义如下: struct timeval { long tv_sec; // 秒 long tv_usec; // 微秒 (0-999999) }; 重要: 在 Linux 上,select 返回时,timeout 的值可能会被修改,以反映剩余的超时时间。但在可移植代码中,不应依赖此行为,每次调用 select 前都应重新设置 timeout。
5. fd_set 集合操作宏 select 使用 fd_set 数据结构来表示文件描述符集合。操作 fd_set 需要使用以下标准宏:
FD_ZERO(fd_set *set): 清空(初始化)整个 fd_set 集合,移除所有文件描述符。
FD_SET(int fd, fd_set *set): 将文件描述符 fd 添加到 fd_set 集合 set 中。
FD_CLR(int fd, fd_set *set): 将文件描述符 fd 从 fd_set 集合 set 中移除。
FD_ISSET(int fd, fd_set *set): 检查文件描述符 fd 是否在 fd_set 集合 set 中。如果在,返回非零值;否则返回 0。
6. 返回值 成功时:
返回 就绪的文件描述符的总数。这个数字可以是 0(表示超时)。
失败时:
返回 -1,并设置全局变量 errno 来指示具体的错误原因(例如 EBADF 某个 fd 无效,EINTR 调用被信号中断,EINVAL nfds 负数等)。
超时:
如果在 timeout 指定的时间内没有任何文件描述符就绪,返回 0。
7. 相似函数,或关联函数
poll: 功能与 select 类似,但使用 struct pollfd 数组而不是 fd_set 位掩码。在处理大量文件描述符时通常性能更好,且没有 FD_SETSIZE 的硬性限制。
epoll_wait / epoll_ctl / epoll_create: Linux 特有的、更高效的 I/O 多路复用机制,特别适合处理大量的并发连接。它使用一个内核事件表来管理监视的文件描述符,避免了 select/poll 每次调用都需要传递整个文件描述符集合的开销。
read, write: 在 select 返回某个文件描述符就绪后,通常会调用 read 或 write 来执行实际的 I/O 操作。
8. 示例代码 示例 1:监视标准输入和一个管道 这个例子演示如何使用 select 同时监视标准输入(键盘)和一个管道的读端,看哪个先有数据可读。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 #include <sys/select.h> // select, fd_set, FD_* #include <sys/time.h> // struct timeval #include <unistd.h> // pipe, read, write, close, STDIN_FILENO #include <stdio.h> // perror, printf, fprintf #include <stdlib.h> // exit #include <string.h> // strlen int main() { int pipefd[2]; fd_set readfds; // 用于 select 的文件描述符集合 int max_fd; // select 需要的最大文件描述符 + 1 struct timeval timeout; // 超时设置 int activity; // select 返回值 char buffer[100]; ssize_t bytes_read; // 1. 创建管道 if (pipe(pipefd) == -1) { perror("pipe"); exit(EXIT_FAILURE); } // 2. 计算 select 需要监视的最大文件描述符 + 1 max_fd = (STDIN_FILENO > pipefd[0]) ? STDIN_FILENO : pipefd[0]; max_fd += 1; printf("Waiting up to 5 seconds for input from stdin or data in pipe...\n"); printf("Type something in the terminal, or run 'echo hello > /proc/%d/fd/%d' in another terminal.\n", getpid(), pipefd[1]); // 提示用户如何向管道写入 // 3. 主循环 while (1) { // 4. 每次循环开始前,必须重新初始化 fd_set FD_ZERO(&readfds); // 清空集合 FD_SET(STDIN_FILENO, &readfds); // 添加标准输入 FD_SET(pipefd[0], &readfds); // 添加管道读端 // 5. 设置超时 (5秒) timeout.tv_sec = 5; timeout.tv_usec = 0; // 6. 调用 select 进行等待 // 注意: timeout 结构体可能会被 select 修改,所以放在循环内 activity = select(max_fd, &readfds, NULL, NULL, &timeout); // 7. 检查 select 的返回值 if (activity == -1) { if (errno == EINTR) { printf("select was interrupted by a signal, continuing...\n"); continue; // 被信号中断,通常继续循环 } else { perror("select error"); break; // 或 exit(EXIT_FAILURE); } } else if (activity == 0) { printf("Timeout occurred! No data within 5 seconds. Exiting.\n"); break; // 超时退出循环 } else { printf("%d file descriptor(s) became ready.\n", activity); // 8. 检查哪个文件描述符就绪了 // 注意:select 返回后,readfds 只包含就绪的 fd if (FD_ISSET(STDIN_FILENO, &readfds)) { printf(" -> Data is ready on standard input (stdin).\n"); bytes_read = read(STDIN_FILENO, buffer, sizeof(buffer) - 1); if (bytes_read > 0) { buffer[bytes_read] = '\0'; // 确保字符串结束 printf(" -> Read from stdin: %s", buffer); // buffer 可能已包含 \n } } if (FD_ISSET(pipefd[0], &readfds)) { printf(" -> Data is ready on the pipe.\n"); bytes_read = read(pipefd[0], buffer, sizeof(buffer) - 1); if (bytes_read > 0) { buffer[bytes_read] = '\0'; printf(" -> Read from pipe: %s", buffer); } } } } // 9. 清理资源 close(pipefd[0]); close(pipefd[1]); return 0; }
代码解释:
使用 pipe() 创建一个管道。
计算需要监视的最大文件描述符 max_fd(STDIN_FILENO 和 pipefd[0] 中的较大者加 1)。
进入主循环。
关键: 在每次 select 调用前,使用 FD_ZERO(&readfds) 清空 fd_set。
使用 FD_SET 将 STDIN_FILENO 和 pipefd[0] 添加到 readfds 集合中。
设置 timeout 结构体为 5 秒。
调用 select(max_fd, &readfds, NULL, NULL, &timeout)。我们只关心可读性,所以 writefds 和 exceptfds 都是 NULL。
检查 select 的返回值:
如果有文件描述符就绪(activity > 0),使用 FD_ISSET 检查 STDIN_FILENO 和 pipefd[0] 是否在修改后的 readfds 集合中。
对于就绪的文件描述符,调用 read 读取数据。
循环继续或根据条件(如超时)退出。
最后关闭管道的两端。
示例 2:简单的 TCP 服务器(非阻塞 accept 和客户端 socket) 这个例子演示如何在 TCP 服务器中使用 select 来同时监听监听套接字(用于接受新连接)和已建立连接的客户端套接字(用于接收数据)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 #include <sys/select.h> // select, fd_set, FD_* #include <sys/time.h> // struct timeval #include <sys/socket.h> // socket, bind, listen, accept, recv, send #include <netinet/in.h> // sockaddr_in #include <arpa/inet.h> // inet_ntoa (简化版,非线程安全) #include <unistd.h> // close, read, write #include <stdio.h> // perror, printf, fprintf #include <stdlib.h> // exit #include <string.h> // memset, strlen #define PORT 8080 #define MAX_CLIENTS 30 // select 的 fd_set 通常有 FD_SETSIZE (1024) 的限制 #define BUFFER_SIZE 1024 int main() { int server_fd, new_socket, client_socket[MAX_CLIENTS]; struct sockaddr_in address; int addrlen = sizeof(address); fd_set readfds; // select 用的读集合 int max_sd; // 当前最大的文件描述符 int activity, i, valread; char buffer[BUFFER_SIZE] = {0}; char *hello = "Hello from server"; // 1. 初始化客户端套接字数组 for (i = 0; i < MAX_CLIENTS; i++) { client_socket[i] = 0; // 0 表示空闲槽位 } // 2. 创建服务器套接字 if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("socket failed"); exit(EXIT_FAILURE); } // 3. 配置服务器地址 address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; // 绑定到所有本地接口 address.sin_port = htons(PORT); // 4. 绑定套接字 if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { perror("bind failed"); close(server_fd); exit(EXIT_FAILURE); } // 5. 监听连接 if (listen(server_fd, 3) < 0) { // backlog=3 perror("listen"); close(server_fd); exit(EXIT_FAILURE); } printf("Server listening on port %d\n", PORT); // 6. 主循环 while(1) { // 7. 清空 fd_set FD_ZERO(&readfds); // 8. 将监听套接字加入集合 FD_SET(server_fd, &readfds); max_sd = server_fd; // 9. 将已连接的客户端套接字加入集合 for (i = 0; i < MAX_CLIENTS; i++) { if (client_socket[i] > 0) { FD_SET(client_socket[i], &readfds); } // 更新最大文件描述符 if (client_socket[i] > max_sd) { max_sd = client_socket[i]; } } // 10. 设置超时 (这里设为 NULL,表示无限等待) struct timeval timeout; timeout.tv_sec = 30; // 30秒超时,避免永久阻塞 timeout.tv_usec = 0; // 11. 调用 select 等待活动 activity = select(max_sd + 1, &readfds, NULL, NULL, &timeout); if (activity < 0) { if (errno == EINTR) { printf("select interrupted by signal, continuing...\n"); continue; } perror("select error"); break; } if (activity == 0) { printf("select timeout (30s), continuing...\n"); continue; } // 12. 检查监听套接字是否有活动 (新连接) if (FD_ISSET(server_fd, &readfds)) { if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) { perror("accept"); continue; } printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port)); // 13. 将新连接的套接字添加到客户端数组中 int added = 0; for (i = 0; i < MAX_CLIENTS; i++) { if (client_socket[i] == 0) { client_socket[i] = new_socket; added = 1; printf("Adding to list of sockets at index %d\n", i); send(new_socket, hello, strlen(hello), 0); // 发送欢迎信息 break; } } if (!added) { printf("Too many clients, connection rejected\n"); close(new_socket); } } // 14. 检查已连接的客户端套接字是否有活动 for (i = 0; i < MAX_CLIENTS; i++) { int sd = client_socket[i]; if (FD_ISSET(sd, &readfds)) { // 15. 有数据从客户端发来 valread = read(sd, buffer, BUFFER_SIZE - 1); if (valread == 0) { // 客户端断开连接 getpeername(sd, (struct sockaddr*)&address, (socklen_t*)&addrlen); printf("Host disconnected, ip %s, port %d. Closing socket %d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port), sd); close(sd); client_socket[i] = 0; // 标记槽位为空闲 } else { // 处理收到的数据 buffer[valread] = '\0'; printf("Received message from socket %d: %s", sd, buffer); // Echo 回去 send(sd, buffer, strlen(buffer), 0); } } } } // 16. 清理 (在真实应用中,需要更优雅的退出机制) for(i = 0; i < MAX_CLIENTS; i++) { if(client_socket[i] != 0) { close(client_socket[i]); } } close(server_fd); printf("Server closed.\n"); return 0; }
代码解释:
创建、绑定、监听 TCP 套接字。
初始化一个 client_socket 数组,用于存储已建立连接的客户端套接字。用 0 表示空闲槽位。
进入主循环。
每次循环开始,使用 FD_ZERO(&readfds) 清空 fd_set。
使用 FD_SET(server_fd, &readfds) 将监听套接字加入监视集合。
遍历 client_socket 数组,将所有有效的(非零)客户端套接字也加入 readfds 集合。
在添加文件描述符的过程中,动态计算并更新 max_sd(当前监视的最大文件描述符)。
设置 select 的超时时间(这里设为 30 秒,避免无限期阻塞)。
调用 select(max_sd + 1, &readfds, NULL, NULL, &timeout)。
检查 select 返回值 activity。
如果 FD_ISSET(server_fd, &readfds) 为真,说明监听套接字就绪,调用 accept 接受新连接,并将其存入 client_socket 数组的空闲槽位。
遍历 client_socket 数组,检查每个有效的客户端套接字 sd 是否在 readfds 中(即 FD_ISSET(sd, &readfds) 为真)。
如果某个客户端套接字就绪,调用 read 读取数据。
如果 read 返回 0,表示客户端关闭了连接,关闭该套接字,并将 client_socket 数组中对应的项标记为 0(空闲)。
如果 read 返回正数,表示读到了数据,这里简单地将其 echo 回客户端。
循环继续处理下一个事件。
这个例子展示了 select 如何在一个单线程服务器中高效地管理多个并发连接。与为每个连接创建一个线程或进程相比,select(以及更高效的 poll 和 epoll)是构建高性能网络服务器的基础技术之一。
总结:
select 是一个功能强大且历史悠久的 I/O 多路复用函数。理解其关键是掌握 fd_set 集合的操作(FD_ZERO, FD_SET, FD_CLR, FD_ISSET)、nfds 参数的正确设置、select 返回后集合的修改行为,以及如何根据返回的就绪文件描述符数量和状态来处理相应的 I/O 操作。虽然在处理大量连接时不如 poll 或 epoll 高效,但其良好的可移植性使其在很多场景下仍然有用。