UNIX网络编程笔记:I/O复用:select和poll函数

发布于:2025-03-25 ⋅ 阅读:(26) ⋅ 点赞:(0)

一、阻塞式I/O模型

在这里插入图片描述


核心概念

这是最基础的I/O模型,所有操作默认按顺序执行。当应用进程发起I/O请求时,进程会被完全阻塞,直到内核完成数据准备和复制工作。这种同步等待特性是其最显著特征。


流程图分步解析

阶段1:发起系统调用
  1. 应用进程调用recvfrom函数(接收数据的系统调用)
  2. 内核检查数据报是否准备好:
    未准备好:内核让进程进入阻塞状态(挂起),CPU资源被释放给其他进程
    已准备好:直接跳转到数据复制阶段(图中未体现此分支)
阶段2:等待数据(阻塞期)

• 进程在recvfrom调用处完全暂停,无法执行后续代码
内核持续监控网络数据,直到检测到数据报到达网卡(针对UDP)或接收缓冲区有数据

阶段3:内核处理数据
  1. 数据报到达后,内核将数据从网络缓冲区复制到应用进程的用户空间缓冲区
  2. 完成复制后,内核向应用进程返回成功指示
阶段4:应用恢复执行

• 进程解除阻塞状态,继续执行recvfrom之后的代码(如处理数据报)


关键细节补充

  1. 为何使用UDP作为示例
    • UDP数据报的"准备好"状态是二元的(完整到达或未到达),而TCP涉及流式数据、低水位标记等复杂机制(如需要积累特定字节数才算"准备好")

  2. 阻塞的本质
    • 进程从调用recvfrom到函数返回的整个时间段处于阻塞状态,包括:
    ◦ 等待数据到达内核缓冲区(被动等待)
    ◦ 内核复制数据到用户空间(主动操作)

  3. 系统调用实现差异
    • 在Berkeley系内核(如Linux)中recvfrom是直接系统调用
    • 在System V系内核中可能通过getmsg函数间接实现

  4. 错误处理
    • 阻塞可能被信号中断(如用户按下Ctrl+C),此时系统调用会提前返回错误


模型特点总结

特性 说明
同步性 应用进程需全程等待操作完成
阻塞性 进程在等待期间完全挂起
简单性 编程模型直观,适合简单场景
资源效率 在长等待场景下CPU利用率低(进程无法执行其他任务)

典型应用场景

• 单线程简单服务端(如教学示例)
• 需要严格顺序执行的任务
• 对延迟不敏感的低并发场景

该模型为理解其他I/O模型(如非阻塞式、多路复用、异步I/O)奠定了基础,尽管在实际高并发系统中较少直接使用,但通过对比能更深入理解不同模型的优化方向。

二、非阻塞式I/O模型

在这里插入图片描述


核心机制

