【Linux开发】I/O 复用:select 模型

一、为什么需要 I/O 复用?

1.1 多进程服务器的缺点

之前我们实现过多进程并发服务器:每个客户端连接就 fork 一个子进程来处理。这种方式虽然能实现并发,但存在明显问题:

  • 资源开销大:每个进程都有独立的地址空间,创建进程需要大量内存和时间。
  • 进程数量有限:系统能同时运行的进程数受限于内存和 CPU,几百个进程就可能让系统不堪重负。
  • 上下文切换频繁:大量进程切换会降低 CPU 效率。

一句话:多进程服务器"重",不适合高并发场景(如成千上万个客户端)。

1.2 I/O 复用的核心思想

用一个进程(线程)同时监视多个文件描述符,当其中任何一个有 I/O 事件(可读、可写、异常)时,就通知程序去处理。

就像餐厅里一个服务员同时照看多张桌子,哪桌客人举手(有事件)就过去服务,而不是每桌配一个服务员。

1.3 I/O 复用的优势

  • 单进程处理多个连接,节省内存和 CPU。
  • 无进程/线程创建销毁开销
  • 易于管理 ,代码逻辑相对简单。

二、select 函数模型

2.1 文件描述符集合 fd_set

select 使用 fd_set 结构体来保存一组待监视的文件描述符(socket)。fd_set 内部是一个位图(bitset),每一位代表一个文件描述符,最多支持 FD_SETSIZE(通常为 1024)个。

操作 fd_set 的四个宏:

作用
FD_ZERO(fd_set *set) 将集合清空(所有位归零)
FD_SET(int fd, fd_set *set) fd 加入集合(对应位置1)
FD_CLR(int fd, fd_set *set) fd 从集合中移除(对应位置0)
FD_ISSET(int fd, fd_set *set) 判断 fd 是否仍在集合中(返回非0表示在)

2.2 select 函数原型

c 复制代码
#include <sys/select.h>
#include <sys/time.h>

int select(int maxfd, fd_set *readset, fd_set *writeset,
           fd_set *exceptset, struct timeval *timeout);
  • maxfd :监视的最大文件描述符 +1(因为 select 会遍历 0 ~ maxfd-1)。
  • readset:监视"可读"事件(有数据可读、连接请求到达、连接关闭等)。
  • writeset:监视"可写"事件(缓冲区有空闲可写)。
  • exceptset:监视"异常"事件(一般不用,传 NULL)。
  • timeout:超时时间,结构体如下:
c 复制代码
struct timeval {
    long tv_sec;   // 秒
    long tv_usec;  // 微秒
};

返回值

  • >0:发生事件的文件描述符数量。
  • 0:超时,没有事件发生。
  • -1:出错。

2.3 select 的工作流程

  1. 初始化一个 fd_set,加入要监视的套接字(如监听套接字 serv_sock)。
  2. 调用 select,程序阻塞等待。
  3. select 返回时,readset 等集合会被修改,只保留那些发生事件的 fd
  4. 遍历所有可能 fd,用 FD_ISSET 检查哪个 fd 就绪。
  5. 处理该 fd 上的事件(accept、read、write)。
  6. 重复步骤 2~5。

三、基于 select 的回声服务器实现

下面我们实现一个单进程、非阻塞、支持多客户端的回声服务器(收到什么就返回什么)。

3.1 服务器代码(完整注释)

c 复制代码
// 包含标准输入输出库,用于打印错误信息
#include <stdio.h>
// 标准库,用于 exit() 退出程序
#include <stdlib.h>
// 字符串操作库,用于 memset、strlen 等
#include <string.h>
// UNIX 标准函数,用于 close、read、write
#include <unistd.h>
// 网络地址转换函数,如 inet_ntoa、htonl 等
#include <arpa/inet.h>
// socket 相关函数和结构体
#include <sys/socket.h>
// timeval 结构体,用于 select 超时
#include <sys/time.h>
// select 函数及相关宏
#include <sys/select.h>

#define BUF_SIZE 100  // 消息缓冲区大小

