最全五大I/O模型浅析—伪代码+动图+流程版本

目录

0、了解同步异步阻塞非阻塞

1、阻塞I/O模型(BIO)-同步阻塞

伪代码:

伪代码动图流程: 

细节展开:

整体缩略流程: 

2、非阻塞IO模型-同步非阻塞

伪代码:

细节展开:

整体缩略流程: 

 3、IO多路复用模型(事件驱动)-同步阻塞

伪代码:

细节展开:

select/poll/epoll的区别

总结:

4、异步I/O模型(AIO)-异步非阻塞

5、信号驱动式I/O模型-阻塞

6、总结


0、了解同步异步阻塞非阻塞

在之前这篇就是要弄懂你之—— 同步 异步 阻塞 非阻塞中,我大概讲述了这四者的概念,重温一遍,就是同步异步是个操作方式,阻塞非阻塞是线程的一种状态。在本篇文章里这些意思就需要深究一下

同步 就是在发出一个“调用”时,在没有得到结果之前,该调用就不返回。

异步 就是“调用”发出后,就直接返回了,没有返回结果。

阻塞调用 就是结果(或有效数据)返回之前,当前线程会被挂起。

非阻塞调用 就是即使没有立刻得到结果(或有效数据),当前线程也不会被挂起。

这里似乎特别容易混淆同步和阻塞,异步和非阻塞的概念。这里再加一个描述:

对于请求的发起者,是否需要等到请求的结果(同步),还是说请求完毕的时候以某种方式通知请求发起者(异步)。在这个语义环境下,阻塞与非阻塞,是指请求的受理者在处理某个请求的状态,如果在处理这个请求的时候不能做其它事情(请求处理时间不确定),那么称之为阻塞,否则为非阻塞。

这里异步操作和异步IO要区分开来,一般我们编程讲的是异步操作,和讲到IO操作的异步是有点区别的。我上面那篇文章主要讲的是异步操作的异步。

在IO操作中,概念更加细一点,不过上面理解好了,下面的也容易理解。关键来了,这里区别上面说的通过我们用户层使用的加线程的小技巧实现异步,而是让操作系统内核来帮我们实现异步或者非阻塞。例如让内核使用信号通知或者回调的方式都是属于异步操作,例如让内核给阻塞函数read等设置非阻塞模式就属于非阻塞。

同步和异步的概念描述的是用户线程与内核的交互方式

阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式

1、阻塞I/O模型(BIO)-同步阻塞

以套接字模型为例:在进程空间中调用recvfrom, 其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才可以返回,在此期间会一直等待,进程在从调用recvfrom 开始到它返回的整段事件内都是阻塞的。

ps:举例read()函数,一般是同步阻塞的。会经历磁盘=>内核态缓冲=>用户态缓冲,当内核把数据从磁盘读出来到内核态的缓冲再复制到用户态的缓冲后,才会返回。也就是说只有当申请读的内容真正存放到buffer中后(user mode的buffer),read函数才返回。对于write操作通常是异步的。因为 linux中有page cache机制,所有的写操作实际上是把文件对应的page cache中的相应页设置为dirty,然后write操作返回。这个时候对文件的修改并没有真正写到磁盘上去。所以说write是异步的,这种方式下 write不会被阻塞。如果设置了O_SYNC标志的文件,write操作再返回前会把修改的页flush到磁盘上去,发起真正的I/O请求,这种模式下会阻塞。

伪代码:

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

伪代码动图流程: 

细节展开:

整体缩略流程: 

å¨è¿éæå¥å¾çæè¿°

所以当这种时候,线程或进程就会被挂起,不多开线程的话,就无法执行其他事情了,效率比较低,这时就引入了非阻塞IO的概念

2、非阻塞IO模型-同步非阻塞

非阻塞可以通过系统提供的函数fcntl(POSIX)或ioctl(Unix)设为非阻塞模式后,recvfrom 从应用层到内核获取数据的时候,如果有数据收到,就返回数据,如果缓冲区没有数据,会直接返回一个EWOULDBLOCK信息。这样是、不会阻塞线程了,但是你还是要不断的轮询来读取或写入。

伪代码:

