最为透彻的IO模型详解

2021年8月25日 138点热度 3人点赞 0条评论

Linux 系统为我们提供五种可用的 IO 模型:

  • 阻塞式 IO 模型

  • 非阻塞式 IO 模型

  • IO 多路复用模型

  • 信号驱动 IO 模型

  • 异步 IO 模型

IO处理过程

在聊IO模型之前,需要先了解一下Linux系统是怎么进行IO处理的:

当某个程序或已存在的进程/线程(后文将不加区分的只认为是进程)需要某段数据时,它只能在用户空间中属于它自己的内存中访问、修改,这段内存暂且称之为app buffer。假设需要的数据在磁盘上,那么进程首先得发起相关系统调用,通知内核去加载磁盘上的文件。但正常情况下,数据只能加载到内核的缓冲区,暂且称之为kernel buffer。数据加载到kernel buffer之后,还需将数据复制到app buffer。到了这里,进程就可以对数据进行访问、修改了。

现在有几个需要说明的问题。

1. 为什么不能直接将数据加载到app buffer呢?

实际上是可以的,有些程序或者硬件为了提高效率和性能,可以实现内核旁路的功能,避过内核的参与,直接在存储设备和app buffer之间进行数据传输,例如RDMA技术就需要实现这样的内核旁路功能。

但是,最普通也是绝大多数的情况下,为了安全和稳定性,数据必须先拷入内核空间的kernel buffer,再复制到app buffer,以防止进程串进内核空间进行破坏。

2. 上面提到的数据几次拷贝过程,拷贝方式是一样的吗?

不一样。现在的存储设备(包括网卡)基本上都支持DMA操作。什么是DMA(direct memory access,直接内存访问)?简单地说,就是内存和设备之间的数据交互可以直接传输,不再需要计算机的CPU参与,而是通过硬件上的芯片(可以简单地认为是一个小cpu)进行控制。

假设,存储设备不支持DMA,那么数据在内存和存储设备之间的传输,必须由内核线程占用CPU去完成数据拷贝(比如网卡不支持DMA时,内核负责将数据从网卡拷贝到kernel buffer)。而DMA就释放了计算机的CPU,让它可以去处理其他任务,DMA也释放了从用户进程切换到内核的过程,从而避免了用户进程在这个拷贝阶段被阻塞。

再说kernel buffer和app buffer之间的复制方式,这是两段内存空间的数据传输,只能由内核占用CPU来完成拷贝。

所以,在加载硬盘数据到kernel buffer的过程是DMA拷贝方式,而从kernel buffer到app buffer的过程是CPU参与的拷贝方式。

3. 如果数据要通过TCP连接传输出去要怎么办?

例如,web服务对客户端的响应数据,需要通过TCP连接传输给客户端。

TCP/IP协议栈维护着两个缓冲区:send buffer和recv buffer,它们合称为socket buffer。需要通过TCP连接传输出去的数据,需要先复制到send buffer,再复制给网卡通过网络传输出去。如果通过TCP连接接收到数据,数据首先通过网卡进入recv buffer,再被复制到用户空间的app buffer。

同样,在数据复制到send buffer或从recv buffer复制到app buffer时,是内核占用CPU来完成的数据拷贝。从send buffer复制到网卡或从网卡复制到recv buffer时,是DMA方式的拷贝,这个阶段不需要切换到内核,也不需要计算机自身的CPU。

4. 网络数据一定要从kernel buffer复制到app buffer再复制到send buffer吗?

不是。如果进程不需要修改数据,就直接发送给TCP连接的另一端,可以不用从kernel buffer复制到app buffer,而是直接复制到send buffer。这就是零复制技术。

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

当应用程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间。

阻塞式 IO 模型 (Blocking I/O)

在阻塞式 IO 模型中,应用程序发起IO请求后,会一直阻塞,直到在内核把数据拷贝到用户空间。

