高级IO

什么是IO ?什么是高效的IO

IO的定义及核心组成

  • IO本质:IO操作包含两个主要阶段:

    1. 等待:资源(例如数据)从内核态到用户态的可用性。

    2. 数据拷贝:数据从内核空间移动到用户空间,或反之。

  • 高效IO的关键

    • 减少等待的时间比重。

    • 优化数据的传输和拷贝过程。

IO的 方式

为什么多路复用IO是高效的代名词?

  1. 统一管理多个IO操作:单线程处理多个连接,减少线程开销。

  2. 减少阻塞时间:通过事件驱动的方式集中等待数据可用。

  3. 高扩展性epoll等机制避免了select/poll的大量文件描述符复制开销,适合高并发场景。

IO的几种方式与效率分析

IO方式

特点

等待机制

效率

应用场景

阻塞式IO

- 调用后进程阻塞,直到操作完成 - 每次处理一个IO操作

阻塞在调用函数上,直到数据准备就绪

低效率:等待时间高,阻塞浪费资源

传统系统,简单场景,无需复杂并发处理

非阻塞式IO

- 调用立即返回,进程主动轮询资源状态

不阻塞,但需要主动轮询资源是否就绪

效率一般:频繁轮询浪费CPU资源

简单并发处理,对实时性要求不高

信号驱动式IO

- 设置信号处理函数,由内核通知数据可用

不阻塞,内核通过信号通知

效率高:减少轮询和等待

实时性高的场景,如网络服务

多路复用IO

- 使用select/poll/epoll集中管理多个IO操作,单线程监听多个连接

不阻塞,集中监听多个资源,统一等待和处理

高效率:减少线程数量和阻塞开销

高并发场景,如Web服务器

异步IO

- 操作由内核完成,进程通过回调处理结果,无需等待和轮询

完全非阻塞,等待和数据拷贝都交由内核异步完成

最高效率:消除等待时间

高性能应用,如数据库、高性能服务器等

阻塞式IO和非阻塞式IO的主要区别在于等待数据的方式和进程的行为

特征

阻塞式IO

非阻塞式IO

IO操作返回

阻塞,直到数据准备好或操作完成

