写在前面

我相信大部分人看到这些名词,都是一头雾水的,如果你去搜索引擎搜索,那么恭喜你,你又会被各种文章中的高大上的名词搞得云里雾里。那么,我们应该怎么理清这么名词之间的关系呢?

所谓 同步/异步/阻塞/非阻塞 IO ,是指操作系统中的对 IO 处理的不同方法,而 Java 对这些不同操作方法做了一些包装,由此有了 BIO / NIO / AIO 几种操作接口。

我不想复制一些高大上的概念,只是想尽量好好说话,说清楚他们之间的关系。

需求

  1. 有 A、B、C、D 四个线程可以生产文件,假设他们的返回的文件是一样的,对应我们的服务端

  2. 有 E、F、G、H 四个线程在随机时间向服务端上传一个文本,并且要求返回一个文件,对应我们的客户端

服务端和客户端可以在同一个操作系统中,也可以是在网络中。
在网络中,客户端的请求会接入到服务端的操作系统内核,所以和在同一个操作系统中没有太大区别。
如果在网络中,那么这可能就是一个 TCP/HTTP 的模型。

问题:要怎么设计一个程序来的完成上面的操作?

系统 IO 缓冲区

在讲之前,还要明白系统内核中的 IO 缓冲区的概念。
在现实生活中,一个人想要把想法告诉另外一个人,有两种方法,一种是靠嘴说给他听,对方要不停的确认,这样是很慢的。另外一种是直接写一篇 10 页的文稿一次性发给另外一个人,另外一个人有问题再重写一份文档把所有问题写在上面,那么多了这个文档的缓冲区,就可以提高交流的效率。

在系统里面,对两个用户之间的传输操作,首先会建立一个连接,然后会创建一个专用的缓冲区。
缓冲区有四个状态

我们现在有一个情景,就是用户端 E 向服务端 A 在随机时间上传一份文件。缓冲区会经历这四个状态。

  1. 缓冲区空:E 心情不好,还没传文件,这个时候缓冲区是空的

  2. 缓冲区非空:E 开始往缓冲区里面传数据了,现在缓冲区是非空的,A 知道之后会来查收

  3. 缓冲区满:如果 A 一直没查收这个文件,或者 A 的查收速度没有 E 上传的速度那么块。那么缓冲区会被填满。这个时候 E 知道了,就先等着。

  4. 缓冲区非满:E 等了一段时间,A 终于处理了一些 E 上传的数据了,缓冲区又有空间了,缓冲区把状态设置为缓冲区非满,E 知道之后,又可以往缓冲区传数据了。

最终,经过上面的“博弈”,缓冲区最终为空,文件传输完成。接下来可能又要有下一轮文件传输。
我们的几个 IO 模型就是围绕着上面的博弈展开的。

同步

关于 A 如何查收 缓冲区中的内容,我们有同步和异步两种模式。绝大部分应用都是同步查收的。就是说,A 主动 从缓冲区 剪切、粘贴 到自己的用户空间里面。剪切、粘贴的时间 CPU 要等着啥也不干,这个过程是比较费时的,但是也没有办法。

同步阻塞

我们来讨论另外一个问题,就是 A 怎么知道 E 要来传文件呢?换一种说法就是,A 怎么知道缓冲区由空变成非空了,要工作了。

阻塞就是这样一种方法:A 在缓冲区非空的时候,就把自己阻塞住(休眠),等着,啥也不干,一直到缓冲区非空了,系统就叫醒 A,A 这个时候就会从缓冲区查收数据了。A 处理完之后又把自己阻塞住,等待下一个非空状态。

这样做的坏处是什么呢?就是 E、F、G、H 想在随机时间 往 服务端传文件,建立了连接之后,服务端上面就要有 A、B、C、D 四个线程等着啥也不干。但是我们知道,创建线程是需要消耗内存的,这样子客户端多了之后,系统内存就捉襟见肘了。

同步非阻塞

1.0 版:忙轮询