从IO处理过程流程图进行分析,阻塞式 IO 模型中app process从过程1到过程3都是阻塞的。只有当数据复制到app buffer完成或者发生了错误的时候,app process才会被唤醒处理它app buffer中的数据。其中,在过程2准备数据的过程中,由于是DMA的方式,所以cpu可以去处理其他进程的任务;而在过程3中数据的复制需要cpu的参与,所以app process会被阻塞。

非阻塞式 IO 模型(Non-Blocking I/O)

在非阻塞IO模型中,IO的处理过程如下:

  1. app process第一次发起系统调用(如read())后,立即返回一个错误值EWOULDBLOCK,而不是让app process进入睡眠状态;
  2. 虽然系统调用(如read())立即返回了,但app process还要不断地去发送read()检查内核:数据是否已经成功拷贝到kernel buffer了,这也被称为轮询(polling)。每次轮询时,只要内核没有把数据准备好,read()就返回错误信息EWOULDBLOCK;
  3. 直到kernel buffer中数据准备完成,再去轮询时不再返回EWOULDBLOCK,而是将app process阻塞,cpu将数据复制到app buffer;

注意:

app process在①到②过程不被阻塞,但是会不断进行轮询。在③被阻塞,将cpu交给内核把数据copy到app buffer。

IO 多路复用模型(I/O Multiplexing)

引入非阻塞IO提高了对CPU资源的利用,但是它仍然存在缺点:

每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。因此,我们引入了I/O 多路复用,可以通过一次系统调用,同时检查多个文件描述符,以找出其中任何一个是否可执行I/O操作。这是 I/O 多路复用的主要优点,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。

注意:

如果处理的连接数不是特别多的情况下使用I/O多路复用模型的web server不一定比使用多线程技术的阻塞I/O模型好。

Linux中提供的IO多路复用有三种,分别是:select、poll和epoll。

select

select可以监视多个文件句柄的状态变化。当select函数在运行的时候。用户进程会阻塞等待,直到有文件描述符就绪或超时。

select的过程如下

首先,通过FD_SET宏函数创建待监控的描述符集合,并将此描述符集合作为select()函数的参数,可以在指定select()函数阻塞时间间隔,于是select()就创建了一个监控对象。

当创建了监控对象后,由内核监控这些描述符集合,于此同时调用select()的进程被阻塞(或轮询)。当监控到满足就绪条件时(监控事件发生),select()将被唤醒(或暂停轮询),于是select()返回满足就绪条件的描述符数量,之所以是数量而不仅仅是一个,是因为多个文件描述符可能在同一时间满足就绪条件。由于只是返回数量,并没有返回哪一个或哪几个文件描述符,所以通常在使用select()之后,还会在循环结构中的if语句中使用宏函数FD_ISSET进行遍历,直到找出所有的满足就绪条件的描述符。最后将描述符集合通过指定函数拷贝回用户空间,以便被进程处理。

select函数的定义如下:

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 函数监视的文件描述符分3类,writefds(写状态), readfds(读状态), exceptfds(异常状态)。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可)。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

缺陷

  • 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024);
  • 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
  • select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;

以select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。

poll

select轮询所有待监听的描述符机制类似,但poll使用pollfd结构表示要监听的描述符,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他select的缺点依然存在。

poll函数定义如下:

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

pollfd结构:

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

pollfd结构包括了events(要监听的事件)和revents(实际发生的事件)。而且也需要在函数返回后遍历pollfd来获取就绪的描述符。

epoll

epoll的实现机制与select/poll机制完全不同,相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll在Linux内核中申请了一个简易的文件系统,通过三个函数epoll_createepoll_ctl, epoll_wait实现调度:

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_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源;
  • 调用epoll_ctl向epoll对象中添加连接的套接字;
  • 调用epoll_wait收集已经准备好的事件;

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

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是logn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

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

通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。

