从5种网络IO模型到零拷贝

一、什么是IO

IO是输入input输出output的首字母缩写形式,直观意思是计算机输入输出,它描述的是计算机的数据流动的过程,因此IO第一大特征是有数据的流动;另外,对于一次IO,它究竟是输入还是输出,是针对不同的主体而言的,不同的主体有不同的描述。但是对于一个Java程序员来说,我们一般把程序当做IO的主体,也可以理解为内存中的进程。那么对于IO的整个过程大体上分为2个部分,第一个部分为IO的调用,第二个过程为IO的执行。IO的调用指的就是系统调用,IO的执行指的是在内核中相关数据的处理过程,这个过程是由操作系统完成的,与程序员无关。

二、一些基本概念

阻塞IO:请求进程一直等待IO准备就绪。
非阻塞IO:请求进程不会等待IO准备就绪。
同步IO操作:导致请求进程阻塞,直到IO操作完成。
异步IO操作:不导致请求进程阻塞。

举个小例子来理解阻塞,非阻塞,同步和异步的关系,我们知道编写一个程序可以有多个函数,每一个函数的执行都是相互独立的,但是 对于一个程序的执行过程,每一个函数都是必须的,那么如果我们需要等待一个函数的执行结束然后返回一个结果(比如接口调用),那么我们说该函数的调用是阻塞的,对于至少有一个函数调用阻塞的程序,在执行的过程中,必定存在阻塞的一个过程,那么我们就说该程序的执行是同步的,对于异步自然就是所有的函数执行过程都是非阻塞的。

这里的程序就是一次完整的IO,一个函数为IO在执行过程中的一个独立的小片段。

我们知道在Linux操作系统中内存分为内核空间和用户空间,而所有的IO操作都得获得内核的支持,但是由于用户态的进程无法直接进行内核的IO操作,所以内核空间提供了系统调用,使得处于用户态的进程可以间接执行IO操作,IO调用的目的是将进程的内部数据迁移到外部即输出,或将外部数据迁移到进程内部即输入。而在这里讨论的数据通常是socket进程内部的数据。

三、5种IO模型

1、首先我们来看看一次网络请求中服务端做了哪些操作。

clipboard_爱奇艺.jpg
在上图中,每一个客户端会与服务端建立一次socket连接,而服务端获取连接后,对于所有的数据的读取都得经过操作系统的内核,通过系统调用内核将数据复制到用户进程的缓冲区,然后才完成客户端的进程与客户端的交互。那么根据系统调用的方式的不同分为阻塞和非阻塞,根据系统处理应用进程的方式不同分为同步和异步。

2、阻塞式IO

clipboard.png
每一次客户端产生的socket连接实际上是一个文件描述符fd,而每一个用户进程读取的实际上也是一个个文件描述符fd,在该时期的系统调用函数会等待网络请求的数据的到达和数据从内核空间复制到用户进程空间,也就是说,无论是第一阶段的IO调用还是第二阶段的IO执行都会阻塞,那么就像图中所画的一样,对于多个客户端连接,只能开辟多个线程来处理。

3、非阻塞IO模型

对于阻塞IO模型来说最大的问题就体现在阻塞2字上,那么为了解决这个问题,系统的内核因此发生了改变。在内核中socket支持了非阻塞状态。既然这个socket是不阻塞的了,那么就可以使用一个进程处理客户端的连接,该进程内部写一个死循环,不断的询问每一个连接的网络数据是否已经到达。此时轮询发生在用户空间,但是该进程依然需要自己处理所有的连接,所以该时期为同步非阻塞IO时期,也即为NIO。
clipboard.png

4、IO多路复用

在非阻塞IO模型中,虽然解决了IO调用阻塞的问题,但是产生了新的问题,如果现在有1万个连接,那么用户线程会调用1万次的系统调用read来进行处理,在用户空间这种开销太大,那么现在需要解决这个问题,思路就是让用户进程减少系统调用,但是用户自己是实现不了的,所以这就导致了内核发生了进一步变化。在内核空间中帮助用户进程遍历所有的文件描述符,将数据准备好的文件描述符返回给用户进程。该方式是同步阻塞IO,因为在第一阶段的IO调用会阻塞进程。

4.1、select/poll

为了让内核帮助用户进程完成文件描述符的遍历,内核增加了系统调用select/poll(select与poll本质上没有什么不同,就是poll减少了文件描述符的个数限制),现在用户进程只需要调用select系统调用函数,并且将文件描述符全部传递给select就可以让内核帮助用户进程完成所有的查询,然后将数据准备好的文件描述符再返回给用户进程,最后用户进程依次调用其他系统调用函数完成IO的执行过程。
clipboard.png

4.2、epoll

在select实现的多路复用中依然存在一些问题。