非阻塞和阻塞相反。A 是不阻塞(休眠)的,他会定时定点就检查一下缓冲区是不是非空(可读)了,如果可读就可以工作了。

这样做有一个好处,就是 A 可以一次性把 E、F、G、H 接进来,创建四个缓冲区。然后把这些连接保存成一个 list ,然后 A 定时就检查这个 list 中的连接的缓冲区的状态。这样一个 A 就可以处理多个客户端,处理效率就提高了。

这种一条线程接进来多个客户端的操作,也叫 IO 多路复用。

但是如果是由应用层来遍历,也未免有点太傻了,如果 E、F、G、H 一直不传文件,A 就要一直耗费 CPU 去定时查询,所以这个傻事要由操作系统统一去干。

2.0 版: select、poll

我们把定时查询这个任务就交给系统去做,我们只需要把我们关心的用户的连接 ID(缓冲区)告诉系统。比如还是上面的例子,A 先自己把自己阻塞住,当 E、F、G、H 没有活干的时候,就一直保持阻塞状态。当 E、F、G、H 其中之一往系统内核的缓冲区上传了数据,那么,系统就会唤醒 A,并且告诉他,起来干活了。不过这个时候 A 对于要查收谁的文件还是一脸懵逼的,只能再自己去遍历一遍所有连接的缓冲区,看看哪个缓冲区的状态是非空的,然后再读。这就是 Select 的大致原理。

这样做避免了 1.0 版的 CPU 空转,但是唤醒 A 之后,A 还必须自己去遍历一下所有客户端连接的列表,是一个 O(n) 的操作,所以这个列表不能太长,select 简单粗暴的就定为了 1024 个。 也就是说默认一个线程只能处理 1024 个客户端。poll 改进了 list 的储存结构,这样就可以存更多的连接,不过也是治标不治本呀。

3.0 版: epoll

epoll 是 select 的改进

  1. 使用了更优的数据结构来管理连接列表,使得定位的时候更快

  2. 唤醒 A 之后,会传给他缓冲区非空的连接,A 直接使用即可。而不只是告诉 A 醒来,让 A 自己去找。所以这里是一个 O (1) 的复杂度。

这是目大多数网络应用的底层实现。

4.0 版: Eventloop

Eventloop 并不是操作系统底层的实现,但是在应用层提供了一种更高效的复用思路。

在 传统 epoll 应用中,普通线程只负责客户端的连接的读写操作,如果客户端没有操作,那么线程会被阻塞。而我们要做一些额外的事情可能就要再开线程。

那么,我们还有另外一种复用线程的思路,就是 Eventloop,我们有一个比较小的线程池,每个线程 都有一个 Eventloop, 上面有个事件队列,一方面,Eventloop 尝试 epoll,并且设置阻塞的超时时间,如果到达时间,仍没有客户端读写事件,就先处理事件队列上面的其他事件。我们可以打包一些要做的函数进事件队列,那么本来是负责 Epoll 的线程在本来应该阻塞的时候,还可以做些别的任务。这样就减少了整个系统的线程数量。

当然,要注意,我们有时候也叫 Eventloop 这种实现是异步的,但是和我们整篇文章讲的同步异步并不是在一个区域,我们讲的是系统级的连接的 IO 操作。而 Eventloop 则是一种在应用级的多任务调度方式。

异步

我们之前说了,在系统 IO 中,对于同步异步的概念,是说 A 如何查收 缓冲区中的内容。

而异步的意思是说,调用之后,马上返回一个内存引用,这个引用什么时候填充数据我们并不知道,但是我们可以告诉这个引用填充完数据之后应该干什么。然后这个事件会打包进任务队列,在不定时间执行。

异步 IO 的方式,我们的 A 就可以先开好一个用户空间的内存区域,然后把查收之后要做的事情打包成一个函数,全部告诉给内核,然后内核会自动帮你把数据从内核缓冲区复制到你指定的用户空间,然后调用你指定的函数。

