66多路复用 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> 头文件中。

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) 参数的含义与取值范围
  1. int nfds

    • 作用 :指定所有被监控的文件描述符集合中最大值加 1。内核通过这个值来线性扫描哪些描述符就绪,从而提高效率。
    • 取值范围 :通常是 max_fd + 1max_fd 是所有监听描述符中最大的那个)。
  2. fd_set *readfds

    • 作用 :指向一个 fd_set 类型的对象,该对象中包含了我们关心是否可读的文件描述符集合。传入时是"我们关心的",返回时是"就绪的"。
    • 取值范围NULL 表示不关心可读事件。
  3. fd_set *writefds

    • 作用 :指向一个 fd_set 类型的对象,该对象中包含了我们关心是否可写的文件描述符集合。
    • 取值范围NULL 表示不关心可写事件。
  4. fd_set *exceptfds

    • 作用 :指向一个 fd_set 类型的对象,该对象中包含了我们关心是否发生异常的文件描述符集合。异常通常指带外数据(OOB data)到达。
    • 取值范围NULL 表示不关心异常事件。
  5. struct timeval *timeout

    • 作用 :指定 select 等待的超时时间。这是一个结构体指针,可以精确到微秒。

    • 结构体定义

      c 复制代码
      struct 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

注意事项:

  1. 参数会被修改select 返回后,readfdswritefdsexceptfdstimeout 参数的值都会被内核修改 。它们表示的是就绪的描述符集合和剩余时间。因此,每次调用 select 前都必须重新初始化这些参数。
  2. 性能问题select 采用线性扫描的方式,其效率与最大文件描述符的值 nfds 相关。当需要监视大量描述符时,性能会急剧下降。这是它被 epoll 取代的主要原因。
  3. 描述符数量限制fd_set 有大小限制,通常是 FD_SETSIZE(通常是 1024)。这意味着一个进程通过 select 最多只能同时监视 1024 个文件描述符。
  4. 无法得知具体数量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 处理错误

相关推荐
轻松Ai享生活3 小时前
5 天学习 Linux Kernel 主要原理 | Day 2:Linux 进程管理与调度
linux
轻松Ai享生活3 小时前
5 天学习 Linux Kernel 主要原理 | Day 1:Linux 内核基础与架构总览
linux
哈喽H4 小时前
centos系统的linux环境不同用户,环境变量不同如何配置?
linux·centos
Doris_LMS5 小时前
在Linux系统中安装Jenkins(保姆级别)
java·linux·jenkins·ci
轻松Ai享生活5 小时前
用户自定义的 systemd unit 文件应该放在哪里?
linux
万添裁5 小时前
1.Linux:命令提示符,history和常用快捷键
linux·运维·服务器
轻松Ai享生活5 小时前
怎么在5天内将Linux systemd学精通
linux
悲伤小伞7 小时前
Linux_网络基础
linux·服务器·c语言·网络
KingRumn7 小时前
Linux ARP老化机制/探测机制/ip neigh使用
linux·网络·tcp/ip