通过将套接字设置为非阻塞模式(fcntl(O_NONBLOCK)),强制内核在I/O未就绪时立即返回错误(EWOULDBLOCK,而非阻塞进程。应用进程通过轮询(Polling) 持续主动查询I/O状态,实现"伪异步"操作。


流程图分步解析

阶段1:非阻塞设置(隐含步骤)

• 应用进程通过fcntl系统调用设置套接字的O_NONBLOCK标志(图中未直接展示但文字说明)
• 内核收到该标志后,改变对此套接字的处理策略

阶段2:首次系统调用
  1. 应用进程调用recvfrom请求数据
  2. 内核检查接收缓冲区:
    无数据:立即返回EWOULDBLOCK错误(而非阻塞进程)
    有数据:直接复制到用户空间(图中未展示此分支)
阶段3:轮询循环(关键特征)

第1-3次调用(图中标注三次):
• 应用进程在循环中重复调用recvfrom
• 内核持续返回EWOULDBLOCK错误
• 进程不被挂起,可继续执行其他代码(如处理其他任务)
第4次调用
• 数据报到达网卡并被内核接收缓冲区捕获
• 内核执行数据复制(从内核空间 → 用户空间缓冲区)
• 返回成功指示和实际数据长度

阶段4:数据处理

• 应用进程获得有效数据后,跳出轮询循环处理数据报
• 处理完成后继续下一轮轮询(隐含持续运行特性)


关键技术细节

  1. 错误处理机制
    EWOULDBLOCK ≠ 真实错误,仅为"未就绪"状态标识
    • 需在应用层判断错误类型,避免与严重错误混淆(如ECONNRESET

  2. 轮询间隔控制
    • 完全由应用层控制查询频率
    • 若轮询过于频繁 → CPU空转率高(如100%)
    • 若轮询间隔过长 → 数据处理的实时性降低

  3. 与内核交互模式
    • 每次系统调用都涉及用户态-内核态切换
    • 数据就绪后的复制操作仍会短暂阻塞进程(但时间极短)

  4. 多任务处理能力

    while(1) {
        ret = recvfrom(fd, ...);  // 非阻塞查询
        if(ret > 0) process_data();
        else do_other_tasks();    // 可插入其他操作
    }
    

模型特性对比(vs 阻塞式I/O)

维度 非阻塞式I/O 阻塞式I/O
进程状态 始终处于可运行状态 在等待期间进入睡眠状态
CPU利用率 高(持续轮询消耗资源) 低(释放资源给其他进程)
实时性 数据到达后可立即处理 需等待内核唤醒进程
编程复杂度 高(需手动管理轮询与错误) 低(顺序执行无需状态判断)

典型应用场景

专用监控系统:需要毫秒级响应网络状态变化的场景
混合任务处理:在等待I/O时可穿插执行轻量级计算任务
传统嵌入式系统:缺乏多线程/异步I/O支持的运行环境


演进与局限

优化方向:实际开发中常结合I/O多路复用(select/poll/epoll)减少轮询开销
根本局限:无法解决海量连接场景下的性能问题(C10K问题)

通过此模型可理解异步编程的基础逻辑,但现代高并发系统通常采用更高级的I/O模型(如事件驱动)来实现资源高效利用。

三、I/O复用模型

在这里插入图片描述


核心机制

通过select/poll系统调用集中监控多个文件描述符,仅当至少一个描述符的I/O事件(如可读)就绪时,才触发实际I/O操作。其本质是用单一线程管理多个I/O通道的阻塞与唤醒,核心价值在于用一次阻塞等待替代多次独立阻塞


流程图分步解析

阶段1:注册与阻塞等待
  1. 应用进程调用select/poll
    • 向内核传递需监控的文件描述符集合(如多个socket)
    • 指定关注的事件类型(可读、可写、异常等)
  2. 内核进入阻塞检查:
    • 若无任何描述符就绪 → 进程在select调用处挂起(释放CPU)
    • 若有至少一个描述符就绪 → 立即返回就绪事件数量(图中数据报准备好分支)
阶段2:事件就绪处理
  1. 内核发现数据到达(以UDP数据报为例):
    • 网卡接收数据报并存入内核接收缓冲区
    • 标记对应socket描述符为"可读"状态
  2. 唤醒进程
    • 内核解除select阻塞,返回就绪的描述符集合
    • 应用进程遍历就绪集合,定位具体就绪的socket
阶段3:执行实际I/O
  1. 应用进程调用recvfrom
    • 从已就绪的socket读取数据
    • 内核将数据从内核空间复制到用户空间缓冲区
    • 此阶段进程短暂阻塞(仅发生在数据复制期间)
  2. 返回数据长度与成功状态
阶段4:循环处理

• 处理完当前数据后,重新调用select进入下一轮监控(隐含循环结构)


关键技术细节

  1. select的双重角色
    事件监听器:统一管理多个I/O通道的状态
    阻塞同步点:仅在无事件时挂起进程,避免忙等待(busy waiting)

  2. 两次系统调用代价

    // 典型代码结构
    while(1) {
        select(max_fd+1, &read_fds, NULL, NULL, NULL);  // 第一次阻塞
        for(fd in read_fds) {
            recvfrom(fd, ...);                           // 第二次调用
            process_data();
        }
    }
    

    • 每次循环至少触发2次用户态-内核态切换

  3. 文件描述符集合限制
    select默认支持1024个描述符(FD_SETSIZE限制)
    poll改用链表结构,突破此限制但仍有性能瓶颈

  4. 就绪事件遍历成本
    • 需遍历所有注册的描述符判断就绪状态(时间复杂度O(n))
    • 海量连接时效率急剧下降(C10K问题)


与阻塞式I/O的对比分析

对比维度 I/O复用模型 多线程阻塞式I/O模型
线程数量 单线程管理所有连接 每个连接独占一个线程
资源消耗 内存占用低(无线程栈复制) 线程数激增导致内存与调度开销大
事件响应 统一响应,可能需遍历就绪队列 每个线程独立响应,无遍历开销
编程复杂度 高(需管理描述符集合与事件循环) 低(每个线程代码类似简单循环)
适用场景 高并发(数千连接以上) 中低并发(数百连接)

优势与局限

优势

连接扩展性:单线程可管理成千上万连接(突破单进程文件描述符上限)
资源利用率:避免多线程上下文切换开销
事件统一响应:适合需要同时处理多种I/O类型(如网络+信号+管道)

局限

性能衰减:万级连接时select/poll的O(n)遍历成为瓶颈
数据拷贝成本:每次调用需向内核传递完整描述符集合
惊群效应:多个进程监听同一端口时可能同时被唤醒(需特殊处理)


典型应用场景

  1. Web服务器:Nginx早期版本使用select/poll处理HTTP请求
  2. 即时通讯系统:同时管理大量用户连接的心跳与消息
  3. 数据库中间件:处理来自应用层的多个SQL查询请求
  4. 协议网关:需同时监听网络数据与配置变更信号

演进方向

epoll/kqueue:Linux的epoll与BSD的kqueue通过事件回调机制优化遍历效率
异步I/O:完全非阻塞模型(如Windows IOCP、Linux io_uring)
协程调度:结合用户态协程实现更轻量的并发控制(如Go语言的goroutine)


通过I/O复用模型,开发者首次在单线程中实现了对海量连接的有效管理,为现代高并发系统奠定了基础。尽管其原始实现(select/poll)存在性能瓶颈,但核心思想仍深刻影响着后续事件驱动架构的设计。

四、信号驱动式I/O模型

在这里插入图片描述


核心机制

通过向内核注册信号回调函数(SIGIO),当I/O操作就绪时,内核主动发送信号通知应用进程。进程无需主动轮询或阻塞等待,实现真正的异步响应。这是唯一由内核主动通知用户空间的经典I/O模型。


流程图分步解析

阶段1:信号处理器注册
  1. 设置信号处理程序
    signal(SIGIO, sigio_handler);  // 或更安全的sigaction()
    
    • 指定当SIGIO信号到达时触发的自定义函数(如sigio_handler
  2. 配置套接字属性
    fcntl(fd, F_SETOWN, getpid());          // 绑定进程所有权
    fcntl(fd, F_SETFL, O_ASYNC | O_NONBLOCK); // 启用信号驱动模式
    
    O_ASYNC标志激活内核信号通知机制
    O_NONBLOCK确保后续I/O操作不阻塞进程
阶段2:进程异步执行

• 应用进程继续执行主程序代码(如计算任务或其他I/O)
不再需要轮询或阻塞在特定I/O操作上(与阻塞/非阻塞模型本质区别)

阶段3:内核触发信号
  1. 数据报到达网卡时,内核将其存入接收缓冲区
  2. 内核检测到套接字数据就绪,向注册进程发送SIGIO信号
  3. 信号传递机制中断进程当前执行流
阶段4:信号处理函数响应
  1. 进入信号处理函数(如sigio_handler):
    void sigio_handler(int signo) {
        recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);  // 非阻塞读取
        process_data(buf);
    }
    
  2. 在回调函数内调用recvfrom读取数据(此时数据已在内核缓冲区)
  3. 内核将数据从内核空间复制到用户空间缓冲区(短暂阻塞)
  4. 返回成功状态和实际数据长度
阶段5:恢复主程序

• 信号处理函数执行完毕后,进程返回被中断的主程序继续执行
• 形成"主程序执行 → 信号中断 → 处理I/O → 恢复执行"的异步循环


关键技术细节

  1. 信号队列溢出风险
    • 快速连续到达的数据报可能导致信号丢失(UNIX信号不排队)
    • 需在回调函数中循环读取直到EWOULDBLOCK,而非仅处理一次

    while(recvfrom(fd, ...) > 0) { ... }  // 清空内核缓冲区
    
  2. 非阻塞模式必要性
    • 必须设置O_NONBLOCK,防止信号处理函数中的recvfrom因数据未到达而阻塞

  3. 竞态条件(Race Condition)
    • 信号可能在recvfrom调用前到达,需结合selectepoll做二次检查
    • 推荐使用自同步数据结构(如无锁队列)传递数据到主程序

  4. 全局变量安全性

    volatile sig_atomic_t flag = 0;  // 信号处理函数与主程序共享状态
    

    • 信号处理函数应尽量简短,仅设置标志位,主程序轮询处理实际逻辑


模型特性对比

维度 信号驱动式I/O 非阻塞轮询式I/O
CPU利用率 高(无忙等待) 低(轮询消耗CPU)
响应延迟 微秒级(内核主动通知) 取决于轮询间隔
编程复杂度 高(需处理信号异步性) 中(需管理轮询循环)
大并发适应性 中(受信号队列限制) 低(轮询成本随连接数线性增)
系统兼容性 依赖OS信号机制实现 全平台通用

典型应用场景

  1. 高实时性系统
    • 股票行情推送(需在毫秒级响应市场数据变化)
    • 工业控制传感器数据采集

  2. 低功耗设备
    • 物联网终端(仅在数据到达时唤醒主处理器)

  3. 混合型服务

    # 示例:同时处理终端输入与网络请求
    $ ./server --signal-driver --port 8080
    

    • 主线程可专注于CLI交互,I/O由信号驱动处理

  4. 传统UNIX守护进程
    • 串口通信、打印机假脱机系统(spooler)


局限性与演进

局限性

信号处理不可控:可能被其他信号中断,需处理可重入性问题
UDP偏向性:TCP流式数据难以界定"就绪"时刻(需结合MSG_PEEK标志)
调试困难:异步执行流导致断点跟踪复杂

现代演进

实时信号扩展:Linux的SIGRTMIN系列信号支持排队(避免丢失)
事件驱动框架:libevent/libuv抽象信号机制,提供跨平台支持
内核旁路(Kernel Bypass):如DPDK直接接管网卡中断


通过信号驱动模型,开发者首次实现了真正的异步I/O响应,尽管其实战复杂度较高,但为现代异步编程范式(如Node.js事件循环)奠定了理论基础。理解此模型对深入掌握Linux系统编程和性能调优至关重要。

五、异步I/O模型

在这里插入图片描述


核心机制

通过aio_read等POSIX异步I/O函数,将整个I/O操作(数据等待+数据复制)全权委托给内核,应用进程在发起请求后立即继续执行,内核在完成所有操作后通过信号或回调机制通知进程。这是真正意义上的全异步模型。


流程图分步解析

阶段1:发起异步请求
  1. 应用进程调用aio_read函数:
    struct aiocb cb = {
        .aio_fildes = fd,          // 文件描述符
        .aio_buf = buffer,         // 用户缓冲区指针
        .aio_nbytes = buffer_size, // 缓冲区大小
        .aio_offset = 0,           // 文件偏移量(类似lseek)
        .aio_sigevent = {          // 通知方式
            .sigev_notify = SIGEV_SIGNAL,
            .sigev_signo = SIGIO
        }
    };
    aio_read(&cb);  // 非阻塞系统调用
    
  2. 内核立即返回EINPROGRESS状态码,不阻塞进程
阶段2:进程自由执行

• 应用进程继续执行后续代码(如处理其他任务或启动更多异步I/O)
完全脱离对当前I/O操作的管理(与信号驱动式模型的本质区别)

阶段3:内核并行处理
  1. 等待数据报到达网卡(针对UDP示例)
  2. 将数据从网卡DMA缓冲区复制到内核空间
  3. 继续将数据从内核空间复制到用户空间缓冲区
  4. 双重复制操作均由内核独立完成
阶段4:完成通知
  1. 内核在两次复制均完成后,通过预定义的信号(如SIGIO)通知进程
  2. 信号处理函数被触发:
    void sigio_handler(int signo) {
        // 检查异步操作结果
        if(aio_error(&cb) == 0) {
            ssize_t ret = aio_return(&cb);  // 获取实际读取字节数
            process_data(buffer, ret);
        }
    }
    
阶段5:数据处理

• 应用进程在信号处理函数中安全访问用户缓冲区(此时数据已完整就绪)


关键技术细节

  1. 操作原子性
    • 从发起请求到获得数据的全过程对应用层透明
    • 内核保证数据复制的完整性(要么全部完成,要么返回错误)

  2. 通知机制选择

    // 可通过sigevent指定多种通知方式
    struct sigevent {
        int sigev_notify;                   // 通知类型
        int sigev_signo;                     // 信号编号
        union sigval sigev_value;           // 传递自定义数据
        void (*sigev_notify_function)(union sigval); // 回调函数
        pthread_attr_t *sigev_notify_attributes; // 线程属性
    };
    

    • 支持信号、线程回调、实时信号扩展等多种通知方式

  3. 错误处理特殊性
    • 需通过aio_error()单独检查操作状态(而非传统errno机制)
    • 必须使用aio_return()获取操作结果(直接访问缓冲区可能读到不完整数据)

  4. 缓冲区生命周期管理

    // 必须保证缓冲区在异步操作期间有效
    char buffer[BUFSIZE];                    // 全局变量或堆内存
    struct aiocb cb;
    cb.aio_buf = malloc(BUFSIZE);           // 堆内存更安全
    

    • 用户缓冲区必须保持有效直到操作完成


与信号驱动式I/O的对比

对比维度 异步I/O模型 信号驱动式I/O模型
阻塞阶段 完全无阻塞(包括数据复制) 数据复制阶段短暂阻塞
通知时机 数据已复制到用户空间后通知 数据到达内核空间即通知
缓冲区安全 内核保证操作完成时数据可用 需在回调中自行处理数据复制
编程复杂度 高(需管理异步控制块和多种回调) 中(只需处理信号和数据读取)
系统支持 仅限特定系统(如Linux的io_uring) 主流UNIX系统普遍支持

优势与挑战

优势

零拷贝潜力:某些实现(如Linux io_uring)支持内核旁路技术
极致吞吐量:适合需要并行处理海量I/O请求的场景(如高频交易系统)
确定性延迟:从操作发起到完成通知的时间可精确测量

挑战

内存管理风险:异步操作未完成时修改缓冲区会导致数据损坏
调试复杂性:异步执行流难以用传统调试工具追踪
生态碎片化

# 不同系统的异步I/O实现差异巨大
Linux → io_uring
Windows → IOCP
BSD → kqueue

现代演进与应用

  1. Linux io_uring
    • 通过环形队列实现零系统调用开销
    • 支持批量提交和完成事件,显著提升吞吐量

    // io_uring示例:提交读取请求
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, len, 0);
    io_uring_submit(&ring);
    
  2. 云原生基础设施
    • 数据库系统(如RocksDB异步压缩)
    • 分布式存储系统(如Ceph对象存储)

  3. 金融交易系统
    • 纳秒级行情数据摄取
    • 多交易所订单流的并行处理


理论价值

该模型虽在传统网络编程中应用有限,但其思想深刻影响了:
• Node.js事件循环(libuv抽象层)
• Go语言goroutine调度器
• Rust异步运行时(tokio、async-std)
• 现代操作系统的I/O栈重构(如Windows IOCP、Linux io_uring)

通过理解异步I/O模型,开发者能更深入掌握计算机系统中并发与并行的本质区别,为构建高性能系统奠定理论基础。尽管当前直接使用POSIX异步I/O的场景较少,但其设计理念已渗透到现代高并发框架的各个层面。

六、异步

在这里插入图片描述


核心概念

同步I/O与异步I/O的本质区别

同步I/O(前4种模型)
真正的I/O操作(recvfrom)会阻塞进程,直到数据从内核缓冲区复制到用户空间
(无论第一阶段是否阻塞,第二阶段必然阻塞)
异步I/O(第5种模型)
I/O操作全阶段非阻塞,内核完成所有操作后通过回调/信号通知进程


模型对比分析

1. 阻塞式I/O(Blocking I/O)

工作流程
┌───────────────┐ ┌───────────────┐
│ 调用recvfrom() │──阻塞等待─→│ 数据到达内核缓冲区 │
└───────────────┘ └───────────────┘

┌───────────────┐ ┌───────────────┐
│ 数据复制到用户空间 │──阻塞复制─→│ 返回成功结果 │
└───────────────┘ └───────────────┘
特点
• 全程同步阻塞,CPU利用率最低
• 编程简单但无法处理并发(如:单线程Web服务器)

2. 非阻塞式I/O(Non-blocking I/O)

工作流程
┌───────────────┐ ┌───────────────┐
│ 循环调用recvfrom() │──轮询检查─→│ 内核返回EWOULDBLOCK │
└───────────────┘ └───────────────┘
(数据到达后)
┌───────────────┐ ┌───────────────┐
│ 数据复制到用户空间 │──阻塞复制─→│ 返回成功结果 │
└───────────────┘ └───────────────┘
特点
阶段1非阻塞(轮询),阶段2仍同步阻塞
• CPU空转严重(100%占用),适合低延迟监控场景

3. I/O复用(I/O Multiplexing)

工作流程
┌───────────────┐ ┌───────────────┐
│ 调用select/poll() │──阻塞监听─→│ 至少一个fd就绪 │
└───────────────┘ └───────────────┘

┌───────────────┐ ┌───────────────┐
│ 调用recvfrom() │──阻塞复制─→│ 返回成功结果 │
└───────────────┘ └───────────────┘
特点
阶段1集中阻塞监听,阶段2逐个同步处理
• 解决C10K问题的基础模型(如:Nginx早期版本)

4. 信号驱动式I/O(Signal-driven I/O)

工作流程
┌───────────────┐ ┌───────────────┐
│ 注册SIGIO处理器 │──异步通知─→│ 内核发送就绪信号 │
└───────────────┘ └───────────────┘

┌───────────────┐ ┌───────────────┐
│ 调用recvfrom() │──阻塞复制─→│ 返回成功结果 │
└───────────────┘ └───────────────┘
特点
阶段1异步通知,阶段2仍同步阻塞
• 适合实时系统(如:股票行情推送)

5. 异步I/O(Asynchronous I/O)

工作流程
┌───────────────┐ ┌───────────────┐
│ 调用aio_read() │──立即返回─→│ 内核处理全流程 │
└───────────────┘ └───────────────┘
(完成后)
┌───────────────┐ ┌───────────────┐
│ 内核发送完成信号 │──纯异步─→│ 直接使用用户缓冲区 │
└───────────────┘ └───────────────┘
特点
全程无阻塞,内核处理两个阶段
• 唯一符合POSIX异步定义的模型(如:高频交易系统)


关键差异总结

模型 阶段1(等待数据) 阶段2(复制数据) 同步/异步
阻塞式I/O 阻塞 阻塞 同步
非阻塞式I/O 非阻塞(轮询) 阻塞 同步
I/O复用 阻塞(多路监听) 阻塞 同步
信号驱动式I/O 异步(信号通知) 阻塞 同步
异步I/O 非阻塞 非阻塞 异步

技术演进路径

  1. 阻塞式 → 非阻塞式
    解决单连接独占问题,但引入轮询开销
    (代价:CPU资源浪费)

  2. 非阻塞式 → I/O复用
    通过单线程管理多连接,提升并发能力
    (代价:O(n)遍历复杂度)

  3. I/O复用 → 信号驱动式
    减少无效轮询,实现精准事件触发
    (代价:信号处理复杂性)

  4. 信号驱动式 → 异步I/O
    完全消除阻塞点,释放CPU资源
    (代价:内存管理和调试复杂性)


现代应用场景

同步模型:传统嵌入式设备、简单协议解析
(如:Modbus TCP网关)
异步模型:云计算基础设施、金融交易系统
(如:AWS Lambda事件驱动架构)

通过此对比可清晰理解:异步I/O是唯一真正实现非阻塞的模型,其他模型均在数据复制阶段存在隐性同步阻塞。掌握这些差异对设计高并发系统至关重要。

七、select函数

在网络编程中,select 函数是实现 I/O 多路复用的核心工具,允许程序通过单线程同时监控多个文件描述符(如套接字)的状态,从而高效处理并发连接。以下是对 select 函数的详细解析:


1. select 的作用

select 用于监视一组文件描述符(fd),检测它们是否处于以下状态之一:
可读(数据可读取,或新连接到达)
可写(可发送数据)
异常(发生错误或带外数据到达)

通过单线程轮询多个 fd,避免为每个连接创建独立线程或进程,提升资源利用率。


2. 函数原型与参数

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, 
           fd_set *exceptfds, struct timeval *timeout);