这样,在 IO 读写的时候,阻塞的是系统内部的线程,而不是我们的 A 线程,这样 A 线程的处理效率会提高,也不用一直在阻塞和继续中徘徊。当然,也就没有阻塞还是非阻塞的区别了。

JDK 在 linux 上面的 AIO 实现方法是去维护一个线程池(Linux 的异步 IO 接口不给力)。而在 Windows 上面则有 IOCP 的异步 IO 黑科技,主要是在 IIS 上面使用。

然而,因为从内核缓冲区复制到用户空间这个地方的时间仍然是没有办法避免的,所以 IOCP 比 epoll 的 Eventloop 在性能上其实并没有太大优势,只是说是内核做异步在线程调度上面更有优势,可以节省一些 CPU 使用。

JDK 实现

IO

最传统的java.io 包下面的 ServerSocket 的写法,只支持阻塞同步。

NIO

普通java.nio 的用法
可以选择同步阻塞模式,和传统 IO 一致
也可以选择同步非阻塞 IO 的实现,底层的话,Windows 下面使用底层的 select ,Linux 下面使用底层的 epoll

AIO

java.nio.channels.Asynchronou* 下面的包的调用
AIO 是异步的实现,底层方面,在 Linux 使用 JDK 自建线程池,在 Windows 下面使用 IOCP,不过据说还是有些 bug

应用实现

Netty

Netty 默认是使用 NIO 底层 (同步非阻塞)。

但是在应用层面上,使用 Eventloop 的异步实现,即用一个比较小的线程池同时处理用户端的连接、数据复制,解析数据和执行业务代码。通常,Netty 已经帮我们将相关客户端的数据读取和解析(比如解析 HTTP 请求),我们只需要写具体的业务代码即可。通常,在业务代码中,我们应该尽可能少的使用同步代码,数据库读取也使用异步 NIO 连接(比如,使用 NIO 客户端 连接 MongoDB 或者 Redis ),从而最大化压缩线程因阻塞而浪费的时间。而确实可能会导致阻塞的耗时操作,也用异步封装成事件,发到另外的大线程池中。这样可以用较少的线程完成较多的任务,同时阻塞的时间尽可能小,以达到性能最大化的目的。

Netty 曾经也尝试过 AIO 作为底层,不过最后因为性能比 NIO 差,和兼容性的问题而放弃。为什么差我们也可以做一个猜测,就是 Netty 有一个 Eventloop 线程池 ,和操作系统的 AIO 线程池没有很好兼容,反而增多更多性能损耗。

Tomcat

Tomcat 默认是使用 NIO 底层(同步非阻塞),并且也是异步实现(Acceptor 和 Poller 和 Netty 的 Eventloop 模型如出一辙),我们跑的 SpringBoot 等等都可以跑在 Tomcat 上面,速度理论上是不错的。

可惜在 Tomcat 对于每一个请求,将我们整个 Servlet 、管道、监听器、拦截器 所有东西都打包在一起(当然里面全部都是同步的代码),形成一个超级大的 Runable ,发到线程池上面,导致整个处理请求的过程中多处都要同步等待(比如读取数据库,一般使用同步的方式,如 JDBC 是典型的同步应用),而等待的时间是要占着当前线程的,导致同时处理的任务量过大时,Tomcat 性能非常差。

这也告诉我们,NIO 并不是解决所有问题的万能药,如果我们在 Netty 中运行大量会阻塞很久的同步的代码,也会导致 Netty 性能大幅下降。

其他

另外,Nginx 、 Nodejs、Redis 网络底层都是 select 或者 epoll,也就是说,在系统层面,IO 是同步非阻塞的,只是在应用的任务调度层面,是异步的。而异步 IO 阵营可以说是门前冷落了,IOCP 只有 IIS 在大规模使用。

参考资料

https://www.zhihu.com/question/20122137/answer/14049112
https://cloud.tencent.com/developer/article/1005481
https://javadoop.com/post/tomcat-nio