linux学习进展 I/O复用函数初步

前面我们已经掌握了Linux网络编程的核心基础:TCP/UDP Socket编程、线程池(半同步半异步模型)、MySQL数据库操作。在实际开发中,我们会遇到一个核心问题:一个服务器需要同时处理多个客户端连接(比如TCP服务器同时接收多个客户端的请求),如果用"一个客户端一个线程"的方式,高并发场景下会创建大量线程,导致系统资源耗尽、内核调度压力剧增。

为了解决这个问题,Linux提供了I/O复用技术------通过一个函数监听多个文件描述符(fd),当某个fd就绪(有数据可读、可写或出错)时,函数返回通知进程处理。本节课我们将学习最基础、最常用的两个I/O复用函数:select和poll,理解I/O复用的核心思想,掌握其基本用法,为后续学习高性能的epoll函数打下基础。

核心重点:掌握I/O复用的核心概念、select/poll函数的参数与用法、实操代码(实现多客户端连接监听),理解I/O复用与多线程的区别,明确其适用场景。

一、I/O复用核心认知

1. 什么是I/O复用

I/O复用(I/O Multiplexing),简单来说,就是一个进程/线程可以同时监听多个文件描述符(fd),当其中任意一个或多个fd就绪(满足可读、可写、出错条件)时,通知进程/线程去处理对应的fd。

这里的"文件描述符",不仅包括Socket的文件描述符(lfd监听fd、cfd客户端fd),还包括普通文件、管道、信号等,但其核心应用场景是网络编程(处理多客户端连接)。

2. 为什么需要I/O复用(解决什么痛点)

在学习I/O复用之前,我们处理多客户端连接的方式有两种,均存在明显缺陷:

  • 方式1:单线程同步处理------主线程accept一个客户端后,阻塞在read()/write(),无法处理其他客户端,并发为1,完全无法满足实际需求;

  • 方式2:多线程/多进程处理------一个客户端对应一个线程/进程,高并发(如千级、万级客户端)时,会创建大量线程/进程,占用大量内存(每个线程默认占用1MB栈空间),且内核调度线程的开销极大,容易导致服务器崩溃。

I/O复用的优势的是:用一个线程监听多个fd,无需创建大量线程,低资源消耗,支持高并发。其核心思想是"等待"------让内核帮忙监听多个fd,当有fd就绪时再去处理,避免进程/线程无效阻塞。

3. I/O复用的适用场景

结合Linux网络编程,I/O复用主要用于以下场景:

  1. 服务器需要同时处理多个客户端连接(如TCP服务器、HTTP服务器);

  2. 服务器需要同时处理多个I/O操作(如同时监听多个端口、同时处理读写请求);

  3. 高并发、低资源消耗的场景(替代多线程,减少内核调度压力)。

注意:I/O复用本身是"同步I/O"------虽然能监听多个fd,但当fd就绪后,仍需要进程/线程主动去读取/写入数据,不会像异步I/O那样自动完成数据读写。

4. 常用的I/O复用函数

Linux提供了3种常用的I/O复用函数,本节课学习前两种基础函数,下一节重点学习高性能的epoll:

  • select:最古老的I/O复用函数,兼容性好(跨平台),但存在fd数量限制;

  • poll:对select的改进,解决了fd数量限制,接口更简洁,但效率仍一般;

  • epoll:Linux特有,高性能、无fd数量限制,是高并发网络编程的首选(如Nginx、Redis底层均使用epoll)。

二、select函数详解(基础重点)

1. 函数原型与参数

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

// 返回值:就绪的fd数量;-1表示出错;0表示超时
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

逐个解析参数(重点,务必理解):

(1)nfds:最大文件描述符+1

select需要知道要监听的fd的范围,nfds是所有监听fd中的最大值加1。例如:监听的fd为3、5、7,最大fd是7,那么nfds=8。

原因:select内部通过数组管理fd,数组下标从0到nfds-1,因此最大fd+1才能覆盖所有监听的fd。

(2)readfds:监听"可读"事件的fd集合

fd_set是一个"文件描述符集合",本质是一个位图(bit数组),每一位对应一个fd,1表示监听该fd,0表示不监听。

作用:告诉select,我们要监听哪些fd的"可读"事件(如客户端发送数据、新客户端连接(lfd可读)、fd出错)。