立即返回,如果数据未准备好,返回错误(如EAGAIN

进程/线程状态

阻塞,直到IO操作完成,其他任务无法执行

非阻塞,进程可以继续执行其他任务,而不等待IO完成

等待方式

等待数据准备好(进程被挂起)

主动轮询数据是否准备好,或者通过事件机制(如信号)监听

性能

对于高并发情况下效率较低,特别是在大量IO等待时

可以通过非阻塞模式减少阻塞,适合并发任务,但有轮询开销

编程模型

简单,易于理解和实现,但可能导致线程阻塞和资源浪费

编程相对复杂,需要处理轮询和状态检查,但更适合高并发场景

适用场景

小规模、低并发的应用,或者简单的同步场景

高并发、低延迟的应用,尤其是需要同时处理多个连接的网络服务

同步与异步的关系

  • 阻塞式IO非阻塞式IO都属于同步IO,即每个IO操作完成后,进程才能继续执行接下来的任务。

  • 异步IO(与阻塞式IO、非阻塞式IO不同)则是完全不同的模型。异步IO允许操作系统在后台处理IO请求,进程或线程通过回调或事件通知得知操作的完成,进程在等待期间不会被阻塞。

非阻塞 IO

fcntl

它提供了一种灵活的方式来管理文件描述符(fd)的属性,比如锁定文件、设置文件描述符的标志等。

SYNOPSIS

#include <fcntl.h>

#include <unistd.h>

int fcntl(int fd, int op, ... /* arg */ );

操作符

描述

F_DUPFD

复制文件描述符,使用大于或等于指定值的最小可用文件描述符。

F_DUPFD_CLOEXEC

复制文件描述符,并设置 FD_CLOEXEC 标志。

F_GETFD

获取文件描述符标志。返回值通常是 0 或 FD_CLOEXEC

F_SETFD

设置文件描述符标志。常见标志包括 FD_CLOEXEC

F_GETFL

获取文件状态标志。返回当前文件状态标志,如是否为非阻塞模式。

F_SETFL

设置文件状态标志。常见标志包括 O_NONBLOCK(非阻塞模式)和 O_APPEND(追加写模式)。

F_GETLK

检查文件是否被锁定。需要传递一个 struct flock 结构指针作为参数。

F_SETLK

设置文件锁(非阻塞)。需要传递一个 struct flock 结构指针作为参数。

F_SETLKW

设置文件锁(阻塞)。需要传递一个 struct flock 结构指针作为参数。

基于fcntl, 实现一个SetNoBlock函数, 将文件描述符设置为非阻塞

void SetNoBlock(int fd)
{
    int fl = fcntl(fd,f_GETFL);
    if(fl<0){
        printf("FL error");
    }
    fcntl(fd,SETFL,fl|O_NOBLOCK);
}
// F_GETFL将当前的文件描述符的属性取出来 在 从取出GETFL的基础上 设置回去直接天机O_NOBLOACK 参数

Select

select : IO = 等 + 拷贝

select 只负责等待 可以 一次性等待多个 fd select 本身没有 数据拷贝的能力,拷贝要用 read,write 来完成

select 是一种 I/O 多路复用技术,允许单线程监控多个文件描述符的状态(如是否可读、可写、发生异常),以便在一个线程中处理多个客户端连接。

网络接口补充

#include <sys/socket.h>

int setsockopt(int socket, int level, int option_name, const void *option_value, socklen_t option_len);
  • 函数用于设置指定套接字的选项。它允许程序控制套接字的行为,例如设置超时时间、启用或禁用某些功能等

  • 主要解决 客户端为断开 连接 所占用端口 不能重复使用的问题

  • 参数:

  • socket : 套接字描述符

  • level : 选项所在的协议层。例如,SOL_SOCKET 表示通用套接字选项,IPPROTO_TCP 表示TCP协议选项

  • option_name : 要设置的具体选项。例如,SO_REUSEADDR 表示允许重用本地地址,SO_RCVTIMEO 表示接收超时时间。

  • option_value : 指向包含新选项值的缓冲区。

  • open_len : option_value 缓冲区的长度

  • 返回值 : 成功返回 0 失败返回 -1 并设置 errno 指示错误原因 。

Select 接口

  #include <sys/select.h>
​
  typedef /* ... */ fd_set;
​
  int select(int nfds, fd_set *_Nullable restrict readfds,
             fd_set *_Nullable restrict writefds,
             fd_set *_Nullable restrict exceptfds,
             struct timeval *_Nullable restrict timeout);
  • nfds select 要监听多个 fd 中 值最大的fd + 1

  • readfds : 输入输出形参数

  • writefds : 输入输出形参数

  • exceptfds : 输入输出型参数

  • struct timeval * timeout : 输入输出型 参数 {秒 ,毫秒} | 当设置为{0,0} 的时候为 非阻塞 当设置为 {5,0}的时候 5s 以内阻塞 超出 5s 非阻塞返回一次|null 阻塞

  • struct timeval { time_t tv_sec; /* seconds / suseconds_t tv_usec; / microseconds */ };

  • RETURNVAl : 接收到几个文件描述符已经就绪了 ret==0 超时返回了 ret<0 select 调用失败了

  • SELECT 未来只关心的事件 , a 读 ,b 写, c 异常 对于任何一个 fd 就是这三种

  • fd_set : 位图结构 表示 文件描述符的集合

  • readfds 参数: 让用户和 内核之间可相互沟通 互相告诉对方自己关系的

    • 输入:输入时,用户设置希望关注的 fd

    • 输出:内核会将已经准备好读的 fd 对应比特位置为 1

    其他 readfds | execptfds 相同

  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);            //  清空集合,将所有比特位置为 0。

select 的 特点

  • select 可以 同时等待的文件 fd 的 个数 是 有上限的 除非 修改 内核 否则 无法解决

  • 必须借助 第三方的数组 来维护 合法的fd

  • 大部分的 参数是 输入输出型的 调用select之前重新设置所有的fd 调用之后要遍历所有的fd (遍历所有的fd 成本很高)

  • select 的 第一个文件描述符 是 fd + 1 告诉内核 的遍历范围

  • select 采用位图 , 用户->内核 内核 -> 用户 来回的数据考本 的成本很高

Poll

poll 相对于 select 的改进

解决文件描述符上限的问题

  • select 的问题select 函数在使用时,文件描述符的最大数量受到 FD_SETSIZE 常量的限制,通常为1024。这对处理大量并发连接时是一个限制。

  • poll 的改进poll 使用的是一个 pollfd 结构体数组,没有文件描述符数量的硬性限制,能够更好地处理大量并发连接。

每次调用都要重新设置关心的文件描述符

  • select 的问题:每次调用 select 都需要重新设置关心的文件描述符集合,这样会增加代码复杂度和调用开销。

  • poll 的改进pollpollfd 结构体数组可以在多次调用之间保持不变,只需要更新需要修改的部分,提高了效率。

