网络编程卡壳?五种IO模型+select/poll一篇讲透,搞懂网络编程核心,阻塞/多路复用不再懵!

0 阅读15分钟

@[toc] 在这里插入图片描述

本篇摘要

  • 本篇将介绍五种IO模型,以及对其中多路转接IO的select,poll进行介绍,使用,总结等。

一· 五种IO模型介绍:

1. 阻塞IO(Blocking IO, BIO)

在这里插入图片描述

  • 核心特点:应用程序发起IO操作(如读/写)后,线程会被“卡住”,直到IO完成(数据到达/发送完毕)。

  • 工作流程
    在这里插入图片描述

  • 总结简单但低效,适合单连接、低并发场景(如早期客户端程序),所有套接字默认都是阻塞IO


2. 非阻塞IO(Non-blocking IO, NIO)

在这里插入图片描述

  • 核心特点:应用程序发起IO操作后,函数立即返回(不会阻塞线程),但需主动轮询检查数据是否就绪,非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返叵EWOULDBLOCK错误码。

  • 工作流程
    在这里插入图片描述

  • 总结避免线程阻塞,但需频繁轮询(浪费CPU),适合短连接或对延迟敏感的场景。


3. IO多路复用(Multiplexed IO)

在这里插入图片描述

  • 核心特点:通过一个系统调用监控多个文件描述符(FD),当任意一个FD就绪(数据可读/写)时,内核通知应用程序处理。

  • 代表实现select(老)、poll(改进)、epoll(高性能,Linux专属)。

  • 工作流程
    在这里插入图片描述

  • 总结用“单线程管多个连接”,大幅提升并发能力(如Nginx、Redis用epoll)。


4. 信号驱动IO(Signal Driven IO, SIGIO)

在这里插入图片描述

  • 核心特点:应用程序通过信号(Signal) 通知内核“监控某个FD”,当数据就绪时,内核发送信号触发应用程序处理。

  • 工作流程
    在这里插入图片描述

  • 总结异步通知,但信号可能丢失或延迟,实际应用较少(仅适合特定场景)。


5. 异步IO(Asynchronous IO, AIO)

在这里插入图片描述

  • 核心特点:应用程序发起IO操作后,内核全程完成数据拷贝,完成后主动通知应用程序(无需线程干预)。

  • 工作流程
    在这里插入图片描述

  • 总结完全异步,“发起即忘”,效率最高(适合高并发、大文件传输等场景,如Windows的IOCP)。


因此对应信号驱动IO以及异步IO区别:信号驱动是数据准备好了,告诉用户自己调用接口从内核态拷贝到用户态,而异步是直接让内核拿到数据后拷贝到用户态完成才进行通知用户。

相关概念认识

同步通信 vs 异步通信

  1. 核心区别

    • 同步:调用者自己发起IO操作,全程等待结果返回(自己干完才停)。
    • 异步:调用者委托他人执行IO操作,完成后收到通知(交给别人干,自己等通知)。
  2. 本质差异

    • 同步:IO操作由调用方“亲自执行”,需阻塞等待。
    • 异步:IO操作由其他组件执行,调用方仅接收结果通知。

阻塞 vs 非阻塞

  1. 关注点

    • 程序在“等待调用结果时的状态”。
  2. 区别

    • 阻塞:等待期间,当前线程被“挂起”(卡住不动),直到结果返回才能继续。
    • 非阻塞:等待期间,线程不被“卡住”,可继续执行其他任务(不干等)。

对比

  • 同步/异步:描述“IO操作由谁执行”(自己干 vs 别人干)。
  • 阻塞/非阻塞:描述“等待时线程是否被挂起”(卡住 vs 不卡住)。
  • 二者独立:同步操作可能阻塞或非阻塞(如同步非阻塞需轮询),异步操作通常搭配非阻塞(避免卡死)。

一句话总结

从“干等”(阻塞)到“轮询”(非阻塞),再到“监控多个”(多路复用),最后到“内核代劳”(异步),IO模型逐步解决“线程效率”和“并发能力”的矛盾。

