引言

在传统的多进程/多线程服务器模型中,每一个客户端连接都需要一个独立的进程或线程来处理。当客户端数量较少时,这种方式工作良好。但当并发连接数达到成千上万时,进程/线程的创建、销毁和切换开销会急剧增加,导致服务器性能严重下降。

I/O 多路复用就是为了解决这个问题而生的。它的核心思想是:用一个线程同时监控多个文件描述符,只处理那些已经就绪的(有数据可读、可写等)描述符

I/O 多路复用允许单线程同时处理多个 I/O 操作,避免了为每个连接创建独立进程/线程的开销。Linux 系统提供了三种 I/O 多路复用机制:selectpoll 和 epoll。今天,我们将从最基础的 select 开始讲解。


第一部分:select 函数基础

一、函数原型

#include <sys/select.h>
#include <sys/time.h>

int select(int nfds, 
           fd_set *readfds, 
           fd_set *writefds, 
           fd_set *exceptfds, 
           struct timeval *timeout);

二、参数详解

参数 类型 作用
nfds int 监控的最大文件描述符值 + 1
readfds fd_set* 监控读事件(数据可读、新连接到达)
writefds fd_set* 监控写事件(缓冲区空闲、可发送数据)
exceptfds fd_set* 监控异常事件(通常设为 NULL)
timeout timeval* 超时时间(NULL表示永久阻塞)

三、返回值

返回值 含义
>0 就绪的文件描述符数量
=0 超时(没有就绪的描述符)
=-1 调用失败(可通过 errno 获取错误码)

四、timeout 结构体

struct timeval {
    time_t      tv_sec;   // 秒
    suseconds_t tv_usec;  // 微秒(1秒 = 1,000,000微秒)
};

// 使用示例
struct timeval tv;
tv.tv_sec = 5;      // 等待5秒
tv.tv_usec = 0;     // 0微秒

timeout 参数的特殊值:

含义
NULL 永久阻塞,直到有描述符就绪
tv_sec = 0, tv_usec = 0 非阻塞轮询,立即返回
tv_sec > 0 等待指定时间后超时

五、fd_set 结构体

fd_set 是一个位掩码集合,用于存储要监控的文件描述符。

// fd_set 的内部实现(简化理解)
typedef struct {
    long int __fds_bits[__FD_SETSIZE / (8 * sizeof(long int))];
} fd_set;

// 常量
#define FD_SETSIZE 1024   // select 能监控的最大描述符数量

相关操作宏:

作用
FD_ZERO(fd_set *set) 清空集合
FD_SET(int fd, fd_set *set) 将 fd 加入集合
FD_CLR(int fd, fd_set *set) 将 fd 从集合中移除
FD_ISSET(int fd, fd_set *set) 检测 fd 是否在集合中(就绪)

重要提醒: select 返回后会修改传入的 fd_set 集合,只保留就绪的描述符。因此每次调用 select 前都需要重新设置监控集合。


第二部分:select 的就绪条件

一、读就绪条件

场景 说明
套接字接收缓冲区有数据可读 recv 不会阻塞
监听套接字有新连接到达 accept 不会阻塞
对方关闭连接(收到 FIN) recv 返回 0
发生异常 需要进一步处理

二、写就绪条件

场景 说明
套接字发送缓冲区有空间 send 不会阻塞
连接已建立 可以发送数据

第三部分:select 的基本使用

一、监控标准输入

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>

int main() {
    fd_set readfds;
    struct timeval tv;
    char buffer[128];
    
    while (1) {
        // 1. 清空集合并添加标准输入
        FD_ZERO(&readfds);
        FD_SET(STDIN_FILENO, &readfds);
        
        // 2. 设置超时时间(5秒)
        tv.tv_sec = 5;
        tv.tv_usec = 0;
        
        // 3. 监控读事件
        int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);
        
        if (ret == -1) {
            perror("select error");
            break;
        } else if (ret == 0) {
            printf("5秒内没有输入,超时!\n");
        } else {
            // 检查标准输入是否就绪
            if (FD_ISSET(STDIN_FILENO, &readfds)) {
                int n = read(STDIN_FILENO, buffer, sizeof(buffer) - 1);
                if (n > 0) {
                    buffer[n] = '\0';
                    printf("输入内容: %s", buffer);
                }
            }
        }
    }
    
    return 0;
}