listenfd = socket();   // 打开一个网络通信端口
fcntl(listenfd , F_SETFL, O_NONBLOCK);//设置非阻塞!!
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

细节展开:

整体缩略流程: 

如果IO连接多的话,那只能多开线程解决,但又涉及到线程多的开销和切换问题,这时就引入了IO多路复用

 3、IO多路复用模型(事件驱动)-同步阻塞

IO多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。  

伪代码:

先用描述符加入select监听 ,然后调用循环调用select就行了

fd_set allset; //监听的文件描述符表
	
FD_ZERO(&allset);
FD_SET(connfd1, &allset);//加入select监视
FD_SET(connfd2, &allset);//加入select监视


while(1) {
  nready = select(&allset);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  
  if(fd == connfd2) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
  }  
}

不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。

只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。

细节展开:

可以看到用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。 当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别, 甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。 但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。 用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。 而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

select/poll/epoll的区别

select 的三个问题:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

poll本质上和select没有区别,主要就是去掉了 select 只能监听 1024 个文件描述符的限制。

epoll 主要就是针对select 的三点进行了改进。

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
    具体,操作系统提供了这三个函数。

这里也介绍epoll的伪代码

第一步,创建一个 epoll 句柄
int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

第三步,类似发起了 select() 调用
int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

总结:

虽然解决了多个阻塞IO需要开多线程的问题 ,但是等待数据 和 将数据复制到用户进程这两个阶段都是阻塞的。select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。  

4、异步I/O模型(AIO)-异步非阻塞

用户程序可以通过向内核发出I/O请求命令,不用等I/O事件真正发生,可以继续做另外的事情,等I/O操作完成(包括等待数据和将数据从内核复制到用户自己的缓冲区两个阶段),内核会通过函数回调或者信号机制通知用户进程。这样很大程度提高了系统吞吐量。

这才是真正的异步(“调用”发出后,就直接返回了,没有返回结果),最优秀的做法了,为什么主流的模型还是使用IO多路复用呢,原因似乎是支持异步的操作系统内核不多,所以都用的很少,这里也不多说,有兴趣可以参考这篇文章使用异步 I/O 大大提高应用程序的性能

5、信号驱动式I/O模型-阻塞

跟异步IO有点类似,也是通过信号进行处理,而这里并没有让内核进行将数据从内核复制到用户缓冲区的操作,所以在recvfrom还是会阻塞。这里也不展开讲了信号驱动式I/O - 故事 - 博客园

这个模式也不差啊,但为什么不常使用这个模式呢?查了一下可能是因为信号机制在操作系统中似乎有点被过渡设计,当有大量IO操作的时候,可能会因为信号队列溢出导致没法进行通知。或者是对于tcp来说,信号驱动式io 对于tcp套接字几乎无用,很多情况都能导致tcp套接字产生SIGIO信号,而且也无法精确的获得消息报到达时间。

6、总结

这张图可以看出阻塞式I/O、非阻塞式I/O、I/O复用、信号驱动式I/O他们的第二阶段都相同,也就是都会阻塞到recvfrom调用上面就是图中“发起”的动作。异步式I/O两个阶段都要处理,完全交给内核了。这里我们重点对比阻塞式I/O(也就是我们常说的传统的BIO)和I/O复用之间的区别。

因为异步IO和信号驱动式IO模型受限,所以主流上还是采用了IO多路复用模型,根据此模型还衍生出了Reactor、Proactor设计模式,放在下一篇讲。

本博客是博主个人学习时的一些记录,个别文章加入了转载的源地址还有个别文章是汇总网上多份资料所成,在这之中也必有疏漏未加标注者,如有侵权请与博主联系。

参考:

浅析Reactor设计模式_summerZBH123的博客-CSDN博客_reactor设计模式

关于同步、异步与阻塞、非阻塞的理解 - Rabbit_Dale - 博客园

常用4种IO模型(同步/异步/阻塞/非阻塞的概念) - 假程序猿 - 博客园

一文读懂I/O多路复用技术_新栋BOOK-CSDN博客_io多路复用

IO多路复用到底是不是异步的? - 知乎

使用异步 I/O 大大提高应用程序的性能

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值