0%

同步I/O(阻塞I/O,非阻塞I/O),异步I/O

I/O模型概述

前面的Linux用户空间与内核空间简述中讲到,进程运行的时候分为用户态和内核态,用户态的程序是无法直接操作I/O设备读取数据的,需要通过系统调用来读取设备数据到内核buf,然后将数据拷贝回用户空间的buf。

整个数据请求过程,有两个关键步骤

  • 内核等待I/O设备准备好数据(比如等待完整的数据到达网卡设备,然后复制到内核中的某个buf)
  • 将数据从内核buf拷贝到用户空间的buf

上述两步,即是UNIX网络编程中提到的,一个输入操作通常包括的两个不同阶段。另外还介绍了五种I/O模型如下。

阻塞I/O(Blocking I/O)

默认情况下,Linux下的所有的socket都是阻塞的,一个典型的流程如下:

1

上图中,进程调用recvfrom,其系统调用直到数据报准备就绪(即是关键步骤中的第一步),并且将数据复制到用户空间的buf中(即是关键步骤中的第二步)或者发生错误之后才返回。

我们称进程在调用recvfrom到返回的这段时间内是被阻塞的,直到recvfrom成功返回之后,应用进程才开始处理数据报。

非阻塞I/O(Non-Blocking I/O)

Linux下可以通过设置将一个socket变成非阻塞,执行一个非阻塞的socket读的时候,流程如下:

1

  • 当用户进程调用recvfrom时,内核判断数据没有准备好,立即返回一个ewouldblock错误;
  • 返回ewouldblock之后,进程知道数据还没准备好,便会再发起一个recvfrom请求;
  • 内核判断数据还是没有准备好,再次返回一个ewouldblock错误;
  • 返回ewouldblock之后,进程再次发起一个recvfrom请求;
  • 这一次内核发现数据准备好,然后拷贝数据到用户空间buf,然后返回数据;
  • 进程收到数据,系统调用完成

从调用方来讲,成功得到数据之前的几次系统调用(即是关键步骤中的第一步)都没有等待,而是调用后立马就得到了一个结果,我们称进程在调用recvfrom到返回的这段时间内是非阻塞的。

对于这种不断循环尝试调用的过程,我们称之为轮询,这样做通常是在浪费CPU的时间。

I/O复用(I/O Multiplexing)

假设A和B都是socket,现在需要同时监听A和B的输入内容:

  • 方案一:采用阻塞模型,需要对A调用read,然后一直阻塞到数据到来,在这个过程中,即使B的数据已经准备好,但是也无法进行处理;
  • 方案二:采用非阻塞模型,需要轮询A和B调用read,直到有数据到来。

上述方案一明显无法支持多I/O设备的读写请求,瓶颈很明显;方案二能支持多I/O设备的读写,但是在数据没有准备好之前,会大量浪费CPU,此小节的I/O复用,能解决上述的大部分问题,我们先看一下使用I/O复用的流程:

1

  • 系统调用不再是直接调用recvfrom,而是调用select ,然后会阻塞,一直等到数据准备好,然后才返回;
  • 进程收到调用返回之后,再调用recvfrom,这个时候数据已经准备好了,直接将数据拷贝到用户空间buf,然后返回。

乍一看这种调用跟阻塞I/O模型没特别的变化,反而从一个系统调用变成两个系统调用,开销还大一些。而实际上,关键点在于select函数上。

select函数支持传递一组socket,上例中,只要将A和B两个socket都传递进去,只要任意一个socket的数据准备好,就会返回,这个时候用户进程再调用recvfrom操作,将数据拷贝到用户空间buf中。即是select可以同时处理多个socket。

I/O复用中,对于关键步骤中的第一步仍然是block的,只是进程是被select函数block,而不是被socket I/O给block。

select,poll

除了select之外,poll函数也能实现同样的功能,但是有如下几个缺点:

selectpoll函数的规范,定义,以及如何使用,这里就不赘述了,参考UNIX网络编程一书的6.3,6.10两节。

  • select的fd_set有总数限制,定义在FD_SETSIZE中,通常是1024
  • select会修改传入的fd_set,因此每次调用完之后,需要重新对参数进行设定
  • 内核在检查socket的时候,会遍历所有传进来的描述符,即使这个描述符不是活跃的,造成了大量CPU浪费
  • 调用函数的时候,需要将大量的描述符从用户空间拷贝到内核空间,产生大量的开销
  • 函数调用返回之后,需要遍历所有关心的描述符,才知道哪些描述符准备就绪

总之,selectpoll的缺点总结下来是:

  • 用户空间和内核空间之间,大量的数据拷贝
  • 很多环节都存在的循环遍历计算,浪费CPU时间
epoll

