I/O模型学习

Posted by BY KiloMeter on February 3, 2020

为什么会有多种IO模型

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

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

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。 - 阻塞 I/O(blocking IO) - 非阻塞 I/O(nonblocking IO) - I/O 多路复用( IO multiplexing) - 信号驱动 I/O( signal driven IO) - 异步 I/O(asynchronous IO)

概念理解

同步:指的是发出一个功能调用时,在没有得到结果前,该调用不能返回,也就是必须一件一件事情做

异步:当一个功能调用发出时,调用者不能立刻得到结果,实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。

阻塞: 阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。注意:同步!=阻塞,很多人会把同步认为就是阻塞,但对于同步调用来说,很多时候当前线程还是激活得,只是逻辑上当前函数没有返回结果而已。

非阻塞:非阻塞指不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回

Linux下的五种I/O模型

阻塞I/O

简介:进程会一直阻塞,直到数据拷贝完成。

应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。 如果数据没有准备好,一直等待….数据准备好了,从内核拷贝到用户空间,IO函数返回成功指示。

非阻塞I/O

简介:非阻塞IO通过进程反复调用IO函数(多次系统调用,并马上返回);在数据拷贝的过程中,进程是阻塞的。在不断调用IO函数过程中,会大量占据CPU时间。

I/O复用(select和poll)

简介:主要是select和epoll;对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听。I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的是,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

信号驱动I/O

简介:两次调用,两次返回。

首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

异步I/O

简介:数据拷贝的时候进程无需阻塞。

当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者的输入输出操作。

五种I/O模型比较

阻塞和非阻塞的区别

调用阻塞IO,会一直阻塞当前进程,直到操作完成。调用非阻塞IO,在内核还没准备完数据时会立刻返回

异步和非异步的区别

这里先看下POSIX的定义是这样子的:

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
  • An asynchronous I/O operation does not cause the requesting process to be blocked;

可以看到异步和非异步的核心区别在于I/O operation是否会被阻塞。按照这个定义,前面四种IO(阻塞IO,非阻塞IO,IO复用,信号驱动IO)都属于非异步IO(即同步IO)。

或许有人会有疑问,非阻塞IO不是没有被阻塞吗?注意,这里指的I/O operation是真正的I/O操作,即数据从内核拷贝到用户进程这个过程。非阻塞IO在第一步等待数据时用户进程确实是没有处于阻塞状态的,但是在第二阶段拷贝数据(这个阶段才是真正的I/O operation)时,用户进程是被阻塞的。只有最后一种属于异步IO,因为在最后拷贝数据的之后,用户进程并没有被阻塞,而是等到拷贝完后内核才通知用户进程。

I/O复用的select、poll和epoll

epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。

select

基本原理:select能够监听的文件描述符分为三类,分别是writefds、readfds、和exceptfds,调用select后进程会堵塞,直到有描述符就绪(可读、可写或者有异常),或者超时(select可以指定timeout),当select返回后,可以遍历描述符,找到就绪的描述符。

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

1、select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。

一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

2、对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。

当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll做的

3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

poll

poll本质和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。

但是它没有最大连接数限制,原因是它是基于链表存储的,但它也存在线性扫描socket的情况,随着监视的描述符的数量增长,其效率也会线性下降。同时也得维护一个存放大量fd的数据结构,并于用户空间和内核空间进行复制传递。

poll还有一个特点是水平触发,指的是如果报告了某个fd后,如果该fd没有被处理,下次仍会报告该fd。

epoll

epoll是select和poll的加强版

基本原理:epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

epoll对文件描述符的操作有两种模式,LT(level trigger即水平触发)和ET(edge trigger即边缘触发)。LT模式是默认模式,边缘触发指的是,它只会告诉进程哪些fd刚变为就绪态,并且只会通知一次(不像水平触发,通知后没有处理的话还会继续通知,边缘触发通知后如果没有处理的话,后续是不会再继续通知的)。

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

epoll的优点在于:

1、没有最大并发连接的限制(1G内存可以监听约10w个fd)

2、效率高,因为fd就绪后,可以通过调用回调函数通知进程

3、内存拷贝,使用mmap文件映射内存加速用户态和内核态的消息传递,减少了大量的复制开销。