IO模型

IO模型

1.概念

  • IO有内存IO, 网络IO和磁盘IO三种, 通常我们说的是后两者
  • 阻塞和非阻塞, 指的是函数/方法的实现方式, 即在数据就绪之前是立刻返回还是等待, 即发起IO请求是否会被阻塞
  • 以文件IO为例, 一个IO读过程是文件数据从磁盘 —> 内核缓冲区 —> 用户内存的过程. 同步和异步的区别主要在于数据从内核缓冲区 —> 用户内存这个过程 需不需要用户进程等待, 即实际的IO读写是否阻塞请求进程

从上面两点可以看出阻塞非阻塞和同步异步之间的区别, 其实对于一次network IO来说, 它会涉及到两个系统对象, 一个是调用这个IO的process(或者thread), 另一个就是系统内核(kernel). 当一个read操作发生时, 它会经历两个阶段 :

  • 1.等待数据准备(waiting for the data to be ready)
  • 2.将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

2.IO模型

1>阻塞IO(blocking IO)

在linux中, 默认情况下所有的socket都是blocking, 其读操作流程如下 :

在这里插入图片描述

当用户进程调用了recfrom这个系统调用, kernel就开始了IO的第一个阶段 : 准备数据. 对于network IO来说, 很多数据在一开始还没有到达(比如还没有收到一个完整的UDP包). 这个时候kernel要等待足够的数据到来. 而在用户进程这边, 整个进程会被阻塞. 当kernel一直等待数据准备好了, 它就会将数据从kernel中拷贝到用户内存, 然后kernel返回结果, 用户进程才解除blocking状态, 重新运行起来

所以, blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段都被Blocking)

2>非阻塞IO(non-blocking IO)

Linux下, 可以通过设置socket使其变为non-blockint. 当对一个non-blocking socket执行读操作时, 流程是这个样子

在这里插入图片描述

从图中可以看出, 当用户进程发出read操作时, 如果kernel中的数据还没有准备好, 那它并不会block用户进程, 而是立即返回一个error. 从用户进程角度来看, 它发起一个read操作后, 并不需要等待, 而是马上就得到了一个结果. 用户进程判断结果是一个error时, 它就知道数据还没有准备好, 于是它可以再此发送read操作. 一旦kernel中的数据准备好了, 并且又再次收到了用户进程的systemcall, 那么它马上把数据拷贝到用户内存, 然后返回

所以在非阻塞IO中, 用户进程其实是需要不断的主动询问kernel数据准备好了没有

3>多路复用IO(IO multiplexing)

有些地方将这种IO方式称为事件驱动IO. 我们都知道, select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO. 它的基本原理就是select这个function会不断轮询所负责的所有socket, 当某个socket有数据到达了, 就通知用户进程

在这里插入图片描述

当用户进程调用了select, 那么整个进程会被block, 而同时, kernel会 "监视"所有select所负责的socket, 当任何一个socket中的数据准备好了, select就会返回. 这个时候用户进程再调用read操作, 将数据从kernel拷贝到用户进程

使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求. 用户可以注册多个socket, 然后不断地调用select读取被激活的socket, 即可以在同一个线程内同时处理多个IO请求的目的. 而在同步阻塞模型中, 必须通过多线程才能达到这个目的. (所以在处理的连接数不是很高的时候, select/epoll的web server不一定比使用mult-threading + blocking IO的web server性能更好, 可能延迟还更大. select/epoll的优势并不是对于单个连接能处理的更快, 而是在于能处理更多的连接)

在多路复用模型中, 对于每一个socket, 一般都设置为non-blocking, 但是如上图所示, 整个用户Process其实是一直被block的. 只不过precess是被select()这个函数block, 而不是被socket IO给block. 所以select()与非阻塞IO类似

大部分Unix/Linux都支持select函数, 该函数用于探测多个文件句柄的状态变化. 下面给出select接口的原型 :

	FD_ZERO(int fd, fd_set* fds) 
    FD_SET(int fd, fd_set* fds) 
    FD_ISSET(int fd, fd_set* fds) 
    FD_CLR(int fd, fd_set* fds) 
    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, 
    struct timeval *timeout) 

这里, fd_set类型可以简单的理解为按bit位标记句柄的队列, 例如要在某fd_set中标记一个值为16的句柄, 则该fd_set的第16个bit位被标记为1. 具体 的置位, 验证可以使用FD_SET, FDISSET等宏实现. 在select函数中, readfds, writefds和exceptfds同时作为输入参数和输出参数. 如果输入的readfds标记了16号句柄, 则select()将检测16号句柄是否可读. 在select()返回后, 可以通过检查readfds是否标记16号句柄, 来判断该"可读"事件是否发生. 另外, 用户可以设置timeout时间.

参数readfds,writefds,exceptfds作为输入参数时, readfds应该标记所有的需要探测的"可读时间"的句柄. writefds和exceptfds应该标记所有需要探测的"可写事件"和"错误事件"的句柄

作为输出参数, readfds,writefds和exceptfds中保存了select()捕捉到的所有事件的句柄值. 程序员需要检查所有的标记位(用FD_ISSET()检查), 以确定哪些句柄发生了事件