输入输出分离

  • select 的问题select 函数的读、写和异常事件都需要通过不同的文件描述符集合来管理,代码上可能会比较复杂。

  • poll 的改进poll 使用 pollfd 结构体中的 events 字段来统一表示读、写和异常事件,简化了代码。

poll接口

  #include <poll.h>
​
  int poll(struct pollfd *fds, nfds_t nfds, int timeout);
​
  #define _GNU_SOURCE         /* See feature_test_macros(7) */
  #include <poll.h>
​
  int ppoll(struct pollfd *fds, nfds_t nfds,
            const struct timespec *_Nullable tmo_p,
            const sigset_t *_Nullable sigmask);
  • int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  • struct pollfd *fds // 动态数组

  • struct pollfd {
    int   fd;         /* file descriptor /      // 文件描述符
    short events;     / requested events / // 请求的事件
    short revents;    / returned events */ // 返回的事件
    };

  • 用户输入 看 fd + events // 告诉内核 要关系你的 事务

  • 内核输出 看 fd + revents // 用户关心的事件已经 就绪了

  • nfds表示fds数组的长度

  • int timeout

  • timeout > 0 // timeout 时间以内 阻塞

  • timeout < 0 // 阻塞

  • timeout = 0 // 非阻塞

  • events 和 revents 的取值

  • 描述

    POLLIN

    有数据可读

    POLLPRI

    有紧急数据可读(通常是带外数据)

    POLLOUT

    数据可以写入(不会导致阻塞)

    POLLERR

    错误条件(只在 revents 中返回)

    POLLHUP

    挂起事件(只在 revents 中返回)

    POLLNVAL

    无效请求(文件描述符未打开)

poll 的缺点

  • 需要轮询 pollfd 结构体poll 返回后,仍需轮询 pollfd 数组来获取就绪的文件描述符,增加了处理复杂性。

  • 数据拷贝开销大:每次调用 poll 都需要将大量的 pollfd 结构从用户态拷贝到内核态,导致性能开销较大。

  • 效率线性下降:当同时连接的大量客户端中只有少数处于就绪状态时,poll 的效率会随着监视的文件描述符数量的增长而线性下降。

epoll

epoll 是 Linux 提供的一种高效 IO 多路复用机制,其核心是通过红黑树和就绪队列的组合高效管理文件描述符的事件

epoll 相关 接口

相关结接口 使用简单 原理 有些抽象

#include <sys/epoll.h>

  • int epoll_create(int size);  // 表示期望处理的最大事件数量

  • int epoll_ctl(int epfd, int op, int fd,struct epoll_event *_Nullable event);

描述

EPOLL_CTL_ADD

epoll 实例中添加一个新的文件描述符

EPOLL_CTL_MOD

修改已经在 epoll 实例中的文件描述符

EPOLL_CTL_DEL

epoll 实例中删除一个文件描述符以下是 epoll 模型中常用的宏,用于 epoll_ctl 函数的操作类型:

int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

epoll_data | epoll_event