二、nfds 参数详解

nfds 参数的含义是:所有监控描述符中的最大值 + 1

// 示例:监控描述符 3、5、7
// 最大描述符是 7,所以 nfds = 7 + 1 = 8

// 示例:只监控标准输入(描述符 0)
// nfds = 0 + 1 = 1

为什么需要这个参数?

内核需要知道要检测哪些描述符。如果 nfds 设置过小,可能导致部分描述符未被检测;设置过大则浪费性能(内核会检查到 nfds-1)。


第四部分:select 实现多客户端服务器

一、数据结构设计

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>

#define PORT 6000
#define MAX_CLIENTS 1024
#define BUFFER_SIZE 128

// 客户端描述符数组(管理所有连接)
int client_fds[MAX_CLIENTS];

// 初始化描述符数组
void init_fds() {
    for (int i = 0; i < MAX_CLIENTS; i++) {
        client_fds[i] = -1;  // -1 表示空闲
    }
}

// 添加描述符
void add_fd(int fd) {
    for (int i = 0; i < MAX_CLIENTS; i++) {
        if (client_fds[i] == -1) {
            client_fds[i] = fd;
            break;
        }
    }
}

// 移除描述符
void remove_fd(int fd) {
    for (int i = 0; i < MAX_CLIENTS; i++) {
        if (client_fds[i] == fd) {
            client_fds[i] = -1;
            break;
        }
    }
}

二、创建监听套接字

int create_listen_socket() {
    // 1. 创建套接字
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket error");
        return -1;
    }
    
    // 2. 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    // 3. 绑定地址和端口
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    
    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind error");
        close(listen_fd);
        return -1;
    }
    
    // 4. 监听
    if (listen(listen_fd, 5) == -1) {
        perror("listen error");
        close(listen_fd);
        return -1;
    }
    
    printf("服务器启动成功,端口:%d\n", PORT);
    return listen_fd;
}

三、构建监控集合

// 将客户端描述符数组中的有效描述符加入 fd_set
int build_fdset(fd_set* readfds, int* max_fd) {
    FD_ZERO(readfds);
    
    int max = -1;
    for (int i = 0; i < MAX_CLIENTS; i++) {
        if (client_fds[i] != -1) {
            FD_SET(client_fds[i], readfds);
            if (client_fds[i] > max) {
                max = client_fds[i];
            }
        }
    }
    
    *max_fd = max;
    return 0;
}

四、处理就绪事件

void handle_events(fd_set* readfds, int listen_fd) {
    // 1. 检查监听套接字(新连接)
    if (FD_ISSET(listen_fd, readfds)) {
        struct sockaddr_in client_addr;
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
        
        if (client_fd != -1) {
            printf("新客户端连接,fd=%d\n", client_fd);
            add_fd(client_fd);
        }
    }
    
    // 2. 检查所有客户端连接套接字(数据收发)
    for (int i = 0; i < MAX_CLIENTS; i++) {
        int fd = client_fds[i];
        if (fd == -1) continue;
        
        if (FD_ISSET(fd, readfds)) {
            char buffer[BUFFER_SIZE];
            int n = recv(fd, buffer, BUFFER_SIZE - 1, 0);
            
            if (n <= 0) {
                // 客户端关闭连接
                printf("客户端 %d 断开连接\n", fd);
                close(fd);
                remove_fd(fd);
            } else {
                buffer[n] = '\0';
                printf("收到数据 (fd=%d): %s\n", fd, buffer);
                send(fd, "OK", 2, 0);
            }
        }
    }
}

五、主循环