注意:select调用后,readfds会被内核修改------只保留"就绪的可读fd",未就绪的fd会被置为0,因此每次调用select前,都需要重新初始化readfds。

(3)writefds:监听"可写"事件的fd集合

与readfds类似,用于监听哪些fd的"可写"事件(如fd缓冲区空闲,可以写入数据)。

如果不需要监听可写事件,可设为NULL。

(4)exceptfds:监听"异常"事件的fd集合

用于监听fd的异常事件(如fd出错、连接异常),一般用不到,设为NULL即可。

(5)timeout:超时时间

struct timeval结构体定义:

cpp 复制代码
struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒(1秒=1000000微秒)
};

timeout有三种取值方式:

  • timeout = NULL:无限等待,直到有fd就绪才返回;

  • timeout->tv_sec=0且tv_usec=0:不等待,立即返回(轮询);

  • timeout->tv_sec和tv_usec设为具体值:等待指定时间,若期间有fd就绪则立即返回,超时则返回0。

2. fd_set相关操作宏(必用)

fd_set是位图,无法直接操作,Linux提供了4个宏来操作fd集合:

bash 复制代码
// 1. 清空fd集合(将所有位设为0)
FD_ZERO(fd_set *set);

// 2. 将指定fd加入集合(将对应位设为1)
FD_SET(int fd, fd_set *set);

// 3. 将指定fd从集合中移除(将对应位设为0)
FD_CLR(int fd, fd_set *set);

// 4. 检查fd是否在集合中(判断对应位是否为1,返回非0表示在,0表示不在)
FD_ISSET(int fd, fd_set *set);

3. select函数工作流程(结合TCP服务器)

以TCP服务器监听多个客户端连接为例,select的工作流程如下:

  1. 创建监听fd(lfd),绑定端口、开始监听;

  2. 初始化readfds集合,将lfd加入集合(监听lfd的可读事件,即新客户端连接);

  3. 设置超时时间timeout;

  4. 调用select函数,阻塞等待fd就绪;

  5. select返回后,遍历所有监听的fd,用FD_ISSET判断哪个fd就绪;

  6. 若lfd就绪:调用accept()接收新客户端连接(cfd),将cfd加入readfds集合,继续监听;

  7. 若cfd就绪:调用read()读取客户端数据,处理后回复客户端;

  8. 重复步骤4-6,循环监听。

4. 实操代码:select实现多客户端监听

编写一个简单的TCP服务器,用select监听lfd和多个cfd,实现多客户端同时连接、发送数据的功能,代码有详细注释,可直接编译运行。

cpp 复制代码
#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 8888
#define MAX_FD 1024  // select默认最大fd限制(可修改内核参数)
#define BUF_SIZE 1024