其他高级IO拓展(了解)

  1. 非阻塞 IO

    • 调用时不阻塞线程,立即返回结果(可能未完成),需主动轮询或配合其他机制(如 I/O 多路复用)获取最终结果。
  2. 记录锁(文件锁)

    • 对文件/数据加锁,确保多进程/线程并发访问时的数据一致性(如防止同时写入)。
  3. 系统 V 流机制(STREAMS)

    • UNIX 系统中的一种设备驱动模型,支持数据的异步处理和模块化组件(现代应用较少,可简单理解为“流式数据处理框架”)。
  4. I/O 多路转接(I/O Multiplexing)

    • 通过 select/poll/epoll 等机制,单个线程监控多个文件描述符的 IO 状态,有事件时才触发处理,避免轮询浪费资源(常用于高并发服务端)。
  5. readv/writev 函数(分散/聚集 I/O)

    • 允许一次系统调用读写多个内存缓冲区(分散写入、聚集读取),减少多次拷贝开销,提高大块数据传输效率。
  6. 存储映射 IO(mmap)

    • 将文件直接映射到进程内存空间,通过内存操作访问文件(避免频繁读写系统调用),适合大文件随机访问和进程间共享内存。

小结:

这些高级 IO 主要解决 效率、并发控制、资源利用 问题,常结合使用(如 epoll + 非阻塞 IO + mmap),适用于高并发、高性能场景。

二.非阻塞IO之fcntl

fcntl 函数基本信息

  1. 头文件:包含 <unistd.h><fcntl.h>
  2. 函数原型
    int fcntl(int fd, int cmd, ... /* arg */);
    
  3. 核心功能通过不同的 cmd 参数实现对文件描述符的多种控制操作。

fcntl 函数的 5 种主要功能

  1. 复制文件描述符cmd=F_DUPFD):复制已有描述符。
  2. 获取/设置文件描述符标记cmd=F_GETFDF_SETFD):操作描述符的标志(如关闭标志 FD_CLOEXEC)。
  3. 获取/设置文件状态标记cmd=F_GETFLF_SETFL):控制文件读写模式(如阻塞/非阻塞)。
  4. 获取/设置异步 I/O 所有权cmd=F_GETOWNF_SETOWN):指定接收异步 I/O 信号的进程。
  5. 获取/设置记录锁cmd=F_GETLKF_SETLKF_SETLKW):对文件区域加锁/解锁。

通过 fcntl 设置非阻塞模式

  1. 核心步骤(以 F_SETFL 为例):
    • 使用 F_GETFL 获取当前文件状态标志(返回一个位图)。
    • 通过按位或运算(|)添加 O_NONBLOCK 标志。
    • 使用 F_SETFL 将修改后的标志写回文件描述符。
  2. 示例set_nonblock 函数):
    void set_nonblock(int fd) {
        int flags = fcntl(fd, F_GETFL, 0);       // 获取当前标志
        flags |= O_NONBLOCK;                     // 添加非阻塞标志
        fcntl(fd, F_SETFL, flags);               // 设置回文件描述符
    }
    

基于fcntl函数简单测试

测试思路

  • 将标准输入(键盘)设置为非阻塞模式,通过无限循环持续尝试读取输入:有数据时输出内容;无数据时因EAGAIN/EWOULDBLOCK提示“没有数据”并休眠1秒;若被信号中断(EINTR)则提示后继续循环;其他错误则提示错误,最终实现非阻塞读取标准输入的功能。

效果展示

在这里插入图片描述

  • 标准输入流没输入就打印没有数据,否则打印输入的数据。

源码展示

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
using std::cout;
using std::endl;
void set_nonblock(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    flags |= O_NONBLOCK;
    fcntl(fd, F_SETFL, flags);
}

int main()
{

    set_nonblock(0);
    while (1)
    {
        char buf[1024];
        int n = read(0, buf, sizeof(buf));
        if (n > 0)
        {
            buf[n - 1] = 0;
           cout << buf << endl;
        }

        else if (n < 0)
        {   //无论是读取失败还对方的接收缓冲区没有数据最后都是n==-1因此三种情况区分!
             cout<<n<<endl;
            if (errno == EINTR)
            {
                cout << "被打断了" << endl;
                continue;
            }
            else if (errno == EAGAIN || errno == EWOULDBLOCK)
            {    sleep(1);
                cout << "没有数据" << endl;
                //虽然IO效率没有提高,可以在等待的时间做自己想做的事情!
                continue;
            }

            else
            {

                cout << "出错了" << endl;
                continue;
            }
        }
    }
    return 0;
}

三. 多路转接之select

