Java网络编程中异步编程的理解

时间:2021-03-19 20:16:30

前言

这篇文章主要是总结自己对于网络编程中异步,同步,阻塞和非阻塞的理解,这个问题自从学习NIO以来一直困扰着我,,其实想来很久就想写了,只不过当时理解不够,无从下手。最近在学习VertX框架,又去熟悉了下Netty的代码,因为了对于多线程也有了更深的理解,所以才开始对于这些概念有了理解,用于理清思路,本文需要有良好的多线程和网络编程基础,不适合初学者。

一、异步,同步,阻塞和非阻塞的理解

关于这四个概念在IO方面的理解我贴两个链接,他们已经有了很好的说明我就不再讲述:

  1. 怎样理解阻塞非阻塞与同步异步的区别? - 严肃的回答 - 知乎
  2. IO - 同步,异步,阻塞,非阻塞

以前在学习c++中muduo只是记得陈硕说的epoll是一个同步非阻塞的模型,但是网上很多人说Reactor模型是一个异步阻塞的模型,在学习Netty的时候官网是这么介绍的:

Netty is an asynchronous event-driven network application framework
for rapid development of maintainable high performance protocol servers & clients.

Netty是一个异步的高性能网络框架,那么到底是谁说错了?

其实大家都没有错误,只是角度不同。
先说说什么IO是异步的?异步其实是针对数据从内核拷贝到用户进程空间这个操作是谁完成的,同步IO非常好理解,当用户进程发起一个read操作的时候发生一次系统调用,然后内核检查有没有数据,如果有则复制数据到进程空间,用户进程继续执行。而异步IO中复制数据到进程空间这个操作是内核帮你完成的,等完成之后再来通知你,执行你的逻辑。Reactor模型中,EventLoop线程在select到有可读数据之后,然后在自己去读取数据,所以从这个角度来讲Reactor模型确实是同步的,在Linux的五种IO模型中只有异步IO是异步的。

那么为什么Netty说他是一个异步网络库呢,这其实是另一个角度的阐述,对于网络库的作者来说,他们面向的是Linux提供的这些api,所以说多路复用的Reactor是同步的没问题。那么对于Netty的使用者来说,我们面向的是Netty,Netty进一步封装了IO操作,在我们发起IO操作的时候它返回了一个Future,我们可以提供一个监听器来传入我们的回调,当IO操作完成时会执行我们的逻辑,我们的这个操作相对于Netty就是异步的。

所以Reactor是同步非阻塞的,Netty是异步非阻塞的。

二、异步编程从用户层面和框架层面不同角度的理解

Java中的Future是异步的吗?

对于这个问题,我想相信很多同学都会认为是异步的,这里我认为是同步的,下面谈谈我的理解。
先想想一个异步操作需要哪些元素,我认为需要发起者,执行者,执行逻辑,回调逻辑。流程: 发起者请求执行者去执行所需逻辑,然后在成功之后调用回调逻辑。Future中缺了什么?没错,就是那个回调!

我们使用Future的模式一般是:投递一个任务到线程池得有个Future,然后去执行其他可以并行的操作,操作完之后去调用Future的get方法获取结果或者isDone判断是否执行完毕。这里的Future只是对于计算结果的一个建模,我们在后面需要使用的时候再去轮询(轮询也是同步非阻塞的一个标志)或者阻塞,他提供的了一个非常好的特性:非阻塞!所以我认为Future是一个同步非阻塞的实现。也正是因为Future没有实现异步的特性,在jdk1.8之后新增了CompletableFuture提供了异步的特性。

注意异步元素的发起者和执行者可以是同一个线程,最常见的例子就是NodeJs的单线程模型。拿Netty的线程来具体,你在EventLoop中发起一个写请求后得到一个Future,你可以设置回调,下次执行这个回调的还是EventLoop线程

用户角度的理解

这里主要说说在使用异步编程的一点理解,因为平时还是用为主,我们作为框架的使用者有必要了解一些常见的使用范式。就我目前接触的最多还是CompletableFuture,Netty和VertX,当时也写过一点Js,Js主要也是回调的用法。我知道的用法如下:

  1. 回调 这种是最常见的,相信也是最容易理解的,Js和VertX很多都采用了这个实现,我们在调用一个函数的时候提供一个响应结果的回调。响应式编程就是结合函数式和异步回调的一个产物,我相信以后会越来越常见
  2. 监听器 这个是Netty的实现,Netty将很多同步的地方改成了异步同时返回一个Future,我们可以通过这个Future添加监听器,执行得到结果时的逻辑
  3. 组合式 相对于回调式,在实现多个回调时代码扁平化,可以了解下CompletableFuture的用法和实现真的是非常的优雅

因为异步的高性能,很多时候我们自己也想把一个操作封装成异步的,就需要明白到底什么是异步,明白异步需要的元素,你会发现如果不借助以后的异步组件将一个操作封装成异步非常的困难,所以最简单的方案就是将你的回调最终传递到已有异步的组件中。