int main() {
    // 1. 创建监听fd
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1) {
        perror("socket create failed");
        exit(1);
    }

    // 端口复用(避免TIME_WAIT导致端口占用)
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. 绑定端口和IP
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    serv_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind failed");
        close(lfd);
        exit(1);
    }

    // 3. 开始监听
    if (listen(lfd, 5) == -1) {
        perror("listen failed");
        close(lfd);
        exit(1);
    }
    printf("TCP服务器启动,端口%d,等待客户端连接...\n", PORT);

    // 4. 初始化select相关参数
    fd_set readfds;       // 监听可读事件的fd集合
    int max_fd = lfd;     // 最大fd,初始为lfd

    // 循环监听
    while (1) {
        // 每次调用select前,重新初始化readfds(因为select会修改它)
        FD_ZERO(&readfds);
        FD_SET(lfd, &readfds);  // 将lfd加入监听集合

        // 遍历所有可能的fd,将已连接的cfd加入监听集合
        for (int i = 0; i < MAX_FD; i++) {
            if (i != lfd && i != 0) {  // 排除lfd和标准输入(0是stdin)
                FD_SET(i, &readfds);
            }
        }

        // 设置超时时间(5秒,5秒内无fd就绪则返回0)
        struct timeval timeout;
        timeout.tv_sec = 5;
        timeout.tv_usec = 0;

        // 5. 调用select,监听fd就绪
        int ret = select(max_fd + 1, &readfds, NULL, NULL, &timeout);
        if (ret == -1) {
            perror("select failed");
            continue;
        } else if (ret == 0) {
            // 超时,无fd就绪,继续循环监听
            printf("select超时,继续等待...\n");
            continue;
        }

        // 6. 遍历所有fd,判断哪个fd就绪
        for (int i = 0; i <= max_fd; i++) {
            // 判断当前fd是否在就绪集合中
            if (FD_ISSET(i, &readfds)) {
                // 情况1:lfd就绪(有新客户端连接)
                if (i == lfd) {
                    struct sockaddr_in clnt_addr;
                    socklen_t clnt_len = sizeof(clnt_addr);
                    int cfd = accept(lfd, (struct sockaddr*)&clnt_addr, &clnt_len);
                    if (cfd == -1) {
                        perror("accept failed");
                        continue;
                    }
                    printf("新客户端连接:fd=%d,IP=%s,端口=%d\n",
                           cfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));

                    // 更新最大fd(如果cfd大于当前max_fd)
                    if (cfd > max_fd) {
                        max_fd = cfd;
                    }
                } 
                // 情况2:cfd就绪(客户端发送数据)
                else {
                    char buf[BUF_SIZE] = {0};
                    ssize_t read_len = read(i, buf, BUF_SIZE - 1);
                    if (read_len == -1) {
                        perror("read failed");
                        close(i);
                        FD_CLR(i, &readfds);  // 将fd从集合中移除
                        continue;
                    } else if (read_len == 0) {
                        // 客户端主动关闭连接
                        printf("客户端fd=%d 主动关闭连接\n", i);
                        close(i);
                        FD_CLR(i, &readfds);  // 将fd从集合中移除
                        continue;
                    }

                    // 处理客户端数据
                    printf("收到客户端fd=%d 消息:%s\n", i, buf);
                    // 回复客户端
                    write(i, buf, read_len);
                }
            }
        }
    }

    // 关闭监听fd(实际不会执行到这里)
    close(lfd);
    return 0;
}

5. 编译运行与测试

cpp 复制代码
# 编译代码
gcc select_server.c -o select_server

# 启动服务器
./select_server

# 打开多个终端,启动客户端(每个终端一个客户端)
telnet 127.0.0.1 8888
# 或用nc命令
nc 127.0.0.1 8888

测试效果:多个客户端可同时连接服务器,发送数据后,服务器会回复相同的消息,实现多客户端并发处理。

6. select的缺点(必记)

select虽然简单易用、兼容性好,但存在明显缺点,这也是后续学习poll和epoll的原因:

  • fd数量限制:默认最大fd数为1024(可通过修改内核参数调整,但有限制),无法支持万级以上高并发;

  • 效率低:每次调用select都需要遍历所有监听的fd,fd数量越多,效率越低;

  • 需要重复初始化fd集合:select会修改fd集合,每次调用前都要重新初始化,增加开发复杂度;

  • 内核与用户空间拷贝开销:每次调用select,fd集合都会从用户空间拷贝到内核空间,fd越多,拷贝开销越大。

三、poll函数详解(select的改进版)

1. 函数原型与参数

poll函数解决了select的"fd数量限制"问题,接口更简洁,无需维护最大fd值,其原型如下:

cpp 复制代码
#include <poll.h>

// 返回值:就绪的fd数量;-1表示出错;0表示超时
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

(1)fds:struct pollfd数组

struct pollfd结构体用于描述一个要监听的fd及其监听的事件,定义如下:

cpp 复制代码
struct pollfd {
    int fd;         // 要监听的文件描述符
    short events;   // 要监听的事件(输入参数)
    short revents;  // 实际发生的事件(输出参数,由内核修改)
};

核心事件(常用):

  • POLLIN:监听"可读"事件(与select的readfds功能一致);

  • POLLOUT:监听"可写"事件(与select的writefds功能一致);

  • POLLERR:监听"异常"事件(与select的exceptfds功能一致)。

注意:events是"要监听的事件"(我们告诉内核要监听什么),revents是"实际发生的事件"(内核告诉我们发生了什么),调用poll后,内核会修改revents的值。

(2)nfds:pollfd数组的长度

即要监听的fd的数量,无需像select那样计算"最大fd+1",直接传入数组长度即可。

(3)timeout:超时时间(单位:毫秒)

与select的timeout不同,poll的timeout单位是毫秒,取值方式:

  • timeout = -1:无限等待,直到有fd就绪;

  • timeout = 0:不等待,立即返回;

  • timeout > 0:等待指定毫秒数,超时返回0。

2. poll函数工作流程(与select对比)