int main() {
    // 初始化
    init_fds();
    
    // 创建监听套接字
    int listen_fd = create_listen_socket();
    if (listen_fd == -1) {
        exit(1);
    }
    
    // 将监听套接字加入管理
    add_fd(listen_fd);
    
    fd_set readfds;
    struct timeval tv;
    
    while (1) {
        // 1. 构建监控集合
        int max_fd;
        build_fdset(&readfds, &max_fd);
        
        // 2. 设置超时(每次都要重新设置)
        tv.tv_sec = 2;
        tv.tv_usec = 0;
        
        // 3. 调用 select
        int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv);
        
        if (ret == -1) {
            perror("select error");
            break;
        } else if (ret == 0) {
            // 超时,可执行一些定时任务
            // printf("select timeout\n");
            continue;
        }
        
        // 4. 处理就绪事件
        handle_events(&readfds, listen_fd);
    }
    
    close(listen_fd);
    return 0;
}

第五部分:select 的注意事项

一、每次调用前需要重置集合

select 返回后会修改 fd_set,只保留就绪的描述符。因此,每次调用 select 前都需要重新构建监控集合。

// 正确做法:每次循环都重新构建
while (1) {
    FD_ZERO(&readfds);
    FD_SET(listen_fd, &readfds);
    // ... 添加其他描述符
    select(...);
}

// 错误做法:只初始化一次
FD_ZERO(&readfds);
FD_SET(listen_fd, &readfds);
while (1) {
    select(...);  // 第一次调用后 readfds 被修改
    // 后续循环中 readfds 已经不是原来的集合了
}

二、超时时间需要重置

select 返回后,timeout 结构体可能会被修改(剩余时间)。因此每次调用前都需要重新设置。

// 正确做法
while (1) {
    tv.tv_sec = 2;
    tv.tv_usec = 0;
    select(..., &tv);
}

三、select 的最大描述符限制

select 默认最多支持 FD_SETSIZE(通常为 1024)个文件描述符。

# 查看当前系统的文件描述符限制
ulimit -n

# 临时修改限制
ulimit -n 1000000

如果需要处理超过 1024 个连接,应该选择 epoll

四、缓冲区截断问题

// 如果接收缓冲区太小,一次只能读取部分数据
char buffer[1];  // 只能读1个字节
recv(fd, buffer, 1, 0);
// 剩余数据还在接收缓冲区中,下次 select 仍会报告该描述符就绪

当客户端发送 "hello"(5个字节),而服务器缓冲区只有1字节时,需要多次 select 才能读完所有数据。


第六部分:select 的优缺点总结

优点

优点 说明
跨平台 几乎所有操作系统都支持
简单易懂 API 相对简单,适合学习入门
事件类型丰富 同时支持读、写、异常事件

缺点

缺点 说明
描述符数量限制 默认只能监控 1024 个描述符
每次都需要重新构建集合 效率较低
不可知具体哪个描述符就绪 需要遍历所有描述符检查
缺乏边缘触发模式 只有水平触发
描述符集合会被修改 需要保存备份

总结

一、select 使用流程

二、核心函数速查表

函数/宏 作用
select() 监控多个文件描述符的状态
FD_ZERO() 清空集合
FD_SET() 将描述符加入集合
FD_CLR() 将描述符从集合中移除
FD_ISSET() 检测描述符是否在集合中

三、使用注意事项

注意点 说明
每次调用前重置集合 select 会修改原始集合
每次调用前重置超时 timeout 可能被修改
nfds 是最大值+1 否则可能漏检或浪费性能
遍历检查所有描述符 不能仅依赖返回值

select 是最基础的 I/O 多路复用机制,理解它的工作原理对于学习更高级的 epoll 非常重要。

学习建议:

  1. 动手实现 select 服务器,理解其工作流程

  2. 对比多进程/多线程模型,体会 I/O 多路复用的优势

  3. 理解 select 的局限性,为学习 epoll 打好基础

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