参数详解
  1. nfds
    • 所有被监控的 fd 中最大值 +1(内核只需检查 0nfds-1fd)。
    • 例如:监控 fd 为 3、5,则 nfds = 5 + 1 = 6

  2. readfds
    • 指向可读事件的文件描述符集合。
    • 调用前:用户设置需监控可读的 fd
    • 返回后:内核修改集合,仅保留可读的 fd

  3. writefds
    • 类似 readfds,但用于检测可写状态。

  4. exceptfds
    • 检测异常状态(如连接错误或带外数据)。

  5. timeout
    • 超时时间结构体:

    struct timeval {
        long tv_sec;  // 秒
        long tv_usec; // 微秒
    };
    

    行为
    timeout = NULL:永久阻塞,直到事件发生。
    timeval = {0, 0}:非阻塞,立即返回。
    ◦ 其他值:等待指定时间后返回(即使无事件)。


3. 操作 fd_set 的宏

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 是否在集合中(用于检测就绪事件)。


4. 使用流程

  1. 初始化集合
    每次调用 select 前需重新设置 fd_set(因内核会修改集合):

    fd_set read_fds;
    FD_ZERO(&read_fds);       // 清空集合
    FD_SET(socket_fd, &read_fds); // 添加需监控的 fd
    
  2. 调用 select

    int ret = select(max_fd + 1, &read_fds, &write_fds, &except_fds, &timeout);
    
  3. 处理返回值
    ret > 0:有 retfd 就绪。
    ret = 0:超时。
    ret = -1:出错(检查 errno,如 EINTR 表示被信号中断)。

  4. 遍历检查所有 fd
    遍历所有被监控的 fd,使用 FD_ISSET 判断是否就绪:

    if (FD_ISSET(socket_fd, &read_fds)) {
        // 处理可读事件(如 accept() 或 read())
    }
    