// 错误处理函数声明
void error_handling(char *buf);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;   // 服务器监听套接字 和 客户端连接套接字
    struct sockaddr_in serv_adr, clnt_adr;     // 服务器地址结构 和 客户端地址结构
    
    // 超时时间结构体,用于 select
    struct timeval timeout;
    // reads: 原始监视集合(所有需要监视的套接字)
    // cpy_reads: 每次 select 调用时使用的副本(select 会修改它)
    fd_set reads, cpy_reads;

    // 客户端地址结构大小(用于 accept)
    socklen_t adr_sz;
    
    // fd_max: 当前最大的文件描述符值(用于 select 第一个参数)
    // str_len: 读取数据的长度
    // fd_num: select 返回的就绪文件描述符数量
    int fd_max, str_len, fd_num, i;
    
    // 数据缓冲区
    char buf[BUF_SIZE];

    // 检查命令行参数:需要端口号
    if (argc != 2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    // ========== 1. 创建 TCP 套接字 ==========
    // PF_INET: IPv4 协议族,SOCK_STREAM: 面向连接的 TCP,0: 自动选择协议
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    // 初始化服务器地址结构,全部置零
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;                     // IPv4 地址族
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);      // 本机任意可用 IP
    serv_adr.sin_port = htons(atoi(argv[1]));          // 命令行指定的端口,转网络字节序

    // ========== 2. 绑定套接字到地址 ==========
    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");

    // ========== 3. 开始监听 ==========
    // 第二个参数 5 表示等待连接队列的最大长度
    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    // ==========******** 4. 初始化文件描述符集合 ********==========
    // FD_ZERO: 将 reads 集合所有位清零
    FD_ZERO(&reads);
    // FD_SET: 将监听套接字 serv_sock 加入到 reads 集合中
    FD_SET(serv_sock, &reads);
    // 当前最大文件描述符就是监听套接字(因为还没有客户端连接)
    fd_max = serv_sock;

    // ========== 5. 主循环:反复调用 select 处理 I/O 事件 ==========
    while (1)
    {
        // 每次 select 调用前,将原始集合复制一份给 cpy_reads
        // 因为 select 会修改传入的集合(只保留就绪的 fd)
        cpy_reads = reads;

        // 设置超时时间:5 秒 + 5000 微秒(即 5.005 秒)
        // 如果传 NULL,select 会一直阻塞直到有事件
        timeout.tv_sec = 5;
        timeout.tv_usec = 5000;

        // ========== 6. 调用 select 函数 ==========
        // 参数1: 最大文件描述符 + 1(告诉内核要检查 0 到 fd_max 这些 fd)
        // 参数2: 读集合(我们只关心可读事件)
        // 参数3,4: 写集合和异常集合,这里不关心,传 NULL
        // 参数5: 超时时间
        // 返回值: 就绪的文件描述符数量,-1 表示出错,0 表示超时
        fd_num = select(fd_max + 1, &cpy_reads, NULL, NULL, &timeout);

        if (fd_num == -1)  // 出错,退出循环
            break;
        if (fd_num == 0)   // 超时,没有事件发生,继续下一次循环
            continue;

        // ========== 7. 遍历所有可能的文件描述符(从 0 到 fd_max) ==========
        for (i = 0; i < fd_max + 1; i++)
        {
            // FD_ISSET: 判断文件描述符 i 是否在 cpy_reads 集合中(即是否就绪)
            if (FD_ISSET(i, &cpy_reads))
            {
                // 情况1: 是监听套接字 serv_sock 就绪 -> 有新的客户端连接请求
                if (i == serv_sock)
                {
                    adr_sz = sizeof(clnt_adr);
                    // 接受连接,返回新的客户端套接字
                    clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                    // 将新客户端套接字加入 reads 集合(以后也要监视它)
                    FD_SET(clnt_sock, &reads);
                    // 如果新套接字的值大于当前 fd_max,则更新 fd_max
                    if (fd_max < clnt_sock)
                        fd_max = clnt_sock;
                    printf("connected client: %d \n", clnt_sock);
                }
                // 情况2: 是普通客户端套接字就绪 -> 有数据到达或者连接关闭
                else
                {
                    // 读取数据(尝试读取最多 BUF_SIZE 字节)
                    str_len = read(i, buf, BUF_SIZE);
                    if (str_len == 0)   // read 返回 0 表示客户端正常关闭连接
                    {
                        // 从监视集合中移除该客户端套接字
                        FD_CLR(i, &reads);
                        // 关闭套接字
                        close(i);
                        printf("closed client: %d \n", i);
                    }
                    else                // 收到数据
                    {
                        // 将收到的数据原样写回(回声)
                        write(i, buf, str_len);
                    }
                }
            }
        }
    }
    // 关闭监听套接字(实际上由于无限循环,不会执行到这里)
    close(serv_sock);
    return 0;
}

// 错误处理函数:输出错误信息并退出程序
void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

3.2 代码关键点解析

关键点 解释
fd_set readscpy_reads reads 保存所有需要监视的套接字;每次 select 前复制一份到 cpy_reads,因为 select 会修改传入的集合。
fd_max select 第一个参数是最大 fd +1,所以需要动态跟踪当前最大 fd。
select 超时 这里设为 5 秒 + 5 毫秒,避免无限阻塞,但实际服务器中通常传 NULL 永久等待。
FD_ISSET(i, &cpy_reads) 判断 fd i 是否就绪。
新连接处理 serv_sock 调用 accept,获得新客户端套接字,加入 reads 并更新 fd_max
数据读取 对普通客户端套接字调用 read,如果返回 0 表示对方关闭,则关闭该套接字并从集合中移除;否则将数据原样写回(回声)。

