Linux和Java的I/O模型

>>2020,微服务装逼指南

最近在学习Java IO这一块的东西,写篇文章总结一下。

所谓IO,是Input和Output的简称,也就是输入和输出。所以严瑾的写法应该是I/O,本文为了方便,就以IO代替。

IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。下面将介绍Linux IO和Java中的IO编程模型。

Linux中的IO模型

当前Linux主要有五种IO模型:

  • 同步阻塞
  • 同步非阻塞
  • IO复用
  • 信号驱动
  • 异步非阻塞

下面将一一介绍。

用户空间和内核空间

Linux为了保证系统安全,把内存分为了两种空间,分别是用户空间内核空间。只有内核空间可以直接调用硬件(比如磁盘,网卡等)。

而我们一般的进程是在用户空间的,位于用户空间的进程是不能直接调用硬件的,而是通过系统调用来请求内核空间协助完成IO操作。

那内核空间是怎么协助的呢?内核空间会为每个IO设备维护一个缓冲区buffer(比如文件系统IO,就是页缓存page cache)。内核收到来自用户空间的进程的系统调用请求后,从IO设备中获取数据到buffer中,再将buffer中的数据copy到用户进程的地址空间。我们以读取数据为例,可以把它分为两个阶段:

  • 准备数据:把数据从磁盘或网卡填充到内核空间的buffer里;
  • 拷贝数据:把数据从内核空间拷贝到用户空间。

同步阻塞

在linux中,是通过recvfrom这个系统调用去通知内核准备IO数据的。同步阻塞是最简单的IO模型,上述两个阶段都是阻塞的,所以性能不高。示意图如下:

同步阻塞

同步非阻塞

非阻塞的意思是你发出指令后就可以立即得到一个响应,不需要阻塞在那里等待。那如果你想得到这个指令的结果怎么办呢?其中一种方式就是轮询,不断地去尝试获取结果,如果得到结果,就进行下一步处理,否则就继续轮询。

同步非阻塞就是这种轮询的方式,这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到,通常是在只专门提供某种功能的系统中才有。并不是很常见。示意图:

同步非阻塞

IO复用

IO复用全称叫做IO多路复用(IO multiplexing)。

这里“复用”指的是对线程的复用。在上面的同步非阻塞模型中,仍然需要每个线程来单独操作一个IO,但如果使用多路复用模型,就只需要一个线程就可以操控多个IO连接

Linux中,提供了selectpollepoll三种接口函数来实现IO多路复用。Linux 2.4内核前主要是select和poll,自Linux 2.6内核正式引入epoll以来,epoll已经成为了目前实现高性能网络服务器的必备技术。尽管它们的使用方法不尽相同,但是本质上却没有什么区别。

其中select和poll有一些缺陷,它们会随着IO连接性能的增加,性能不断下降。所以现在一般都是使用epoll。想要研究这三者的原理的可以参考这篇文章

当用户进程调用了epoll,那么进程会被等待,内核中一个或者多个IO条件就绪了,epoll就会返回。这个时候进程再进行下一步的拷贝数据阶段。

示意图:

IO复用

这个图和同步阻塞的图其实并没有太大的不同,事实上,性能可能还更差一些。因为这里需要使用两个系统调用(select 和 recvfrom),而同步阻塞只调用了一个系统调用(recvfrom)。但是,用IO复用的优势在于它可以同时处理多个IO连接

所以,如果处理的连接数不是很高的话,使用IO复用的Web服务器不一定比使用多线程 + 同步阻塞性能更好,可能延迟还更大。IO复用的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

信号驱动

信号驱动IO模型是纯属信号来做的。

应用进程在发起IO时时通知内核,如果某个IO连接的某个事件发生时,请向我发一个信号。在收到信号后,信号对应的处理函数会进行后续处理,这个信号一般是SIGIO

信号驱动IO主要是在UDP套接字上使用,在TCP套接字上几乎是没有什么使用的。

在UDP上,SIGIO信号会在下面两个事件的时候产生:

  1. 数据报到达套接字
  2. 套接字上发上一部错误

因此我们很容易判断SIGIO出现的时候,如果不是发生错误,那么就是有数据报到达了。而在TCP上,由于TCP是双工的,它的信号产生过于频繁,并且信号的出现几乎没有告诉我们发生了什么事情。因此对于TCP套接字,SIGIO信号是没有什么使用的。

示意图:

信号驱动

异步IO

其实上述四种IO严格来讲都是“同步的”,因为尽管它们有些在第一阶段实现了IO的非阻塞,但第二阶段仍然都是阻塞的。

异步IO在两个阶段都是非阻塞的。所以又称为“异步非阻塞IO”。如下图所示:

异步IO

但数据完成拷贝后,内核就会就会产生一个信号或执行一个基于进程的回调函数来完成这次IO处理过程。

理论上来说,AIO似乎是一个很完美的IO模型。但Linux的AIO似乎做得并不好。详情可以参考以下两个链接:

Linux内核5.1版本推出了io_uring用于支持AIO,有兴趣的可以了解一下这篇文章:《Linux 5.1内核AIO 的新归宿:io_uring》

Java中的IO模型

BIO

在Java 1.4以前,只有一种IO模型,就是BIO(Blocking-IO,阻塞IO)。所以当时Java在处理高并发时性能并不好,通常使用多线程+阻塞IO来实现Web Server,然后使用线程池协助。比如Tomcat就是基于这个原理。

BIO相关的类和接口就是我们在入门Java的时候通常会了解到的InputStreamOutputStreamReaderWriter等等。

BIO虽然性能不高,但编程比较简单。如果你的程序对性能要求不大,可以考虑使用BIO。

BIO

NIO

在Java 1.4的时候,JDK提供了对NIO的支持。NIO底层是使用的上述的“IO复用”模型,也就是基于epoll。事实上目前很多高性能的服务或框架都用到了epoll,比如nginx,redis,nodejs等等。

NIO主要有这三个概念:Channel、Buffer、Selector。

基本上,所有的 IO 在NIO 中都从一个Channel 开始。Channel 有点象流。 数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中。这里有个图示:

channel & buffer

Selector允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。例如,在一个聊天服务器中。这是在一个单线程中使用一个Selector处理3个Channel的图示:

selector

后续会有文章详细分析Java NIO,本文主要做IO模型概述,所以这里不赘述。

Netty是Java NIO的集大成者,并且对网络编程模型的许多常见的问题做了处理和优化,后续也会写一点关于netty的文章。

AIO

AIO即异步IO,用到的就是上面介绍的最后一种网络模型:异步IO。Java AIO是在JDK 1.7引入的,也被称为NIO 2.0。

前面也提到了,Linux下的AIO做得并不好。所以JAVA AIO框架在windows下使用windows IOCP技术,在Linux下使用epoll多路复用IO技术模拟异步IO。

感兴趣的同学可以先看看这篇文章:《Java aio 编程》

原文链接:https://yasinshaw.com/articles/52