举2个简单的例子:

  1. 我们利用CompletableFuture.supplyAsync(Object::new).thenAccept(o -> System.out.println(o));这一行非常简单的代码实现了一个异步,Object::new会被投递到线程池中,然后执行完成后执行打印语句。
  2. VertX的例子,VertX将很多同步的操作封装成了异步的操作,比如场景的发起Http请求的,他的底层实现就是将这个操作委托给了Netty

框架角度的理解

框架层面的理解有助于我们在写代码中不会用错。有没有想过一个异步操作框架给你做了什么?
当你发起一个操作的时候,框架会去执行你的逻辑,在执行完毕时(成功或异常)去修改状态并执行你的回调修改状态并执行你的回调这个操作在JDK中放在了CompletableFuture中,在Netty中则单独采用了Promise接口,其实两者的实现是非常类似的(方法名都取的差不多)。以Netty举例分为Future和Promise两个方法,作为用户我们更应该关心Future的接口,Promise是框架层面需要实现的,我们在自己去实现的时候值得我们去学习里面的思想。

不过我认为我们直接使用Promise的这种接口的机会很少,Netty和VertX场景下还是有机会用到,在用到Promise接口的时候应该考虑下是否合理,检查下是不是在同一个线程中,是不是可以简单的接口代替。给一个简单的错误示例:

这里说下Promise,我们知道Js中也有一个Promise,千万不要当成类似的东西,两者毫无干系,Netty的Promise是对完成操作的行为的建模,Js的Promise是为了组合各个异步的调用。

import io.VertX.core.Future;

public class AuctionHandler {

  public Future<Void> handle() {
    // 请求级别变量
    Context context = new Context();
    context.future.tryComplete();
    return context.future;
  }

  public static class Context {
    Future<Void> future = Future.future();
  }
  public static void main(String[] args) { 
    //  注意这里的handle方法返回的Future是VertX的。
    //  这里的方法都是在同一个线程中执行的,完全没有异步化,所以可以改成传递一个普通的接口即可
    new AuctionHandler().handle().setHandler(event1 -> System.out.println("handler exec!"));
  }
}

虽然这个的代码错误看上去很低级,但是在开发VertX应用时需要时刻保持警惕。另外还有一点需要说明:当返回给你的Future已经是完成状态时,如上面的代码示例,你再增加回调,这个回调还会被执行,Netty和CompletableFuture在添加回调的时候都是检查状态是否完成,完成的话直接投递到相应线程执行。

三、为什么使用异步

为什么要使用异步,相信很多同学都知道是为了高性能,那么异步为什么高性能?

这里先谈谈NodeJs和Java,对于NodeJs,很多人听说性能十分高,"秒杀"Java。我当时一直无法理解,为什么Js能超过Java,
首先Node是单线程的,虽然可以借助第三方库来实现多线程,另外Jvm作为业界最优秀的虚拟机,那么Node到底是靠了什么超过了Java?这里的关键就在于Node的Io模型采用了Reactor模型,可以处理大量的连接。Java中的Web开发是以Servlet为主导,采用了同步阻塞模型,虽然用线程池实现n个连接用m和线程做优化,但是当有大量连接时,线程数量过多导致的线程调度成本会很高,另外在线程处理Io的时候也是同步阻塞,如果对方返回很难会导致当前线程一直无法释放,所以Tomcat这种不适合处理大量连接的场景。

我们知道Jetty的底层实现就是Reactor模型,Tomcat在8之后默认也用了Reactor是不是会大幅提高性能?不幸的是,虽然可以提高一些性能但是还是无法和Node一较高低,他解决的是Http连接那一块的阻塞问题,但是由于Servlet的编程模型,大量的同步阻塞操作还是无法避免,比如你在一个请求中去访问了数据库,这个线程就会一直被占用,一定程度上你可以通过增加线程来缓解但是线程过多又会增加调度的成本,可能会导致虚拟机假死。所以如果你的处理中有这种耗时操作,那他就是你的瓶颈,你的qps的上限就很低。在高并发场景下,Servlet的瓶颈会十分突出,只能通过大量的堆机器来水平扩展,但是没有很好的榨干服务器的性能。

所以我们需要的是编程模型的改变,像Nodejs那样在同步阻塞的地方进行异步非阻塞或者异步阻塞化。Spring5.0中的 WebFlux给了一个对应的解决方案,提供了响应式编程的模型用以取代Servlet,他对常见同步阻塞的地方进行了重写,如Redis和Mysql等常见的IO。很早之前VertX(早期名字Node.X,Java版的Nodejs)框架也提供了这样的编程模型,对很多同步阻塞的地方进行了重写,这个框架十分轻量级,社区活跃度非常高,使用起来非常方便。这两个底层都是Netty,不得不说Netty实在是太强大了。也从另外一个角度说明设计的重要性,语言反而是其次。NodeJs,WebFlux和VertX都采用了类似的Reactor模型,高性能服务器领域这个模型几乎已经是最佳实践,理解这个模型就和多线程一样重要。我觉得拿Servlet和NodeJs来做性能的对比,是十分不公平的。NodeJs在Java领悟的对手应该是VertX这种框架,关于高性能Web框架的对比,techempower这个网站已经给出了详细的排名,排名前十的大部分是Jvm语言,Nodejs在五十名之后了,所以不要在拿Servlet去和NodeJs做对比了,Servlet这种模型在高并发领悟一定会被逐渐取代。所以要深入理解响应式编程,拥抱响应式编程,现有的代码以及未来的开发都可以用响应式编程来做优化。