5. 示例代码(简化服务器)

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

int main() {
    int listen_sock = /* 创建并绑定监听 socket */;
    fd_set read_fds;
    int max_fd = listen_sock;

    while (1) {
        FD_ZERO(&read_fds);
        FD_SET(listen_sock, &read_fds);  // 监控监听 socket

        // 添加其他客户端 fd 到 read_fds,并更新 max_fd

        int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            continue;
        }

        // 处理新连接
        if (FD_ISSET(listen_sock, &read_fds)) {
            int client_sock = accept(listen_sock, ...);
            FD_SET(client_sock, &read_fds);
            if (client_sock > max_fd) max_fd = client_sock;
        }

        // 处理客户端数据
        for (int fd = 0; fd <= max_fd; fd++) {
            if (FD_ISSET(fd, &read_fds) && fd != listen_sock) {
                char buffer[1024];
                ssize_t len = read(fd, buffer, sizeof(buffer));
                // 处理数据...
            }
        }
    }
    return 0;
}

6. 优缺点

优点

跨平台:支持大多数 Unix/Linux 系统和 Windows。
灵活:可同时监控读、写、异常事件,支持超时。

缺点

性能瓶颈
• 监控的 fd 数量受 FD_SETSIZE 限制(通常 1024)。
• 每次调用需从用户态拷贝 fd 集合到内核态,fd 多时开销大。
• 需线性遍历所有 fd 检测就绪状态,效率低。
复杂度:需手动管理 fd 集合,易出错。