epoll的工作模式

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序可以通过记录IO状态,从而减少epoll_wait的调用,提高应用程序效率。

  • 水平触发(LT):默认工作模式,表示当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件。
  • 边缘触发(ET): 当epoll_wait检测到事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。边缘触发只在状态由未就绪变为就绪时只通知一次。

ET和LE容易走向两个极端,LT会在你不能处理读或写时不断epoll_wait返回告诉你可读可写从而浪费不必要的时间;而ET则可能会在你想读或想写时由于错过第一次时机从而获取不到对应的响应。

select、poll和epoll的区别

名称 监听FD数量 数据拷贝 操作复杂度 工作模式
select 1024 每次操作从用户空间拷入内核空间然后拷出 𝑂(𝑁) LT
poll 无限制,因为是它基于链表存储的 每次操作从用户空间拷入内核空间然后拷出 𝑂(𝑁) LT
epoll 连接数有上限,但是很大,1G内存可以打开10万左右的连接 add时拷贝一次,epoll_wait时利用MMAP和用户共享空间,直接拷贝数据到用户空间 𝑂(1) LT、ET

信号驱动 IO 模型(Signal-driven)

用户进程可以通过sigaction系统调用注册一个信号处理程序,然后主程序可以继续向下执行,当有IO操作准备就绪时,由内核通知触发一个SIGIO信号处理程序执行,然后将用户进程所需要的数据从内核空间拷贝到用户空间。

由于进程没有主动执行IO操作,所以不会阻塞,当数据就绪后,进程收到内核发送的SIGIO信号,进程会去执行SIGIO的信号处理程序,当进程执行read()时,由于数据已经就绪,所以可以直接将数据从kernel buffer复制到app buffer,read()过程中,进程将被阻塞。

注意:

注意:sigio信号只是通知了数据就绪,但并不知道有多少数据已经就绪。

更优化的信号驱动IO
默认的信号驱动IO,内核会在就绪时发送SIGIO信号通知进程,进程收到SIGIO信号后会执行SIGIO的信号处理程序。

但是,SIGIO信号是一个非排队的标准信号,它并非实时信号:

  • 非实时信号意味着:就绪时,内核可能不会立即发送SIGIO信号给进程;
  • 非排队信号意味着:内核发送SIGIO信号后,如果经常仍在执行SIGIO的信号处理程序而未退出,那么内核再次发送SIGIO信号将被进程丢弃

如果让内核在就绪时发送实时信号而非默认的SIGIO信号,那么信号会立即发送给进程,且重复的信号会进行排队,而不是丢弃。

但是,可排队的实时信号的数量是有限的,当超出排队数量后,将默认恢复为发送SIGIO信号。所以,即使使用实时信号作为就绪通知,进程也仍然需要配置SIGIO的信号处理程序。

异步IO模型(Asynchronous I/O)

异步IO是由POSIX AIO规范定义的。一般地说,异步IO的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。

异步IO与信号驱动的IO模型的主要区别在于:信号驱动的IO模型是由内核通知我们何时可以启动一个IO操作,而异步IO模型是由内核通知我们IO操作何时完成。

异步IO模型在整个过程中都不会被阻塞。

注意:

看上去异步很好,在复制kernel buffer数据到app buffer中时是需要CPU参与的,这意味着不受阻的进程会和异步调用函数争用CPU。并发量比较大,需要处理的连接可能就越多,CPU争用情况就越严重,异步函数返回成功信号的速度就越慢。如果不能很好地处理这个问题,异步IO模型也不一定就好。

参考资料

https://www.junmajinlong.com/coding/IO_Model/

https://gitlib.com/page/linux-io-event.html

https://wendeng.github.io/2019/06/09/%E6%9C%8D%E5%8A%A1%E7%AB%AF%E7%BC%96%E7%A8%8B/IO%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8%E4%B8%8Eepoll%E5%8E%9F%E7%90%86%E6%8E%A2%E7%A9%B6/

agedcat_xuanzai

这个人很懒,什么都没留下

文章评论