那么异步到底解决了什么问题?

上面举的例子只是简单说明了现有的异步非阻塞框架的性能优势。但是这个问题我也无法给出准确的解释,只是谈谈我自己的理解:

  1. 非阻塞很好理解,如果是阻塞的,那个当前的用户线程一定被hang住,直到数据写完或者读完(这个过程中这个线程就是没用的,所以我们需要开启大量的线程),如果非阻塞可以立即返回,继续处理其他任务。
  2. 异步的理解我用一个例子来说名:Netty中发起一个写操作时立即返回了一个Future,用户可以提供一个监听器执行写操作完成后的逻辑。试想如果这里是同步非阻塞的,即调用Future的sync方法(不要在EventLoop中调用,导致死锁),那就会白白浪费一个线程,

程序运行过程中始终是围绕着两个主题:IO、CPU。CPU和IO的速度差距十分大,异步和Reactor模型都是为了平衡这个差距,让CPU能充分利用起来,不要因为IO和其他同步操作导致线程Hang住,始终处于可运行的状态,可以使用少量的线程充分利用CPU。

四、理解这些能在实际中的应用

很多人可能会疑问就算了把这些弄的明明白白到底有什么用?其实如果你很好的掌握了Reactor的编程模型,很多问题就能想明白了下面谈下自己理解的有用的地方:

  1. 如果用过Redis都了解他是单线程来处理用户的请求的,他实际就是采用了Reactor模型来处理请求,也就很好的理解了为什么Redis单线程能保持很好的性能。知道了他的实现,在使用的过程中就知道尽量避免大对象的传输,因为是单线程处理,如果一个连接传输大对象那么别的连接的请求将不能被及时处理。还有Redis需要处理过期的键,它内部有定时任务去清理过期键,那么既然Redis是单线程的这个任务由谁去执行呢?还是那个处理请求的EventLoop线程,EventLoop线程其实不光处理IO请求,还会处理一些任务和定时任务用来避免锁(具体可以参考Netty的网络模型)
  2. 明白NodeJs的高性能,我觉得也是一个应用,在技术选型的过程中不用人云亦云。Java也有拿得出手的框架:Netty
  3. 采用响应式框架编写代码。,在开发响应式代码中心中也能保持警惕自己所写的代码会不会导致EventLoop的阻塞(阻塞EventLoop是相当严重的问题)。如果阻塞最好是能通过异步的api实现业务逻辑,如果避免不了阻塞或者耗时操作,则需要把任务投递到另外的线程池中去处理,任何情况下都不要去阻塞EventLoop,像VertX框架中如操作Mysql,PostgreSql这种都已经有了异步的实现。响应式编程一种趋势,从现在开始拥抱它吧!
  4. 在学习Dubbo的时候他默认的Rpc协议Dubbo协议底层就是Netty,消费者和提供者之间是单一长连接,所以官网也指出他更适合小数据量大并发,因为单个连接的带宽上限在7MByte左右。如果要传输文件,可以采用Http,这样的带宽上限就是物理网卡的上限,Http可以开启多个连接。
  5. 上面四条说了Reactor结合异步的,其实Jdk8中的CompletableFuture是一个非常优秀的异步实现,我们在需要异步化逻辑时(比如调用第三方接口)可以充分利用这个类,我曾经也写过一点关于这个类:异步编程降低延迟

最后还想说一句,Netty这个框架实在是太强大了,线程模型设计十分优秀,VertX把很多异步操作委托给了底层的Netty,因为Netty实现中的EventLoop具有天然的线程隔离(一个EventLoop对应一个线程,只会被这个线程调用),很多地方免去了同步,VertX同样继承了这个优点,有机会一定好好看看Netty的设计和源码。

六、困惑

阻塞和同步的四种组合,对于异步阻塞还是无法理解,这种模式真的存在吗?

参考文章

  1. 回调地狱的今生前世
  2. 怎样理解阻塞非阻塞与同步异步的区别? - 严肃的回答 - 知乎
  3. 怎样理解阻塞非阻塞与同步异步的区别? - 陈硕的回答 - 知乎
  4. Netty官网
  5. IO - 同步,异步,阻塞,非阻塞
  6. nodejs真的是单线程吗?
  7. 作为一个服务器,node.js 是性能最高的吗? - 圆胖肿的回答 - 知乎
  8. web框架性能排名
  9. Java8实战第11章
  10. Java并发编程实战
  11. Netty权威指南第二版