7. 替代方案

poll:解决 fd 数量限制,但仍有性能问题。
epoll(Linux) / kqueue(BSD):高效处理大量连接,使用事件驱动模型。
libevent / libuv:跨平台的高层抽象库,封装底层多路复用机制。


总结

select 是网络编程中基础的 I/O 多路复用工具,适用于连接数较少或需跨平台的场景。其核心思想是通过单线程轮询多个 fd,但受限于性能和扩展性。在高并发场景下,建议使用 epollkqueue 等更高效机制。

八、poll函数

在网络编程中,poll 函数是 I/O 多路复用的另一种实现方式,与 select 类似,但解决了 select 的一些设计缺陷(如文件描述符数量限制)。以下是 poll 函数的详细解析:


1. poll 的作用

poll 用于监控多个文件描述符(fd),检测它们是否处于以下状态:
可读(数据可读取,或新连接到达)
可写(可发送数据)
异常(错误或带外数据)

select 不同,poll 不依赖固定大小的 fd 集合,而是通过动态数组管理,突破了 selectFD_SETSIZE 限制(通常 1024),更适合高并发场景。


2. 函数原型与参数

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数详解
  1. fds
    • 指向 struct pollfd 结构体数组的指针,每个结构体描述一个待监控的 fd 及其事件。
    • 结构体定义:

    struct pollfd {
        int   fd;       // 文件描述符
        short events;   // 监控的事件(用户设置)
        short revents;  // 实际发生的事件(内核返回)
    };
    
  2. nfds
    fds 数组的长度(即监控的 fd 数量)。

  3. timeout
    • 超时时间(毫秒):
    timeout > 0:等待指定毫秒后返回(即使无事件)。
    timeout = 0:非阻塞,立即返回。
    timeout = -1:永久阻塞,直到事件发生。