poll的工作流程与select基本一致,核心区别在于"fd的管理方式":

  1. 创建监听fd(lfd),绑定、监听;

  2. 初始化pollfd数组,将lfd加入数组,设置events为POLLIN;

  3. 调用poll函数,阻塞等待fd就绪;

  4. poll返回后,遍历pollfd数组,判断revents是否包含POLLIN(可读);

  5. 若lfd就绪:accept新客户端,将cfd加入pollfd数组;

  6. 若cfd就绪:read数据、处理、回复;

  7. 重复步骤3-6。

3. poll的优势与缺点

(1)优势(对比select)

  • 无fd数量限制:pollfd数组的长度由用户自己定义,理论上无限制(仅受系统内存限制);

  • 无需重复初始化fd集合:poll不会修改fd和events,仅修改revents,无需每次调用前重新初始化;

  • 接口更简洁:无需维护最大fd值,直接传入数组长度即可。

(2)缺点(仍未解决的问题)

  • 效率低:每次调用poll后,仍需要遍历所有pollfd元素,判断revents,fd数量越多,效率越低;

  • 内核与用户空间拷贝开销:pollfd数组仍需要从用户空间拷贝到内核空间,拷贝开销随fd数量增加而增大;

  • 无法直接定位就绪的fd:必须遍历整个数组,才能找到就绪的fd。

四、select与poll对比总结(表格清晰记忆)

对比维度 select函数 poll函数
fd数量限制 有(默认1024,可修改) 无(仅受系统内存限制)
fd管理方式 fd_set位图,需用宏操作 pollfd数组,结构清晰
初始化开销 每次调用需重新初始化fd集合 无需重新初始化,仅修改revents
效率 低,遍历所有fd 低,遍历所有pollfd元素
拷贝开销 fd集合拷贝,开销随fd增加而增大 pollfd数组拷贝,开销随fd增加而增大
兼容性 跨平台(Windows、Linux、Unix) 仅Linux、Unix,不支持Windows
适用场景 低并发、跨平台场景 低并发、fd数量较多的场景

五、学习小结

  1. I/O复用的核心思想:用一个进程/线程监听多个fd,由内核等待fd就绪,避免创建大量线程,降低资源消耗,支持高并发。

  2. select是最基础的I/O复用函数,兼容性好,但有fd数量限制、效率低、需重复初始化fd集合,适合低并发、跨平台场景。

  3. poll是select的改进版,解决了fd数量限制,接口更简洁,无需重复初始化fd集合,但仍存在效率低、拷贝开销大的问题。

  4. 两者的共同缺点:遍历所有fd/pollfd元素才能找到就绪fd,效率随fd数量增加而下降,无法满足万级以上高并发需求------这也是下一节我们学习epoll的核心原因。

  5. 实操重点:掌握select函数的参数、fd_set操作宏,能编写多客户端监听的TCP服务器,理解select的工作流程和缺点,为后续epoll学习打下基础。

下一节,我们将学习Linux高性能I/O复用函数------epoll,它解决了select和poll的所有缺点,是高并发网络编程(如Nginx、Redis)的核心,也是Linux网络编程的重点难点。

相关推荐
志栋智能1 小时前
超自动化巡检:敏捷运维体系中的重要一环
运维·服务器·网络·云原生·容器·kubernetes·自动化
V搜xhliang02461 小时前
OpenClaw进阶完全教程
运维·人工智能·算法·microsoft·自动化
LinuxGeek10241 小时前
Linux内核“Dirty Frag”漏洞(CVE-2026-43284)修复方案
linux·运维·服务器
曦夜日长1 小时前
Linux系统篇,权限(一):用户创建与切换、权限及角色定义与修改、文件权限二进制表示
linux·运维·服务器
开开心心就好1 小时前
支持音视频图片文档的格式转换器
人工智能·学习·游戏·决策树·音视频·动态规划·语音识别
原来是猿1 小时前
应用层【协议再识/序列化与反序列化】
linux·运维·服务器·网络·网络协议·tcp/ip
2501_940655971 小时前
Paperiii 官网入口:www.paperiii.com——2026抖音爆款AI写作工具
人工智能·学习·ai写作
天草二十六_简村人1 小时前
对接AI大模型之nginx代理配置SSE接口
运维·网络·nginx·http·阿里云·ai·云计算
我想我不够好。1 小时前
2026.5.9消防监控学习 40min
学习