select相关函数介绍

  1. 功能:系统提供select函数实现多路复用输入/输出模型,用于监视多个文件描述符的状态变化,程序会在select处等待直至监视的描述符有状态改变。
  2. 函数原型
    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    
    • nfds:需监视的最大文件描述符值+1。
    • readfds/writefds/exceptfds:分别对应可读、可写、异常文件描述符的集合。
    • timeout:设置等待时间的结构体(struct timeval)。
  3. 文件描述符集合操作
    • 集合以位图形式存储,从右到左第几位代表对应fd值,用户通过以下函数操作:
      void FD_CLR(int fd, fd_set *set);    // 清除fd在集合中
      int  FD_ISSET(int fd, fd_set *set);  // 检查fd是否在集合中
      void FD_SET(int fd, fd_set *set);    // 将fd添加到集合中
      void FD_ZERO(fd_set *set);          // 清空集合
      
    • 若不关心某类事件,对应参数可传nullptr
  4. timeout参数

在这里插入图片描述

  • 结构体含tv_sec(秒)和tv_usec(微秒)字段。
  • 若为NULL:阻塞等待;若字段全为0:非阻塞(立即返回)。
  1. 返回值
    • 成功:返回状态改变的文件描述符数量。
    • 返回0:超时时间内无描述符状态改变。
    • 返回-1:发生错误,错误原因存于errno,涉及错误码如:
      • EBADF:无效文件描述符或文件已关闭。
      • EINTR:调用被信号中断。
      • EINVAL:参数n为负值。
      • ENOMEM:内核内存不足。
      • 参数 readfds、writefds、exceptfds和 timeout的值可能变为不可预测状态,后续代码不应依赖这些值。

==使用流程:==

在这里插入图片描述

select代码编写测试(基于tcp实现echo回显)

设计思路

  • 拿着select进行文件描述符的就绪监测(包括istenfd以及给用户开的普通的fd);当select等待到事件就绪就进行为新的连接开辟fd或者对普通fd的读写操作等;这里只检测读事件默认写都是就络的;但是select会更改输入参数,导致每次都要动态维护需要监测的fd,然后对d集合进行调整;因此这里采取一个数组来动态维护再进行fd集合的填充(新连接来或者有客户退出进行d数组的更新)。

测试效果

在这里插入图片描述

  • 成功连接,为新的连接开辟新的fd。

在这里插入图片描述

  • 普通的fd进行的读事件就绪,进行正常读取。

在这里插入图片描述

  • 客户端退出,对应fd的读事件就绪(里面被写入了类似文件结尾的标志,读取后直接关闭这个fd,移除监测集合中对应的位置)。

源码汇总

点我速看源码

select缺点

  1. 文件描述符监控机制:通过fd_set位图实现,监控数量由sizeof(fd_set)决定(某服务器为512字节,支持最大4096个文件描述符),位图容量存在上限。
  2. select的主要缺点
    • 每次调用需手动设置fd集合,使用不便;
    • 需将fd集合从用户态拷贝到内核态,fd数量多时开销大;
    • 内核需遍历所有传入的fd,数量多时效率低;
    • 支持的文件描述符数量上限较低(依赖系统实现)。
  3. 局限性中的优势:尽管存在上述缺点,但其跨平台兼容性好,因此仍被广泛使用。

四.多路转接之poll

poll解决了select的部分问题,主要体现在两方面:

  1. 突破fd数量上限:select依赖固定大小数组存储文件描述符(fd),存在上限(如1024);poll改用pollfd结构体数组,理论上仅受系统资源限制,无固定上限。
  2. 实现输入输出分离:poll通过pollfd结构体中的events(监听事件)和revents(就绪事件)字段,明确区分读写等事件类型,无需每次全量遍历检查,提升了事件处理的效率和灵活性。

poll相关函数介绍

poll 是 Linux 系统中用于 I/O 多路复用的系统调用,用于监控多个文件描述符(FD)的状态变化,避免单线程阻塞等待 I/O。以下是其核心用法总结:

函数原型

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

核心参数

  1. struct pollfd *fds

如下:

