BIO、NIO、IO多路复用(select/poll/epoll)、信号驱动IO、异步IO

Linux用户空间和内核空间

程序运行时,为了避免用户应用发生冲突,甚至发生系统冲突,用户应用和内核是分开的:
进程的寻址空间会划分为两部分:内核空间、用户空间。
当进程运行在用户空间时,我们称之为用户态,当进程运行在内核空间的时候,我们称之为内核态。

  • 用户空间只能执行受限的命令,而且不能调用系统资源,必须通过内核提供的接口来访问。
  • 内核空间可以执行特权命令,调用一切资源。

Linux为了提高IO效率会在用户空间和内核空间都加入缓冲区:

  • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写去设备。
  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。

其中读数据的过程如下所示:
在这里插入图片描述
读数据主要分为两部分:

  1. 等待数据就绪:我们向内核空间发起请求,等待数据,内核空间将数据准备好之后,放到内核的缓冲区。
  2. 从内核缓冲区拷贝数据到用户缓冲区。然后用户程序就可以对用户缓冲区内的数据进行操作。

阻塞IO(Blocking IO)

阻塞IO指的是在 等待数据阶段由内核拷贝数据到用户空间阶段 都阻塞等待的IO。

等待数据阶段: 用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,此时用户线程也处于阻塞状态。
拷贝数据阶段: 数据到达并拷贝到内核缓冲区,代表已经就绪;将内核数据拷贝到用户缓冲区;拷贝过程中,用户进程依然阻塞等待;拷贝完成,用户进程阻塞解除,处理数据。
在这里插入图片描述
用户应用调用recvfrom读取数据,数据未准备好,用户应用阻塞等待,直到1,2都完成才能解除阻塞等待。

非阻塞IO(Noblocking IO)

非阻塞IOrecvfrom操作会立即返回结果而不是阻塞用户进程。

等待数据阶段:
用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,返回异常给用户进程,用户进程拿到error之后,再次读取失败,之后不停调用recvfrom,直到数据就绪。

数据拷贝阶段:
用户进程阻塞等待,直到拷贝完成,用户进程解除阻塞,处理数据。

在这里插入图片描述
虽然第二个阶段是非阻塞状态,但是性能并没有得到提高,而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用(IO Multilpexing)

文件描述符File DescriptorFD, 是一个从0开始的无符号整数,用来关联Linux中的一个文件。在Linux中一切皆文件,例如常规文件、视频、硬件设备等,也包括网络套接字。

IO多路复用是利用单个线程来同时监听多个FD,并在FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

等待数据阶段:用户进程调用select,指定要监听的FD集合,内核监听FD对应的多个socket,任意一个或多个socket数据就绪就返回readable,该过程中用户线程阻塞。

数据拷贝阶段:用户线程找到就绪的socket,依次调用recvfrom读取数据,内核将数据拷贝到用户空间,用户进程处理数据。
在这里插入图片描述
IO多路复用中监听FD的方式,有多种实现,常见的有:selectpollepoll
其中:

  • selectpoll只会通知用户进程有FD就绪,但不确定是哪个FD,需要用户进程遍历FD来确认。
  • epoll则会在通知用户进程FD就绪的同时,把已经就绪的FD写入用户空间。

select

select的部分代码如下:

// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;
/* fd_set 记录要监听的fd集合,及其对应状态 */
typedef struct {
    
    
    // fds_bits是long类型数组,长度为 1024/32 = 32
    // 共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    // ...
} fd_set;
// select函数,用于监听fd_set,也就是多个fd的集合
int select(
    int nfds, // 要监视的fd_set的最大fd + 1
    fd_set *readfds, // 要监听读事件的fd集合
    fd_set *writefds,// 要监听写事件的fd集合
    fd_set *exceptfds, // // 要监听异常事件的fd集合
    // 超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间
    struct timeval *timeout
);

其中,存储FD的数据结构为fd_set, fd_set中有一个数组fds_bits,其大小为32,fds_bits数组的类型为long int4字节,因此总共为32x4x8=1024位,每个bit位代表一个fd0代表未就绪,1代表就绪

其过程如下:
在这里插入图片描述
在这里插入图片描述
select模式存在的问题

  • 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  • select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  • fd_set监听的fd数量不能超过1024

poll

poll的部分代码如下:

// pollfd 中的事件类型
#define POLLIN     //可读事件
#define POLLOUT    //可写事件
#define POLLERR    //错误事件
#define POLLNVAL   //fd未打开

// pollfd结构
struct pollfd {
    
    
    int fd;     	  /* 要监听的fd  */
    short int events; /* 要监听的事件类型:读、写、异常 */
    short int revents;/* 实际发生的事件类型 */
};
// poll函数
int poll(
    struct pollfd *fds, // pollfd数组,可以自定义大小
    nfds_t nfds, // 数组元素个数
    int timeout // 超时时间
);

IO流程:

  1. 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
  2. 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
  3. 内核遍历fd,判断是否就绪
  4. 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
  5. 用户进程判断n是否大于0
  6. 大于0则遍历pollfd数组,找到就绪的fd

select对比:

  • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
  • 虽然fd数量无限制,但是监听FD越多,每次遍历消耗时间也越久,性能反而会下降

epoll

epoll模式是对select和poll的改进,它提供了三个函数:

struct eventpoll {
    
    
    //...
    struct rb_root  rbr; // 一颗红黑树,记录要监听的FD
    struct list_head rdlist;// 一个链表,记录就绪的FD
    //...
};
// 1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);
// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
    int epfd,  // epoll实例的句柄
    int op,    // 要执行的操作,包括:ADD、MOD、DEL
    int fd,    // 要监听的FD
    struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
    int epfd,                   // epoll实例的句柄
    struct epoll_event *events, // 空event数组,用于接收就绪的FD
    int maxevents,              // events数组的最大长度
    int timeout   // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);

在这里插入图片描述
select模式存在的三个问题

  • 能监听的FD最大不超过1024
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态

`poll模式的问题

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

epoll模式

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

信号驱动IO(Signal Driven IO)

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

等待数据阶段

  1. 用户进程调用sigaction,注册信号处理函数
  2. 内核返回成功,开始监听FD
  3. 用户进程不阻塞等待,可以执行其它业务
  4. 当内核数据就绪后,回调用户进程的SIGIO处理函数

数据拷贝阶段

  1. 收到SIGIO回调信号
  2. 调用recvfrom,读取
  3. 内核将数据拷贝到用户空间
  4. 用户进程处理数据

在这里插入图片描述
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

异步IO(Asynchronous IO)

异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。

等待数据阶段

  1. 用户进程调用aio_read,创建信号回调函数
  2. 内核等待数据就绪
  3. 用户进程无需阻塞,可以做任何事情

数据拷贝阶段

  1. 内核数据就绪
  2. 内核数据拷贝到用户缓冲区
  3. 拷贝完成,内核递交信号触发aio_read中的回调函数
  4. 用户进程处理数据

异步IO模型中,用户进程在两个阶段都是非阻塞状态。

五种IO模型对比

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_36944952/article/details/126681307