在没有epoll之前,一般都会选择select或者poll等I/O多路复用的方法来实现并发。但是在如今高并发,大数据场景越来越多的情况下,selectpoll已经被使用得越来越少,转而使用epoll这种更高效的I/O复用模型。epoll通过以下几个方法,来解决上述selectpoll等模型面临的问题:

  • 采用mmap

    epoll采用mmap将内核空间的一块地址与用户空间的一块地址的映射到相同的一块物理内存地址上(用户空间和内核空间的地址都是虚拟地址,最终都是要通过地址映射到物理地址上),从而减少用户态和内核态之间的数据交换。

  • 采用红黑树的数据结构存储要监听的socket

    上述mmap出来的内存地址,采用红黑树的数据结构,来保存需要监听的socket。每次需要新增/删除监听的socket的时候,都会修改红黑树上的数据(红黑树的修改时间复杂度为O(logN))。

  • 事件回调

    当添加一个事件的时候,会将事件与设备驱动程序建立回调关系,当相应的时间发生后,就会调用这个回调函数。这样可以做到不需要遍历所有监听的socket,而是等活跃的socket主动通知事件。

信号驱动式I/O模型(Signal Driven I/O)

为什么省略呢?因为实际中并不常用这个模型,重要的是我也没认真分析过这个模型。。。

略。(后续关于模型的对比,默认也不包含此模型)

异步I/O模型(Asynchronous I/O)

先看一下异步I/O模型的流程:

1

这个模型就比较简单了,当用户发起请求权的时候,会立即返回,然后内核会记录下需要处理的任务:

  • 等待数据准备好
  • 数据准备好之后,拷贝到用户空间buf
  • 发送信号告诉用户进程任务处理完毕

模型总结

四种模型对比如下:

1

阻塞I/O、非阻塞I/O、 I/O复用

这三个模型之间,只有关键步骤中的第一步不同,第二部都是一样的,都会等待数据拷贝完毕,才能返回。

  • 对于阻塞I/O来说,进程需要一直等到数据准备好,然后拷贝数据,最后才返回
  • 对于非阻塞I/O来说,进程需要不断轮询数据是否准备好,准备好之后拷贝数据,最后才返回
  • 对于I/O复用来说,需要等待有型号的描述符出现,等到结果之后拷贝数据,最后才返回

同步I/O、异步I/O

讲了半天,一直都没提到同步I/O的概念,所以大家说的同步I/O到底是个啥?

POSIX有两个术语的定义如下:

  • 同步I/O操作(synchronous I/O operation):导致请求进程阻塞,直到I/O操作完成
  • 异步I/O操作(asynchronous I/O operation):不导致请求进程被阻塞

根据上述定义,以上的前三种模型,在关键步骤的第二步的时候,都会阻塞进程,因此阻塞I/O模型,非阻塞I/O模型,I/O复用模型都属于同步I/O操作;只有最后一种异步 I/O模型,从开始调用,到最后收到数据,都没有阻塞进程,属于异步I/O操作。

“阻塞”“非阻塞”与“同步”“异步”的区别

好了,现在把这4个概念放到一起,为啥总是容易弄混淆呢? 我觉得主要有两方面原因:

  • 狭义和广义的不同理解

    • 本文中狭义的阻塞是指上述的阻塞I/O模型;而从广义上来讲,任何一次函数调用,只要调用过程中会等待某个事情,我们都可以认为他是广义上的阻塞的。

    • 本文中狭义的非阻塞是指上述的非阻塞I/O模型;而从广义上来讲,任何一次函数调用,不需要等待任何事情,执行完之后就返回,我们都可以认为他是广义上的非阻塞的。

    • 广义上的阻塞和非阻塞,有点约等于同步和异步的意思。
  • 不同层次的概念,被拿到一起来横向对比。

    • 阻塞I/O,非阻塞I/O,异步I/O是属于内核系统调用层次的,在这个层次中,所有的定义都是属于狭义的定义,指的就是上述的I/O模型;
    • 在应用层,有同步的函数调用(我们也可以说这是广义的阻塞),有异步的函数调用(我们也可以说是广义的非阻塞),这里属于广义的定义。

    讨论这几个概念的时候,需要区分不同的层次。

    • 应用框架封装的异步函数,底层可以是用阻塞I/O的方式来实现的(比如调用线程立马返回,派生出来的线程做I/O操作),也可以是用异步I/O的方式来实现的。但是不管底层用什么方式来实现,在应用层,我们都可以说这个函数调用是个异步的。
    • 应用框架封装的同步函数,底层也可以是用阻塞I/O的方式来实现的,也可以是用非阻塞I/O的方式来实现的。但是在应用层,我们都可以说这个函数调用是个同步的。

所以:

  • 从狭义上来说,阻塞I/O,非阻塞I/O都阻塞了进程,都属于同步I/O操作;异步I/O没有阻塞进程,属于异步I/O操作;
  • 从广义上来说,一般的应用框架可以封装一个同步或者异步的函数调用,这个函数跟底层是用阻塞I/O,非阻塞I/O还是异步I/O无关,只跟函数的调用形式有关。

参考



-=全文完=-