#include <poll.h>  
struct pollfd {
    int    fd;       
    short  events;  
    short  revents;  
};
  • 监控的文件描述符列表,每个元素包含三部分信息:
    • fd:要监控的文件描述符(如套接字、文件等)。
    • events感兴趣的事件(通过宏定义指定,如 POLLINPOLLOUT 等)。
    • revents实际发生的事件(由内核填充,调用后检查该字段判断状态)。
  1. nfds_t nfds

    • fds 数组的长度(即监控的文件描述符数量)。
  2. int timeout

    • 超时时间(单位:毫秒):
      • -1:永久阻塞,直到有事件发生或出错。
      • 0:立即返回(非阻塞轮询)。
      • >0:最多等待指定毫秒数,超时返回。

返回值

  • < 0:发生错误(需检查 errno)。
  • = 0:超时(无任何事件发生)。
  • > 0:就绪的文件描述符数量(fdsrevents 非零的元素个数)。

核心事件宏(events/revents

| 宏定义          | 含义                          |
|-----------------|-------------------------------|
| POLLIN        | 可读数据(输入缓冲区非空)。  |
| POLLOUT       | 可写数据(输出缓冲区未满)。  |
| POLLERR       | 错误状态。                    |
| POLLHUP       | 挂起状态(连接断开)。        |
| POLLNVAL      | 无效的文件描述符。            |

使用步骤

  1. 初始化 pollfd 数组
    为每个需要监控的文件描述符创建 struct pollfd 结构体,设置 fdevents

    struct pollfd fds[2];
    fds[0].fd = stdin_fd;       // 标准输入
    fds[0].events = POLLIN;     // 监控可读事件
    fds[1].fd = socket_fd;      // 套接字
    fds[1].events = POLLIN | POLLOUT; // 监控可读和可写事件
    
  2. 调用 poll 函数

    int ret = poll(fds, 2, 5000); // 监控 2 个 FD,最多等 5 秒
    
  3. 处理返回结果

    • ret > 0,遍历 fds 数组,检查 revents 判断哪些 FD 就绪:
      for (int i = 0; i < nfds; i++) {
          if (fds[i].revents & POLLIN) {
              // 处理可读事件
          }
          if (fds[i].revents & POLLHUP) {
              // 处理连接断开
          }
      }
      
    • ret == 0:超时处理。
    • ret < 0:错误处理(如 EINTR 表示被信号中断)。

操作流程:

在这里插入图片描述

poll代码编写测试(基于tcp实现echo回显)

设计思路

这里只需要把对应的之前用数组维护的fd集合改成维护这个struct pollfd结构体数组,对结构体里面的变量进行初始化,更新,判断等操作即可。

测试效果

同上面没啥区别。

源码汇总

点我速看源码

特点与适用场景

poll的优点

  1. 接口设计更灵活:不同于select使用三个位图表示fd_set,poll采用pollfd结构体指针,结构体内包含要监视的事件和发生的事件,避免了select“参数-值”传递方式的局限性,使用更方便。
  2. 文件描述符数量限制宽松:poll没有最大数量限制(实际受系统资源等影响),仅理论上限为数组大小,相比select有一定优势(select通常有固定上限如1024)。

poll的缺点

  1. 性能随监控数量线性下降:当监听的文件描述符数量增多时,每次调用poll都需要把大量pollfd结构从用户态拷贝到内核态;并且poll返回后需轮询整个pollfd数组来获取就绪描述符,若大量连接中只有少数就绪,效率会显著降低(如第10000个fd就绪仍需遍历全部)。
  2. 需手动管理就绪状态:poll不自动从就绪列表中移除文件描述符,需要开发者手动处理,增加了代码复杂度与管理成本。
  3. 存在不必要的拷贝开销:每次调用poll都要将大量pollfd结构在用户态和内核态之间拷贝,和select类似,当监控数量大时,这种拷贝带来的性能损耗不可忽视。

典型场景(相比epoll落后)

  • 网络服务器(监控多个客户端连接)。
  • 同时处理文件和网络 I/O 的程序。

select 的对比

  • poll 使用数组存储 FD,无数量上限(取决于内存),而 select 使用 fd_set(通常限制为 1024)。
  • poll 的事件模型更清晰(通过位掩码区分事件),select 需重置 fd_set

通过合理使用 poll,可以实现高效的 I/O 多路复用,避免单线程阻塞导致的性能瓶颈。

五.本篇小结

  • 本篇带大家简略认识五种常见的IO模型以及多路转接复用的select及poll的接口的使用,也看到了有点与缺点,基于poll略胜一筹,但是还是比不上epoll,因此,下篇介绍epoll独特之处。