上述模型主要模拟的是"一问一答"的服务流程, 所以如果select()发现某句柄捕捉到"可读事件", 服务器程序应及时做recv()操作, 并根据接收到的数据准备好待发送数据, 并将对应的句柄值加入writefds, 准备下一次"可写事件"的select()探测. 同样, 如果select()发现某句柄捕捉到的"可写事件", 则程序应及时做send()操作, 并准备好下一次的"可读事件"探测准备. 下图描述该模型的一个执行周期

在这里插入图片描述

这种模型的特征在于每一个执行周期都会探测一次或一组事件, 一个特定的事件会触发某个特定的响应. 我们可以将这总模型归类为**“事件驱动型”** 相比其他模型, 使用select()的事件驱动模型只用单线程(进程)执行, 占用资源少, 不消耗太多CPU,同时能够为多客户端提供服务.

但这个模型依然存在问题 : 首先select()接口并不是实现"事件驱动"的最好选择. 因为当需要探测的句柄值较大时, select()接口本身需要消耗大量事件去轮询各个句柄. 其次, 该模型将事件探测和事件响应混杂在一起, 一旦事件响应的执行体庞大, 则对整个模型是灾难性的.

select和poll的原理基本相同 :

  • 注册待侦听的fd
  • 每次调用都去检查这些fd的状态, 当有一个或多个fd就绪的时候就返回
  • 返回结果中包括已就绪和未就绪的fd
int poll(struct pollfd *fds, unsigned int nfds, int timeout);

poll使用pollfd类型的数组来作为fd描述符

struct pollfd {
               int   fd;         /* file descriptor */
               short events;     /* requested events */
               short revents;    /* returned events */
           };

相比select, poll解决了单独线程能够打开的文件描述符数量有限制这个问题 : select受限与FD_SIZE的限制, 如果修改则需要修改这个宏重新编译内核; 而poll通过一个pollfd数组向内核传递需要关注的时间, 避开了文件描述符数量限制

此外, select和poll共同具有的一个很大的缺点就是包含大量fd的数组被整体复制于用户态和内核态地址空间之间, 开销会随着fd数量增多而线性增大

epoll :

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll_ctl()用于向内核注册新的描述符或者是改变某个文件描述符的状态. 已注册的描述符在内核中会被维护在一棵红黑树上, 通过回调函数, 内核会将I/O准备好的描述符加入到一个链表中管理. 进程调用epoll_wait便可以得到事件完成的描述符

其实现如下 :

  • 调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)

  • 调用epoll_ctl向epoll对象中添加连接的socket

  • 调用epoll_wait收集发生的事件的连接

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

struct eventpoll{
    ....
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
    ....
};

每个epoll对象都有一个独立的eventpoll结构体, 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件. 这些事件都会挂载在红黑树中, 如此, 重复添加的事件就可以通过红黑树而高效的识别出来

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系, 当相应的事件发生时会调用这个回调方法.这个回调方法会将发生的事件添加到rdlist双链表中

在epoll中, 对于每一个事件, 都会建立一个epitem结构体

struct epitem{
    struct rb_node  rbn;//红黑树节点
    struct list_head    rdllink;//双向链表节点
    struct epoll_filefd  ffd;  //事件句柄信息
    struct eventpoll *ep;    //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}

当有事件发生时, 只需要检查eventpoll对象中rdlist双链表中是否有epitem元素即可. 如果rdlist不为空, 则把发生的事件复制到用户态, 同时将事件数量返回给用户

在这里插入图片描述

从上面的描述可以看出, epoll只需要将描述符从进程缓冲区向内核缓冲区拷贝一次, 并且进程不需要通过轮询来获得事件完成的描述符

epoll有以下优点 :

  • 基于事件驱动的方式, 避免了每次都要把所有的fd都扫描一遍
  • epoll_wait只返回就绪的fd
  • epoll的fd数量上限是操作系统的最大文件句柄数目, 这个数目一般和内存有关

另外, 对于IO多路复用还有一个水平触发和边缘触发的概念

  • 水平触发 : 当就绪的fd未被用户进程处理, 下一次查询依旧会返回
  • 边缘触发 : 无论就绪的fd是否被处理, 下一次不再返回. 理论上性能更高, 但是实现较为复杂, 任何意外的丢失时间都会造成请求处理错误
4>信号驱动IO(signal driven IO)
  • 开启socket信号驱动IO功能
  • 系统调用sigaction执行信号处理函数(非阻塞, 立即返回)
  • 数据就绪, 生成sigio信号, 通过信号回调应用来读取数据

在这里插入图片描述

5>异步IO(Asynchronous IO)

在用户发起read操作之后, 立即就可以去做其他的事. 另一方面, 在内核的角度, 当它收到一个asynchronous read之后, 首先它会立刻返回, 所以不会对用户进程产生任何block. 然后, 内核会等待数据准备完成, 然后将数据拷贝到用户内存, 当这一切都完成后, 内核会给用户进程发送一个signal, 告诉它read完成了

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值