今天来讲一下操作系统的I/O模型,主要是设计5种模型,I/O多路复用,以及Reactor模型和零拷贝

I/O模型

内核态与用户态

内核态: 可以访问系统资源, 比如CPU, 内存, 网络, 外设

用户态: 只能访问进程自己的资源, 无法访问系统资源

用户态需要访问系统资源时, 需要CPU切到内核态, 读取资源后再切回用户态. 中间涉及堆栈上下文的切换, 为避免频繁切换, 有了”用户缓冲区”和”系统缓冲区”.

当用户进程需要从”磁盘/网络”中读取数据时, 系统会将”系统缓冲区”的数据复制到”用户缓冲区”. 若”系统缓冲区”中没有对应数据, 系统会将当前进程挂起, 处理其他进程. 等数据到达”系统缓冲区”后, 系统将数据拷贝至”用户缓冲区”, 然后才会通知进程, 注意不同IO模型方式不同.
五种IO模型, IO即磁盘/网络读写

再说一下IO发生时涉及的对象和步骤。对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
1)等待数据准备 (Waiting for the data to be ready)
2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO模型的区别就是在两个阶段上各有不同的情况。

阻塞IO(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

img

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。

非阻塞IO(non-blocking IO

Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

img

从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有。

多路复用IO(IO multiplexing)

IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

img

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句:所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

异步IO(Asynchronous I/O)

img

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

signal driven IO,即信号驱动IO

信号驱动IO, 第一阶段”请求进程”向内核注册信号后, 不阻塞. 当”数据准备好”后, 内核通过信号通知”请求进程”, “请求进程”调用receive, 阻塞取得数据

多路复用分为三种

I/O的多路复用:select/poll/epoll

一个进程在任一时刻只能处理一个请求,但是处理请求事件控制在1ms以内,那么1秒就可以处理上千个请求,多个请求复用了一个进程,这就是多路复用,也可以叫做时分多路复用。

select/poll

select实现多路复用的方式是将已连接的Socket放到一个文件描述集合,然后调用select函数将文件描述符拷贝到内核里面,让内核检查是否有网络事件的产生,检查的方式很暴力,就是通过遍历文件描述符的方式,当检查到有事件产生之后,将Socket标记为可读或者可写,接着将整个文件描述符拷贝到用户态里,然后用户态还需要遍历的方法找到可读可写的Socket,然后在对其处理。

所以对于select方式,需要进行2次遍历文件描述符集合,一次在内核态,一次在用户态,然后还会发生2次拷贝文件描述符集合,先从用户态传入内核空间,然后内核修改之后,在传出到用户空间。

select使用固定长度的BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在Linux系统中,由内核中的FD_SETSIZE限制,默认最大值1024,只能监听0-1023的文件描述符。

poll不再使用BitsMap存储所关注的文件描述符,取而代之只用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是两者本质没有太大的区别,都是使用线性结构存储进程关注的Socket集合,一次都需要遍历文件描述符来找可读或者可写的Socket,时间复杂度为O(n),而且也需要在用户态和内核态之间拷贝文件描述符集合,随着并发上来,性能的损耗会呈现指数级增长。

epoll

epoll相对于上面的有两个方面的改进:

  • epoll在内核使用红黑树来跟踪所有待检测的文件描述字,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树,红黑树是个高效的数据结构,增删改查的复杂度是**O(logn)**,通过对红黑函数的操作,不需要像select/poll每次操作都传入整个socket杰哥,只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到就绪队列事件中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率。

image-20211206143711836

epoll的方式即使监听的Socket数量越多,效率不会大幅度降低,能够同时监听的socket的数目也非常的多了,上限是系统定义的进程的打开的最大文件描述符的个数,因而epoll被称为解决C10K问题的利器。

epoll支持两种触发模式,分别是边缘触发(ET)和水平触发(LT)。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait中苏醒⼀次,即使进程没有调⽤用read 函数从内核读取数据,也依然只苏醒⼀次,因此我们程序要保证⼀次性将内核缓冲区的数据读取完
  • 使用水平触发模式时,当被监控的socket上有可读的事件发生时,服务器不断的从epoll_wait中苏醒,直到内核缓冲区数据被read读完才结束,目的是告诉我们有数据需要读取。

image-20211206150607107

image-20220313113637509

BIO线程模型

对于传统的网络框架而言,服务端通常采用的是BIO的通信模型。对于BIO通信模型,它通常使用一个专门的线程来负责接收网络连接,然后再为每一个连接单独创建一个线程来进行数据的读写、编解码、业务处理等操作。其IO模型可以用如下图表示。

图片

显然,BIO的通信模型的优缺点很明显。优点就是程序的代码简单,复杂度低,开发人员容易上手。缺点是,对于每一个客户端连接,服务端都需要为其创建一个线程来处理数据,当并发较高时,就会创建很多线程,这对服务端而言简直就是灾难。因为创建太多线程后,系统资源会占用较高,而且CPU在多个线程之间进行切换时,频繁的切换上下文,会严重影响服务的性能。

既然线程创建太多了,那我们是不是可以使用线程池来解决问题呢?当一个新连接创建好以后,我们不再为其单独创建一个连接,而是将其交由线程池来处理,那这种方案是否可行呢?

答案是不行。为什么呢?线程池在这里只能解决线程无限增长的问题,但是在进行读写数据时,由于read操作和write操作都是阻塞的,在这段期间,线程会挂起,什么事情也干不了。当多个客户端来连接时,由于线程池中的线程,都阻塞在前面连接的读写数据操作上了,此时新来的连接,只能等待,所以对于BIO而言,使用线程池最终还是无法解决高并发的问题。

那么怎么办呢?这个时候NIO出现了,NIO是非阻塞IO。对于NIO而言,服务端和客户端之间的读写数据,不再是阻塞的了。基于NIO实现的网络框架,它们底层的IO模型通常是基于Reactor模型来实现的。Reactor模型又可以分为三种:单线程模型、多线程模型、主从多线程模型。

Reactor单线程模型

Reactor单线程模型中,只有一个线程。这个线程既负责客户端的接入,还负责数据的读写、编解码、业务逻辑处理等工作。IO模型示意图如下。

图片

Reactor单线程使用的是异步非阻塞IO,所有的读写操作都是非阻塞的,因此理论上一个线程可以完成所有的工作。当一个新连接来接入时,通过Acceptor类可以进行TCP的连接,当TCP连接创建完成后,可以通过Dispatcher类将对应的请求数据(即ByteBuffer)派发到指定的Handler上进行编解码操作,最后再通过该线程将数据发送给客户端。
对于一个并发量较小的场景,可以使用单线程模型来处理。但是当并发较高时,单线程就无法满足了。理由如下:

  1. 一个NIO线程显然无法支撑多个连接的接入,即便NIO线程的CPU负荷达到100%,也无法满足海量数据的编解码、读取和发送。
  2. 当CPU负载较高后,处理就会变慢,这样就会造成大量的客户端出现连接超时。当客户端发现连接超时后,又会尝试进行重新请求,这样更加会加重NIO线程所在的CPU的负载,最终就会导致系统负载高,处理慢,成为系统的性能瓶颈。
  3. 可靠性低。一旦NIO线程因为处理数据中出现异常,或者进入到死循环,那将导致整个系统不可用。

Reactor多线程模型

为了解决Reactor单线程模型的问题,Reactor多线程模型出现了。在Reactor多线程模型中,由一个NIO线程来负责客户端的接入,连接创建完成后,再由一组线程来处理数据的读写、编解码、业务处理等操作。示意图如下。

图片

在Reactor多线程模型中,由一个单独的NIO线程来充当Acceptor的角色,它负责监听服务端的端口,并接收客户端的连接。然后由一个线程池来处理数据的读写、编解码等操作。线程池可以采用Java中的线程池,它有一个任务队列和多个NIO线程,因此一个NIO线程可以同时处理多个连接,但是一个连接只属于一个NIO线程。
Reactor多线程完美的解决了单线程存在的问题,它也几乎能满足大部分应用场景。但是由于它只使用一个NIO线程来负责处理新连接的接入,因此在特殊场景下,例如新连接的创建,服务端需要进行安全认证等操作,由于认证可能会耗时较长,这个时候再使用一个线程来负责处理百万连接,显然无法满足要求,这最终会成为系统的性能瓶颈。

Reactor主从多线程模型

Reactor主从多线程模型则解决了多线程模型的缺点,主从多线程模型中由一组NIO线程来负责处理新连接的接入,另外一组NIO线程来处理IO读写、编解码、业务逻辑处理等操作。因此它是两个线程池,负责新连接接入的线程池称之为主线程池,负责数据读写、编解码操作的线程池称之为从线程池。其示意图如下:

图片

当一个客户端来连接服务端时,主线程池会从线程池中选择出一个NIO线程,来充当Acceptor的角色,负责新连接的接入。当连接创建完成后,再将其通过Dispatcher派发到主线程池,由主线程来进行安全认证等操作,当安全认证等操作完成后,会将这个新连接绑定到从线程池的一个NIO线程上,后续则由这个NIO线程来进行数据的读写、编解码等操作。

零拷贝

DMA技术,也就是直接内存访问,在进行I/O设备和内存的数据传输的时候,数据搬运的工作全部交给DMA控制器,而CPU不再参与任何数据搬运相关的事情,这样CPU就可以去处理别的事务。

image-20211205222024478

具体过程:

image-20211205222043262

可以看出,CPU不再参与数据搬运的工作,全程由DMA完成,但是CPU在这个过程中也是必不可少的,因为传输什么数据,传输到哪里,都需要CPU来告诉DMA控制器。

传统的数据传输

数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取或写入。

代码通常需要两个系统调用:

image-20211205223523581

image-20211205223532133

期间,一共发生了四次用户态和内核态的上下文切换,因为发生了两次系统调用。

其次,发生了四次数据拷贝,其中两次是DMA的拷贝,另外两次是CPU拷贝。

想要提高文件传输的性能,就需要减少用户态和内核态的上下问切换和内存拷贝的次数

用户缓冲区是没有必要的,因为我们不会对数据的在加工

零拷贝的实现方式:

  • mmap+write
  • sendfile
mmap+write

在前面我们知道, read() 系统调用的过程中会把内核缓冲区的数据拷备到用户的缓冲区里,于是为了减少这⼀步开销,我们可以用 mmap() 替换 read() 系统调用函数

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区⾥的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作

image-20211205225217402

image-20211205225235780

sendfile

在Linux内核版本2.1中,提供了一个专门发送调用函数的sendfile(),函数形式如下:

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后⾯两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的⻓度。

⾸先,它可以替代前⾯的 read() 和 write() 这两个系统调⽤,这样就可以减少⼀次系统调⽤,也就减少了 2 次上下文切换的开销。

其次,该系统调⽤,可以直接把内核缓冲区⾥的数据拷贝到 socket 缓冲区⾥,不再拷贝到⽤户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图

image-20211205225626398

但这还不是真正的零拷贝技术,如果网卡支持SG-DMA技术,还可以进一步减少通过CPU将内核缓冲区的数据拷贝到Socket缓冲区的过程。

于是,从 Linux 内核 2.4 版本开始起,对于⽀持网卡⽀持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下

  • 第一步,通过DMA将磁盘的数据拷贝到内核缓冲区里
  • 第二步,缓冲区描述符和数据长度传到Socket缓冲区,这样网卡的SG-DMA控制器就可以将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到socket缓冲区,这样就减少了一次数据拷贝。
  • image-20211206130249553

这就是所谓的零拷贝技术,因为没有在内存层面去拷贝数据,全程没有CPU来搬运数据,所有数据都是通过DMA来传输的。总体看来,零拷贝技术可以吧文件传输的性能提高至少一倍以上

Netty 的零拷贝主要包含三个方面:

  • Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
  • 对于传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,我们就需要创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf(CompositeByteBuf),就可以避免这样的操作,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,这也是“零拷贝”的另一个体现。
  • Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。