多路复用 I/O 函数——`select`函数解析
好的,我们以 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秒后会打印超时。