#include <sys/epoll.h>
​
struct epoll_event {
 uint32_t      events;  /* Epoll events */
 epoll_data_t  data;    /* User data variable */
};
​
union epoll_data {
 void     *ptr;
 int       fd;
 uint32_t  u32;
 uint64_t  u64;
};
  • eveents

  • 描述

    EPOLLIN

    表示对应的文件描述符可以读(包括对端 close

    EPOLLOUT

    表示对应的文件描述符可以

    EPOLLRDHUP

    表示对方关闭了连接

    EPOLLPRI

    表示对应的文件描述符有紧急数据可读

    EPOLLERR

    表示对应的文件描述符发生错误

    EPOLLHUP

    表示对应的文件描述符被挂起

    EPOLLET

    设置边缘触发模式(默认是水平触发模式)

    EPOLLONESHOT

    设置一次性事件(处理后事件将自动移除)

    EPOLLWAKEUP

    防止系统进入休眠或降低休眠级别

epoll 原理

epoll 的 核心 组件

  • 红黑树

    • 管理文件描述符 每次 EPOLL_CTL_ADD 都会向 红黑树中添加文件fd 或者 删除

    • 二叉平衡树 高效

  • 就绪队列

    • 当某个 文件描述符 事件触发的时候 fd 会 添加到就绪队列

    • epoll_wait从就绪队列中取出已就绪的fd

epoll 的 工作流程

  • 初始化

    • 调用 epoll_create 创建一个 epoll 实例,内核会为此分配一个结构体,其中包含红黑树和就绪队列。

  • 添加文件描述符

    • 调用 epoll_ctl,将文件描述符添加到红黑树。 文件描述符会被与指定的事件类型(EPOLLIN/EPOLLOUT/...)一起注册。

  • 事件检测

    • 内核监听所有红黑树上的文件描述符 当文件描述符上的事件发生时,将该描述符加入就绪队列。

  • 获取事件

    • 调用 epoll_wait,从就绪队列中获取已触发的事件,并返回给用户

epoll 如何 检测 文件就绪 ?

  • 检测工作由底层内核通过回调机制完成

  • 文件描述符在内核层绑定了一个回调函数。 当文件描述符有状态变化时内核会通过回调通知 epoll

  • 事件触发后,epoll 将文件描述符从红黑树映射到就绪队列中。

为什么 epoll 性能高?

内核事件驱动

  • epoll 使用事件驱动模型,而不是轮询模型。

  • 只有当事件发生时,内核才通知 epoll,这大大减少了不必要的系统调用和资源消耗。

就绪队列机制

  • 只将已就绪的文件描述符放入就绪队列,避免了遍历所有文件描述符的开销

红黑树高效管理

  • 红黑树使得文件描述符的动态增删查操作高效,能够应对大量文件描述符的场景

支持大规模并发

  • epoll 的设计允许同时管理数以万计的文件描述符("1-N" 模型),并且性能不会因文件描述符数量增加而明显下降。

事件批量返回

  • epoll_wait 能够一次性返回多个就绪的文件描述符,减少了系统调用的次数

epoll的 ET/LT 模式

LT 水平触发 ET 边缘触发

水平模式

默认模式

如果一个文件描述符就绪,epoll_wait 会一直返回该事件,直到用户处理完成

简单但可能导致重复通知,降低效率

垂直模式

高效模式

当文件描述符就绪时,仅通知一次

用户需要一次性读取或写入所有数据,否则可能丢失通知

对程序要求较高,需要通过非阻塞 IO 配合使用

EPoll 能不能 直接 发送呢 ?

epoll 是事件驱动的机制,只有当某个事件触发时,内核才会通知用户程序。而在发送数据时,我们必须确保发送条件是满足的

  • 发送缓冲区有空间

  • 网络通道可以正常发送数据

用户程序不能直接判断发送缓冲区是否有空间,因此只能依赖 epoll 通过事件通知来判断。

  • 当发送缓冲区有空间时,EPOLLOUT 事件会触发。

  • 如果没有空间,直接发送会导致阻塞(在阻塞模式)或失败(在非阻塞模式)

什么是写事件就绪?

写事件就绪(EPOLLOUT)的条件是:

  • 发送缓冲区有可用空间

  • 表示当前可以向这个 socket 写入数据,而不会阻塞

  • 写事件默认 是就绪的: 大多数发送缓冲区的空间是充足的写事件一直是可用的

    启动服务器时,写事件几乎总是就绪的,因为发送缓冲区尚未被填满

按需设置写事件:

  • 因为写事件通常是就绪的,所以对于大部分程序来说,不需要一直监听 EPOLLOUT

  • 当遇到需要多次发送数据发送缓冲区可能会满的情况时,才需要按需注册 EPOLLOUT 事件。

为什么 文件描述符需要 自己的发送缓冲区 ?

  • 当发送数据较多时,可能无法一次性发送完成

  • 用户程序的发送缓冲区 :

    • 如果数据未能一次性发完,需要把剩余的数据存储在用户程序的缓冲区中,等待下次写事件就绪时继续发送

  • 内核发送缓冲区:

    • 这是由 TCP 协议栈维护的,当用户程序通过 send 将数据写入时,数据会先进入内核的发送缓冲区

  • 处理失败

    • 当内核发送缓冲区满时,写操作可能会失败(返回 EAGAIN),需要等待写事件再次触发。

    • 用户程序需要保存未发送的数据,并在下一次 EPOLLOUT 事件触发时,继续尝试发送。

写事件的使用场景

发送时的初始状态:

  • 用户程序尝试直接发送数据。

  • 如果发送成功并且数据全部发送完,则不需要注册 EPOLLOUT

发送未完成:

  • 如果发送缓冲区满,导致部分数据未发送:

  • 注册 EPOLLOUT,等待写事件就绪。

  • 保存未发送的数据到用户缓冲区。

写事件触发:

  • 当发送缓冲区有空间时,内核触发 EPOLLOUT

  • 用户程序从用户缓冲区取出剩余数据,继续发送。

数据发送完成:

  • 取消 EPOLLOUT 的监听,避免不必要的系统调用。

两种高效的事件处理模式:Reactor模式和Proactor模式_proactor模式和reactor模式应用场景-CSDN博客