3. 事件类型(eventsrevents

eventsrevents 是位掩码,通过位或操作组合多个事件:

事件宏 含义
POLLIN 数据可读(包括新连接到达)
POLLPRI 紧急数据可读(如 TCP 带外数据)
POLLOUT 数据可写
POLLERR 发生错误(自动触发,无需设置 events
POLLHUP 连接挂起(如对端关闭写端)
POLLNVAL fd 未打开(非法操作)
示例设置
struct pollfd fd;
fd.fd = socket_fd;
fd.events = POLLIN | POLLOUT;  // 监控可读和可写事件

4. 使用流程

  1. 初始化 pollfd 数组
    为每个待监控的 fd 创建一个 struct pollfd,设置 fdevents 字段:

    struct pollfd fds[MAX_FDS];
    fds[0].fd = listen_sock;
    fds[0].events = POLLIN;  // 监控监听 socket 的可读事件
    
  2. 调用 poll

    int ret = poll(fds, nfds, timeout);
    
  3. 处理返回值
    ret > 0:有 retfd 就绪。
    ret = 0:超时。
    ret = -1:出错(检查 errno)。

  4. 遍历检查所有 pollfd
    遍历数组,通过 revents 判断哪些 fd 就绪:

    for (int i = 0; i < nfds; i++) {
        if (fds[i].revents & POLLIN) {
            // 处理可读事件
        }
        if (fds[i].revents & POLLERR) {
            // 处理错误
        }
    }
    

5. 示例代码(简化服务器)

#include <poll.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    int listen_sock = /* 创建并绑定监听 socket */;
    struct pollfd fds[1024];
    int nfds = 1;

    fds[0].fd = listen_sock;
    fds[0].events = POLLIN;

    while (1) {
        int ret = poll(fds, nfds, -1);  // 永久阻塞
        if (ret < 0) {
            perror("poll error");
            continue;
        }

        // 处理新连接
        if (fds[0].revents & POLLIN) {
            int client_sock = accept(listen_sock, ...);
            fds[nfds].fd = client_sock;
            fds[nfds].events = POLLIN;
            nfds++;
        }

        // 处理客户端数据
        for (int i = 1; i < nfds; i++) {
            if (fds[i].revents & POLLIN) {
                char buffer[1024];
                ssize_t len = read(fds[i].fd, buffer, sizeof(buffer));
                if (len <= 0) {
                    close(fds[i].fd);
                    // 从数组中移除(或标记为无效)
                } else {
                    // 处理数据...
                }
            }
        }
    }
    return 0;
}

6. poll 与 select 的对比

特性 select poll
文件描述符上限 FD_SETSIZE 限制(通常 1024) 无硬性限制,由系统资源决定
事件类型 分读、写、异常三个独立集合 统一通过 events 字段设置
内核与用户态交互 每次调用需拷贝全部 fd 集合 只需传递 pollfd 数组
性能 大量 fd 时效率低(线性遍历) 大量 fd 时仍线性遍历,但略高效
跨平台 广泛支持(包括 Windows) 主要支持 Unix/Linux

7. 优缺点

优点

突破 fd 数量限制:适合高并发场景。
事件分离:通过 eventsrevents 分离监控事件和实际事件,避免重复设置。
更直观的 API:无需计算最大 fd,直接传递数组。

缺点

性能问题
• 仍需遍历所有 fd 检查状态,fd 数量大时效率低。
• 内核仍需线性扫描所有 fd
无批量事件通知:每次调用需传递整个 pollfd 数组,内核和用户态间存在数据拷贝。


8. 替代方案

epoll(Linux)
使用事件驱动模型,仅返回就绪的 fd,时间复杂度 O(1),适合海量连接。
kqueue(BSD/macOS)
类似 epoll,高效处理高并发。
io_uring(Linux 5.1+)
异步 I/O 新机制,性能更高。


总结

pollselect 的改进版,解决了 fd 数量限制问题,适合中等规模的并发场景。但其性能仍受限于线性遍历 fd,无法应对超高并发(如数万连接)。在实际开发中:
小规模场景selectpoll 均可。
Linux 高并发:优先使用 epoll
跨平台开发:考虑 libeventlibuv 封装底层差异。