select系统调用及示例

我们继续学习 Linux 系统编程中的重要函数。这次我们介绍 select 函数,它是一种经典的 I/O 多路复用机制,允许一个进程监视多个文件描述符,等待其中任何一个或多个文件描述符变为“就绪”状态(例如可读、可写或发生异常)。

注意:虽然 select 功能强大且历史悠久,但在处理大量文件描述符时,poll 和更现代的 epoll (Linux 特有) 通常性能更好。不过,select 因其可移植性(在多种 Unix 系统上都可用)和教学价值,仍然是需要了解的重要函数。


1. 函数介绍

select 是一个 Linux 系统调用(实际上在很多类 Unix 系统上都可用),用于实现 I/O 多路复用 (I/O multiplexing)。它的核心思想是让进程能够同时检查多个文件描述符(如套接字、管道、终端等)的状态,看它们是否准备好进行 I/O 操作(例如读取、写入),而无需对每个文件描述符都进行阻塞式等待。

在没有 select(或 pollepoll)的情况下,如果一个程序需要同时处理多个网络连接或文件,它可能需要创建多个线程或进程,或者在一个文件描述符上阻塞等待,这会非常低效或复杂。select 允许一个线程/进程在一个调用中“监听”所有感兴趣的文件描述符,当其中任何一个准备好时,select 返回,程序就可以处理那个就绪的文件描述符。

你可以把它想象成一个“服务员”,同时照看多张餐桌(文件描述符)。服务员不需要一直站在某一张餐桌旁等客人点菜(数据),而是可以走一圈看看哪张餐桌的客人举手了(数据就绪),然后去为那张餐桌服务。


2. 函数原型

#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 会检查 readfdswritefdsexceptfds 这三个集合中列出的文件描述符的状态。
  • 等待就绪: 调用 select 的进程会阻塞(挂起),直到以下情况之一发生:
    1. readfdswritefdsexceptfds 中指定的至少一个文件描述符变为“就绪”状态。
    2. 调用被信号中断(返回 -1,并设置 errno 为 EINTR)。
    3. 达到指定的超时时间 timeout(如果 timeout 不为 NULL)。
  • 返回就绪数量: 当 select 返回时,它会报告有多少个文件描述符已就绪。
  • 更新集合关键点select 会修改 readfdswritefdsexceptfds 这三个集合作为输出。调用返回后,这些集合中只保留那些已就绪的文件描述符,所有未就绪的文件描述符都会从集合中被清除。因此,如果需要在下一次 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 == NULLselect 会无限期阻塞,直到至少一个文件描述符就绪或被信号中断。
    • timeout->tv_sec == 0 && timeout->tv_usec == 0select 执行非阻塞检查,立即返回,报告当前有多少文件描述符已就绪。
    • timeout->tv_sec > 0 || timeout->tv_usec > 0select 最多阻塞 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 每次调用都需要传递整个文件描述符集合的开销。
  • readwrite: 在 select 返回某个文件描述符就绪后,通常会调用 read 或 write 来执行实际的 I/O 操作。

8. 示例代码

示例 1:监视标准输入和一个管道

这个例子演示如何使用 select 同时监视标准输入(键盘)和一个管道的读端,看哪个先有数据可读。

#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;
}

代码解释:

  1. 使用 pipe() 创建一个管道。
  2. 计算需要监视的最大文件描述符 max_fdSTDIN_FILENO 和 pipefd[0] 中的较大者加 1)。
  3. 进入主循环。
  4. 关键: 在每次 select 调用前,使用 FD_ZERO(&readfds) 清空 fd_set
  5. 使用 FD_SET 将 STDIN_FILENO 和 pipefd[0] 添加到 readfds 集合中。
  6. 设置 timeout 结构体为 5 秒。
  7. 调用 select(max_fd, &readfds, NULL, NULL, &timeout)。我们只关心可读性,所以 writefds 和 exceptfds 都是 NULL
  8. 检查 select 的返回值:
    • -1:错误(检查 errno 是否为 EINTR)。
    • 0:超时。
    • 0:就绪的文件描述符数量。
  9. 如果有文件描述符就绪(activity > 0),使用 FD_ISSET 检查 STDIN_FILENO 和 pipefd[0] 是否在修改后的 readfds 集合中。
  10. 对于就绪的文件描述符,调用 read 读取数据。
  11. 循环继续或根据条件(如超时)退出。
  12. 最后关闭管道的两端。

示例 2:简单的 TCP 服务器(非阻塞 accept 和客户端 socket)

这个例子演示如何在 TCP 服务器中使用 select 来同时监听监听套接字(用于接受新连接)和已建立连接的客户端套接字(用于接收数据)。

#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;
}

代码解释:

  1. 创建、绑定、监听 TCP 套接字。
  2. 初始化一个 client_socket 数组,用于存储已建立连接的客户端套接字。用 0 表示空闲槽位。
  3. 进入主循环。
  4. 每次循环开始,使用 FD_ZERO(&readfds) 清空 fd_set
  5. 使用 FD_SET(server_fd, &readfds) 将监听套接字加入监视集合。
  6. 遍历 client_socket 数组,将所有有效的(非零)客户端套接字也加入 readfds 集合。
  7. 在添加文件描述符的过程中,动态计算并更新 max_sd(当前监视的最大文件描述符)。
  8. 设置 select 的超时时间(这里设为 30 秒,避免无限期阻塞)。
  9. 调用 select(max_sd + 1, &readfds, NULL, NULL, &timeout)
  10. 检查 select 返回值 activity
  11. 如果 FD_ISSET(server_fd, &readfds) 为真,说明监听套接字就绪,调用 accept 接受新连接,并将其存入 client_socket 数组的空闲槽位。
  12. 遍历 client_socket 数组,检查每个有效的客户端套接字 sd 是否在 readfds 中(即 FD_ISSET(sd, &readfds) 为真)。
  13. 如果某个客户端套接字就绪,调用 read 读取数据。
  14. 如果 read 返回 0,表示客户端关闭了连接,关闭该套接字,并将 client_socket 数组中对应的项标记为 0(空闲)。
  15. 如果 read 返回正数,表示读到了数据,这里简单地将其 echo 回客户端。
  16. 循环继续处理下一个事件。

这个例子展示了 select 如何在一个单线程服务器中高效地管理多个并发连接。与为每个连接创建一个线程或进程相比,select(以及更高效的 poll 和 epoll)是构建高性能网络服务器的基础技术之一。

总结:

select 是一个功能强大且历史悠久的 I/O 多路复用函数。理解其关键是掌握 fd_set 集合的操作(FD_ZEROFD_SETFD_CLRFD_ISSET)、nfds 参数的正确设置、select 返回后集合的修改行为,以及如何根据返回的就绪文件描述符数量和状态来处理相应的 I/O 操作。虽然在处理大量连接时不如 poll 或 epoll 高效,但其良好的可移植性使其在很多场景下仍然有用。

此条目发表在linux文章分类目录。将固定链接加入收藏夹。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注