3.3 客户端代码(标准回声客户端)

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;                       // 客户端套接字
    char message[BUF_SIZE];         // 发送和接收缓冲区
    int str_len;                    // 实际接收的字节数
    struct sockaddr_in serv_adr;    // 服务器地址结构

    // 检查命令行参数:需要服务器 IP 和端口
    if (argc != 3) {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    // ========== 1. 创建套接字 ==========
    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1)
        error_handling("socket() error");

    // ========== 2. 初始化服务器地址结构 ==========
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;                 // IPv4
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]); // 点分十进制 IP 转整数
    serv_adr.sin_port = htons(atoi(argv[2]));      // 端口转网络字节序

    // ========== 3. 连接服务器 ==========
    if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect() error!");
    else
        puts("Connected...........");

    // ========== 4. 循环发送消息并接收回声 ==========
    while (1)
    {
        // 提示用户输入
        fputs("Input message(Q to quit): ", stdout);
        // 从标准输入读取一行,存入 message
        fgets(message, BUF_SIZE, stdin);

        // 如果输入 "q\n" 或 "Q\n",退出循环
        if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
            break;

        // 发送消息给服务器(不包括结尾的 '\0')
        write(sock, message, strlen(message));
        // 接收服务器返回的回声
        str_len = read(sock, message, BUF_SIZE - 1);
        // 在字符串末尾添加 '\0',确保正确打印
        message[str_len] = 0;
        printf("Message from server: %s", message);
    }

    // 关闭套接字,结束程序
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

四、运行与测试

4.1 编译

bash 复制代码
gcc select_server.c -o server
gcc client.c -o client

4.2 运行服务器

bash 复制代码
./server 9190

4.3 运行多个客户端

bash 复制代码
# 终端2
./client 127.0.0.1 9190

# 终端3
./client 127.0.0.1 9190

4.4 测试结果

  • 每个客户端可以独立发送消息,服务器会原样返回(回声)。
  • 服务器端打印连接和关闭信息。
  • 服务器只使用一个进程,却能同时服务多个客户端。

五、select 的局限与改进

5.1 优点

  • 跨平台(几乎所有操作系统都支持)。
  • 代码简单,易于理解。

5.2 缺点

缺点 说明
文件描述符上限 默认 1024,虽可修改但效率会下降。
效率低 每次调用都要把所有 fd 从用户态拷贝到内核态,且内核需要遍历所有 fd。
无法动态修改集合 每次循环都要重新设置集合(因为 select 会修改)。
O(n) 扫描 应用程序需要遍历所有 fd 检查就绪位,复杂度 O(n)。

5.3 改进方案

  • poll:去除了 1024 限制,但仍是 O(n) 扫描。
  • epoll(Linux 特有):事件驱动,只返回就绪的 fd,效率高,适合海量连接。
  • kqueue(BSD 系统)。

六、总结

  • I/O 复用 用一个进程监视多个套接字,避免多进程/多线程的高昂开销。
  • select 是最简单的 I/O 复用模型,通过 fd_set 管理监视对象,通过 select 等待事件。
  • 实现步骤:
    1. 创建监听套接字,加入 fd_set
    2. 循环调用 select
    3. 根据 FD_ISSET 判断事件类型:
      • 监听套接字 → accept 新连接,加入集合。
      • 客户端套接字 → read 数据,返回 0 表示断开,否则处理数据。
相关推荐
小肝一下2 小时前
每日两道力扣,day6
数据结构·c++·算法·leetcode·双指针·hot100
ambition202422 小时前
【算法详解】飞机降落问题:DFS剪枝解决调度问题
c语言·数据结构·c++·算法·深度优先·图搜索算法
EnglishJun2 小时前
ARM嵌入式学习(十八)--- Linux的内核编译和启动
linux·运维·学习
I Promise342 小时前
C++ 基础数据结构与 STL 容器详解
开发语言·数据结构·c++
魔都吴所谓2 小时前
【Ubuntu】离线环境下Git LFS(deb包)安装与验证完整教程
linux·git·ubuntu
w6100104662 小时前
CKA-2026-StorageClass
linux·运维·服务器·cka·storageclass
旖-旎2 小时前
链表(两两交换链表中的节点)(2)
数据结构·c++·学习·算法·链表·力控
吕司2 小时前
Linux线程同步
linux·服务器·开发语言