好的,我们以 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>
头文件中。
c
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
等待的超时时间。这是一个结构体指针,可以精确到微秒。 -
结构体定义 :
cstruct timeval { long tv_sec; /* seconds (秒)*/ long tv_usec; /* microseconds (微秒)*/ };
-
取值范围 :
NULL
:无限阻塞。直到有描述符就绪。{0, 0}
:非阻塞轮询。立即返回,检查描述符状态。{n, m}
:等待最多 n 秒 m 微秒。
-
fd_set
相关操作宏(非常重要):
c
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
),实现一个带超时等待的输入提示符。
c
#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+1
retval = 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 string
printf("You typed: %s", buf);
} else {
perror("read");
break;
}
}
}
}
return 0;
}
示例 2:监听多个套接字(服务器端模型)
此示例展示一个简易的单线程回显服务器,可以同时处理监听新连接和已连接客户端的读事件。
c
#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 1024
int 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
检查一个套接字是否可写,这在连接建立后首次发送数据或处理阻塞写时有用。
c
#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) 编译方式与注意事项
编译命令:
bash
# 编译示例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秒后会打印超时。
8) 图文总结:select
工作流程
返回值 > 0 在 readfds 中就绪 在 writefds 中就绪 不在任何集合中 返回值 == 0 返回值 == -1 EINTR (被信号中断) 其他错误 应用程序准备 设置超时时间 timeout
清空并设置 fd_set 集合
计算 nfds (max_fd + 1) 调用 select() 阻塞等待 select() 返回 有描述符就绪 遍历所有被监听的描述符 使用 FD_ISSET 检查? 处理可读事件
accept/read 处理可写事件
write/connect完成 继续下一轮循环 等待超时
执行超时处理 检查 errno 处理错误