author: hjjdebug
date: 2026年 01月 25日 星期日 15:08:37 CST
descrip: select 函数详解.
文章目录
- [0: I/O 多路复用是什么意思 ??](#0: I/O 多路复用是什么意思 ??)
- [1. select 函数可以同时支持多少路I/O ?](#1. select 函数可以同时支持多少路I/O ?)
-
- [1.1. server_fd 是一个整数](#1.1. server_fd 是一个整数)
- [1.2 read_fds 是什么?](#1.2 read_fds 是什么?)
- [1.3 read_fds 赋值.](#1.3 read_fds 赋值.)
- [2. 使用select 的注意事项.](#2. 使用select 的注意事项.)
-
- [2.1 timeout 值必需每次都要初始化.](#2.1 timeout 值必需每次都要初始化.)
- [2.2 fd_set 值必需每次都要初始化.](#2.2 fd_set 值必需每次都要初始化.)
0: I/O 多路复用是什么意思 ??
如果一个程序需要用到多个I/O, 例如从键盘读数, 从3个socket 中读数,这就是4路I/O 读操作.
因为对I/O 的读写, read, write 是阻塞的, 当条件未满足时会被挂起, 为了保证各I/O 互相独立,
通常用多线程来应对各I/O 的读写. 对每一个I/O 都启动一个线程来进行读写.
那么能不能用一个线程来监测多个I/O 呢, 有数据了或可以写了再对不同的fd 操作,
这就是select 函数出场的时机. 就是一个线程看管多个I/O
于是我们明白
I/O多路复用, 就是多个I/O, 复用同一个线程的意思. 复用就是重复使用.
所以这种简化, 让人容易迷糊, 你会问 "谁复用?", "I/O 复用是什么意思?" "什么叫I/O复用"
你还不如直接说, "I/O多路复用" 就是 "多路I/O只用一个线程监测".
前者说得高大上不明不白, 就是词不达意, 后者简单直接一目了然.
1. select 函数可以同时支持多少路I/O ?
谁说了也不好使, 我从网上抄了一段代码, 我们调试一下看看吧.
测试代码:
cpp
$ cat main.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_CLIENTS 100 // 定义最大客户端数量,远大于16
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
int max_fd;
int client_sockets[MAX_CLIENTS] = {0}; // 存储所有客户端套接字
fd_set read_fds; // 用于存储当前需要监控的文件描述符集合
// 创建服务器套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置套接字选项,允许重用地址
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
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);
// 初始化客户端套接字数组
for (int i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = 0;
}
// 主循环
while (1) {
// 清空读描述符集合
FD_ZERO(&read_fds);
// 将服务器套接字添加到集合中
FD_SET(server_fd, &read_fds);
max_fd = server_fd;
// 将所有活跃的客户端套接字添加到集合中
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] > 0) {
FD_SET(client_sockets[i], &read_fds);
if (client_sockets[i] > max_fd) {
max_fd = client_sockets[i];
}
}
}
// 调用 select,阻塞等待事件
int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if (activity < 0) {
perror("select error");
break;
}
// 检查是否有新连接到来
if (FD_ISSET(server_fd, &read_fds)) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
continue;
}
printf("New connection, socket fd is %d\n", new_socket);
// 找到一个空闲的客户端槽位
int i;
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, rejecting new connection\n");
close(new_socket);
}
}
// 检查所有客户端套接字是否有数据可读
for (int i = 0; i < MAX_CLIENTS; i++) {
int sock = client_sockets[i];
if (sock > 0 && FD_ISSET(sock, &read_fds)) {
char buffer[BUFFER_SIZE] = {0};
int valread = read(sock, buffer, BUFFER_SIZE - 1);
if (valread <= 0) {
// 客户端断开连接
printf("Client disconnected, socket fd %d\n", sock);
close(sock);
client_sockets[i] = 0;
} else {
// 收到数据,回显给客户端
printf("Received: %s from client %d\n", buffer, i);
write(sock, buffer, strlen(buffer));
}
}
}
}
return 0;
}
调试:
1.1. server_fd 是一个整数
(gdb) p server_fd
$1 = 3
只所以server_fd 为3, 是因为0,1,2已经被系统占用了, 懂的都懂.
1.2 read_fds 是什么?
(gdb) p read_fds
$2 = {
__fds_bits = {0 <repeats 16 times>}
}
(gdb) ptype read_fds
type = struct {
__fd_mask __fds_bits[16];
}
(gdb) ptype __fd_mask
type = long
原来read_fds 是一个16个长整形数.
当时看到16, 就朦胧的联想到是不是只能检测16个I/O呢? 实际上不是这意思.
16位长整形,在64位机器上是1024个bit位, 它能够监测1024个I/O
下面解释为什么bit位就对应着fd.
1.3 read_fds 赋值.
FD_SET(server_fd, &read_fds);
(gdb) p read_fds
$3 = {
__fds_bits = {8, 0 <repeats 15 times>}
}
server_fd 为3, read_fds 在bit3位置置1,形成8.
FD_SET(client_sockets[i], &read_fds);
(gdb) p client_sockets[i]
$6 = 4
(gdb) p read_fds
$7 = {
__fds_bits = {24, 0 <repeats 15 times>}
}
24==0x18, 8是bit3的1, 0x10 是bit4的1, 对应client_socket[0]的描述符4
至此我们就明白了, 你监测的fd, 要转换位bitmap 上的对应的位置1.
至此, select 的工作原理就算说明白了.
所以说,64位机器上最多监测1024个描述符.
2. 使用select 的注意事项.
如果select 是在一个循环中调用, 注意.
2.1 timeout 值必需每次都要初始化.
否则你就搞不对.
测试程序没有使用timeout, 所以也就没有值的初始化问题了.
如果你使用了timeout, 则执行完select 后, 其值 timeout.tv_sec, timeout.tv_usec
就都变成0 了, 不初始化, 意味着第二次调用时其值为0,调用select时会立即返回超时.
这是我测试2秒超时, 程序一运行, 程序哗哗的打印超时发现的.
这里timeout 即是一个输入参数,也是一个输出参数,传递的是timeout地址.
说实话,这种设计不太好,容易让人犯错误,不过知道了也就无所谓了.
2.2 fd_set 值必需每次都要初始化.
否则你就读不到数据.
这里也是一个坑, 因为select 函数也是传递的fd_set的地址,它是输入参数,也是输出参数, 当select 返回时,保留的是准备就绪的描述符集.
而不再是你感兴趣的描述符集合了, 如果在循环中不初始化,你将得不到预期结果.
这就是参数即当输入,也当输出的风险, 我发现它.
是我用select 监测键盘输入, 我设置超时1秒,2秒,就不能从键盘获取输入,而设置超时
5,6,7,8秒就总能从键盘接受到数据, 为什么?
因为设置2秒钟超时, 我启动程序后,2秒内来不及从键盘输入字符. 例如输入hello还要敲回车,
结果超时,结果read_fds 此时是空的,结果再进入循环调用select, 由于read_fds 没有初始化,将不监测键盘, 所以收不到键盘输入.
而5,6,7,8秒, 我有足够时间反应,启动程序后又输入测试字符加回车键,select 执行收到字符
将返回准备好的read_fds, 这跟初始化的read_fds 数值是相等的, 都是bit1 置1,
所以下一轮执行select, read_fds 未进行初始化还是能接受到数据,
如果设置都正确, 别说2秒, 0.2秒,0.02秒超时都是可以的.
这次经历让我认识到了初始化的重要性.
这是函数参数设计的缺陷,即当输入也当输出,但好处是节省了参数个数.
这也无所谓好赖,必需要符合原设计者的约定. 这就是审题的必要性了. 所以对于一个新的函数,新的接口要了解其用途,这是学习成本.
所以若非必要,我们也不愿意学习新接口.
用select函数很久了,也只是用用而已,也没碰到什么问题,没有认真想过,
这次再用,自己手写时,碰到了一点问题,认真的思考了一下,
感觉算是搞懂了, 写篇博客吧, 也没多少内容, 就是select 函数本身,起名就叫select 函数详解吧.
连个函数原型都没有写也叫详解吗? 那些基本的东西大家搜一下就行了,我这就忽略了.
这里侧重的是实现原理和注意事项.