1、用户进程需要传递所有的文件描述符,然后内核将数据准备好的文件描述符再次传递回去,这种数据的拷贝降低了IO的速度。
2、内核依然会执行复杂度为O(n)的主动遍历操作。

对于第一个问题,提出了一个共享空间的概念,这个空间为用户进程和内核进程所共享,并且提供了mmap系统调用,实现用户空间和内核空间到共享空间的映射,这样用户进程就可以将1万个文件描述符写到共享空间中的红黑树上,然后内核将准备就绪的文件描述符写入共享空间的链表中,而用户进程发现链表中有数据了就直接读取然后调用read执行IO即可。

对于第二个问题,内核引入了事件驱动机制(类似于中断),不再主动遍历所有的文件描述符,而是通过事件驱动的方式主动通知内核该文件描述符的数据准备完毕了,然后内核就将其写入链表中即可。

clipboard.png
对于epoll来说在第一阶段的epoll_wait依然是阻塞的,故也是同步阻塞式IO。

5、信号驱动式IO

在IO执行的数据准备阶段,不会阻塞用户进程。当用户进程需要等待数据的时候,会向内核发送一个信号,告诉内核需要数据,然后用户进程就继续做别的事情去了,而当内核中的数据准备好之后,内核立马发给用户进程一个信号,用户进程收到信号之后,立马调用recvfrom,去查收数据。该IO模型使用的较少。
clipboard.png

6、异步IO(AIO)

应用进程通过 aio_read 告知内核启动某个操作,并且在整个操作完成之后再通知应用进程,包括把数据从内核空间拷贝到用户空间。信号驱动 IO 是内核通知我们何时可以启动一个 IO 操作,而异步 IO 模型是由内核通知我们 IO 操作何时完成。是真正意义上的无阻塞的IO操作,但是目前只有windows支持AIO,linux内核暂时不支持。
clipboard.png

四、总结

前四种模型的主要区别于第一阶段,因为他们的第二阶段都是一样的:在数据从内核拷贝到应用进程的缓冲区期间,进程都会阻塞。相反,异步 IO 模型在这两个阶段都不会阻塞,从而不同于其他四种模型。
clipboard.png

五、直接内存与零拷贝

直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。直接内存申请空间耗费更高的性能,直接内存IO读写的性能要优于普通的堆内存,对于java程序来说,系统内核读取堆类的对象需要根据代码段计算其偏移量来获取对象地址,效率较慢,不太适合网络IO的场景,对于直接内存来说更加适合IO操作,内核读取存放在直接内存中的对象较为方便,因为其地址就是裸露的进程虚拟地址,不需要jvm翻译。那么就可以使用mmap开辟一块直接内存mapbuffer和内核空间共享,并且该直接内存可以直接映射到磁盘上的文件,这样就可以通过调用本地的put而不用调用系统调用write就可以将数据直接写入磁盘,RandomAccessFile类就是通过开辟mapbuffer实现的读写磁盘。

以消息队列Kafka来说,有生产者和消费者,对于生产者,从网络发来一个消息msg并且被拷贝到内核缓冲区,该消息通过Kafka调用recvfrom将内核中的msg读到队列中,然后加上消息头head,再将该消息写入磁盘。如果没有mmap的话,就会调用一个write系统调用将该消息写入内核缓冲区,然后内核将该消息再写入磁盘。在此过程中出现一次80中断和2次拷贝。但实际上Kafka使用的是mmap开辟了直接内存到磁盘的映射,直接使用put将消息写入磁盘。实际上也是通过内核访问该共享区域将该消息写入的磁盘。同时在Kafka中有一个概念叫segment,一般为1G大小。它会充分利用磁盘的顺序性,只追加数据,不修改数据。而mmap会直接开辟1G的直接内存,并且直接与segment形成映射关系,在segment满了的时候再开辟一个新的segment,清空直接内存然后在与新的segment形成映射关系。
clipboard.png

零拷贝描述的是CPU不执行拷贝数据从一个存储区域到另一个存储区域的任务,这通常用于通过网络传输一个文件时以减少CPU周期和内存带宽。

在Kafka的消费者读取数据的时候,如果当前消费者想读取的数据是不是当前直接内存所映射的segment怎么办?如果没有零拷贝的话,进程会先去调用read读取,然后数据会从磁盘被拷贝到内核,然后内核再拷贝到Kafka队列,进程再调用write将数据拷贝到内核缓冲区,最后再发送给消费者。实际上可以发现,数据没有必要读到Kafka队列,直接读到内核的缓冲区的时候发送给消费者就行了。实际上,linux内核中有一个系统调用就是实现了这种方式读取数据——sendfile,它有2个参数,一个是infd(读取数据的文件描述符),一个是outfd(客户端的socket文件描述符).消费者只需调用该函数,告诉它需要读取那个文件就可以不经过Kafka直接将数据读到内核,然后由内核写到消费者进程的缓冲区中。
clipboard.png

阅读 197

推荐阅读