本文共 9514 字,大约阅读时间需要 31 分钟。
《Netty 进阶之路》、《分布式服务框架原理与实践》作者李林锋深入剖析通信层和 RPC 调用的异步化。李林锋此后还将在 InfoQ 上开设 Netty 专题持续出稿,感兴趣的同学可以持续关注。
在将近10年的平台中间件研发历程中,我们的平台和业务经历了从C++到Java,从同步的BIO到非阻塞的NIO,以及纯异步的事件驱动I/O(AIO)。服务器也从Web容器逐步迁移到了内部更轻量、更高性能的微容器。服务之间的RPC调用从最初的同步阻塞式调用逐步升级到了全栈异步非阻塞调用。
每次的技术演进都会涉及到大量底层平台技术以及上层编程模型的切换,在实际工作中,我发现很多同学对通信框架的异步和RPC调用的异步理解有误,比较典型的错误理解包括:
1.我使用的是Tomcat8,因为Tomcat8支持NIO,所以我基于Tomcat开发的HTTP调用都是异步的。
2.因为我们的RPC框架底层使用的是Netty、Vert.X等异步框架,所以我们的RPC调用天生就是异步的。
3.因为我们底层的通信框架不支持异步,所以RPC调用也无法异步化。
在Tomcat6.X版本对NIO提供比较完善的支持之前,作为Web服务器,Tomcat以BIO的方式接收并处理客户端的HTTP请求,当并发访问量比较大时,就容易发生拥塞等性能问题,它的工作原理示意如下所示:
传统同步阻塞通信(BIO)面临的主要问题如下:
1.性能问题:一连接一线程模型导致服务端的并发接入数和系统吞吐量受到极大限制。
2.可靠性问题:由于I/O操作采用同步阻塞模式,当网络拥塞或者通信对端处理缓慢会导致I/O线程被挂住,阻塞时间无法预测。
3.可维护性问题:I/O线程数无法有效控制、资源无法有效共享(多线程并发问题),系统可维护性差。
从上图我们可以看出,每当有一个新的客户端接入,服务端就需要创建一个新的线程(或者重用线程池中的可用线程),每个客户端链路对应一个线程。当客户端处理缓慢或者网络有拥塞时,服务端的链路线程就会被同步阻塞,也就是说所有的I/O操作都可能被挂住,这会导致线程利用率非常低,同时随着客户端接入数的不断增加,服务端的I/O线程不断膨胀,直到无法创建新的线程。
同步阻塞I/O导致的问题无法在业务层规避,必须改变I/O模型,才能从根本上解决这个问题。
Tomcat 6.X提供了对NIO的支持,通过指定Connector的protocol=“org.apache.coyote.http11.Http11NioProtocol”,就可以开启NIO模式,采用NIO之后,利用Selector的轮询以及I/O操作的非阻塞特性,可以实现使用更少的I/O线程处理更多的客户端连接,提升吞吐量和主机的资源利用率。Tomcat 8.X之后提供了对NIO2.0的支持,默认也开启了NIO通信模式。
事实上,Tomcat支持NIO,与Tomcat的HTTP服务是否是异步的,没有必然关系,这个可以从两个层面理解:
1.HTTP消息的读写:即便采用了NIO,HTTP请求和响应的消息处理仍然可能是同步阻塞的,这与协议栈的具体策略有关系。从Tomcat官方文档可以看到,Tomcat 6.X版本即便采用Http11NioProtocol,HTTP请求消息和响应消息的读写仍然是Blocking的。
2.HTTP请求和响应的生命周期管理:本质上就是Servlet是否支持异步,如果Servlet是3.X之前的版本,则HTTP协议的处理仍然是同步的,这就意味着Tomcat的Connector线程需要同时处理HTTP请求消息、执行Servlet Filter、以及业务逻辑,然后将业务构造的HTTP响应消息发送给客户端,整个HTTP消息的生命周期都采用了同步处理方式。
Tomcat与Servlet的版本配套关系如下所示:
Servlet****规范版本 | Tomcat****版本 | JDK****版本 |
---|---|---|
4.0 | 9.0.X | 8+ |
3.1 | 8.0.X | 7+ |
3.0 | 7.0.X | 6+ |
2.5 | 6.0.X | 5+ |
2.4 | 5.5.X | 1.4+ |
2.3 | 4.1.X | 1.3+ |
以Tomcat 6.X版本为例,Tomcat HTTP协议消息和后续的业务逻辑处理如下所示(Tomcat HTTP协议处理非常复杂,为了便于理解,图示做了简化):
从上图可以看出,HTTP请求消息的读取、Servlet Filter的执行、业务Servlet的逻辑处理,以及HTTP响应都是由Tomcat的NIO线程(Processor,实际更复杂些,这里做了简化处理)做处理,即HTTP消息的处理周期中都是串行同步执行的,尽管Tomcat使用NIO做接入,HTTP服务端的处理仍然是同步的。它的弊端很明显,如果Servlet中的业务逻辑处理比较复杂,则会导致Tomcat的NIO线程被阻塞,无法读取其它HTTP客户端发送的HTTP请求消息,导致客户端读响应超时。
可能有读者会有疑问,途中标识处,为什么不能创建一个业务线程池,由业务线程池异步处理业务逻辑,处理完成之后再填充HttpServletResponse,发送响应。实际上在Servlet支持异步之前是无法实现的,原因是每个响应对象只有在Servlet的service方法或Filter的doFilter方法范围内有效,该方法一旦调用完成,Tomcat就认为本次HTTP消息处理完成,它会回收HttpServletRequest和HttpServletResponse对象再利用,如果业务异步化之后再处理HttpServletResponse,拿到的实际就不是之前请求消息对应的响应,会发生各种非预期问题,因此,业务逻辑必须在service方法结束前执行,无法做异步化处理。
如果使用的是支持Servlet3.0+版本的Tomcat,通过开启异步处理模式,就能解决同步调用面临的各种问题,在后续章节中会有详细介绍。
通过以上分析我们可以看出,除了将Tomcat的Connector配置成NIO模式之外,还需要Tomcat配套的Servlet版本支持异步化(3.0+),同时还需要在业务Servlet的代码中开启异步模式,HTTP服务端才能够实现真正的异步化:I/O异步以及业务逻辑处理的异步化。
很多人喜欢将JDK 1.4提供的NIO框架称为异步非阻塞I/O,但是,如果严格按照UNIX网络编程模型和JDK的实现进行区分,实际上它只能被称为非阻塞I/O,不能叫异步非阻塞I/O。在早期的JDK 1.4和1.5 update10版本之前,JDK的Selector基于select/poll模型实现,它是基于I/O复用技术的非阻塞I/O,不是异步I/O。在JDK 1.5 update10和Linux core2.6以上版本,Sun优化了Selctor的实现,它在底层使用epoll替换了select/poll,上层的API并没有变化,可以认为是JDK NIO的一次性能优化,但是它仍旧没有改变I/O的模型。相关优化的官方说明如下图所示:
由JDK1.7提供的NIO 2.0新增了异步的套接字通道,它是真正的异步I/O,在异步I/O操作的时候可以传递信号变量,当操作完成之后会回调相关的方法,异步I/O也被称为AIO。NIO类库支持非阻塞读和写操作,相比于之前的同步阻塞读和写,它是异步的,因此很多人仍然习惯于称NIO为异步非阻塞I/O,在此不需要太咬文嚼字。
不同的I/O模型由于线程模型、API等差别很大,所以用法的差异也非常大。各种I/O模型的优缺点对比如下:
同步阻塞I/O(BIO) | 非阻塞I/O(NIO) | 异步I/O(AIO) | |
---|---|---|---|
客户端个数:I/O线程 | 1:1 | M:1(1个I/O线程处理多个客户端连接) | M:0(不需要用户启动额外的I/O线程,被动回调) |
I/O类型(阻塞) | 阻塞I/O | 非阻塞I/O | 非阻塞I/O |
I/O类型(同步) | 同步I/O | 同步I/O(I/O多路复用) | 异步I/O |
API使用难度 | 简单 | 非常复杂 | 复杂 |
调试难度 | 简单 | 复杂 | 复杂 |
可靠性 | 非常差 | 高 | 高 |
吞吐量 | 低 | 高 | 高 |
RPC 的全称是 Remote Procedure Call,它是一种进程间通信方式。允许像调用本地服务一样调用远程服务,它的具体实现方式可以不同,例如Spring的HTTP Invoker,Facebook的Thrift二进制私有协议通信。
RPC框架的目标就是让远程过程(服务)调用更加简单、透明,RPC框架负责屏蔽底层的传输方式(TCP或者UDP)、序列化方式(XML/Json/二进制)和通信细节。框架使用者只需要了解谁在什么位置提供了什么样的远程服务接口即可,开发者不需要关心底层通信细节和调用过程。
RPC框架的调用原理图如下所示:
RPC框架实现的几个核心技术点总结如下:
1.远程服务提供者需要以某种形式提供服务调用相关的信息,包括但不限于服务接口定义、数据结构,或者中间态的服务定义文件,例如Thrift的IDL文件,WS-RPC的WSDL文件定义,甚至也可以是服务端的接口说明文档;服务调用者需要通过一定的途径获取远程服务调用相关信息,例如服务端接口定义Jar包导入,获取服务端IDL文件等。
2.远程代理对象:服务调用者调用的服务实际是远程服务的本地代理,对于Java语言,它的实现就是JDK的动态代理,通过动态代理的拦截机制,将本地调用封装成远程服务调用。
3.通信:RPC框架与具体的协议无关,例如Spring的远程调用支持HTTP Invoke、RMI Invoke,MessagePack使用的是私有的二进制压缩协议。
4.序列化:远程通信,需要将对象转换成二进制码流进行网络传输,不同的序列化框架,支持的数据类型、数据包大小、异常类型以及性能等都不同。不同的RPC框架应用场景不同,因此技术选择也会存在很大差异。一些做的比较好的RPC框架,可以支持多种序列化方式,有的甚至支持用户自定义序列化框架(Hadoop Avro)。
RPC异步与I/O的异步没有必然关系,当然,在大多数场景下,RPC框架底层会使用异步I/O,实现全栈异步。
RPC框架异步调度模型如下所示:
异步RPC调用的关键点有2个:
1.不能阻塞调用方线程:接口调用通常会返回Future或者Promise对象,代表异步操作的一个回调对象,当异步操作完成之后,由I/O线程回调业务注册的Listener,继续执行业务逻辑。
2.请求和响应的上下文关联:除了HTTP/1.X协议,大部分二进制协议的TCP链路都是多路复用的,请求和响应消息的发送和接收顺序是无序的。所以,异步RPC调用需要缓存请求和响应的上下文关联关系,以及响应需要使用到的消息上下文。
正如上图所示,当RPC调用请求消息发送到I/O线程的消息队列之后,业务线程就可以返回,至于I/O线程采用同步还是异步的方式读写消息,与RPC调用的同步和异步没必然的关联关系,当然,采用异步I/O,整体性能和可靠性会更好一些,所以现在大部分的RPC框架底层采用的都是异步/非阻塞I/O。以Netty为例,无论RPC调用是同步还是异步,只要调用消息发送接口,Netty都会将发送请求封装成Task,加入到I/O线程的消息队列中统一处理,相关代码如下所示:
异步回调的一些实现策略:
1.Future/Promise:比较常用的有JDK8之前的Future,通过添加Listener来做异步回调,JDK8之后通常使用CompletableFuture,它支持各种复杂的异步处理策略,例如自定义线程池、多个异步操作的编排、有返回值和无返回值异步、多个异步操作的级联操作等。
2.线程池+RxJava:最经典的实现就是Netflix开源的Hystrix框架,使用HystrixCommand(创建线程池)做一层异步封装,将同步调用封装成异步调用,利用RxJava API,通过订阅的方式对结果做异步处理,它的工作原理如下所示:
通过以上分析可以得出如下结论:
1.RPC异步指的是业务线程发起RPC调用之后,不用同步等待服务端返回应答,而是立即返回,当接收到响应之后,回调执行业务的后续逻辑。
2.I/O的异步是通信层的具体实现策略,使用异步I/O会带来性能和可靠性提升,但是与RPC调用是同步还是异步没必然关系。
很多RPC框架同时支持同步和异步调用,下面对同步和异步RPC调用的工作原理以及优缺点进行分析。
在传统的单体架构中,以Spring + Struts + MyBatis + Tomcat为例,业务逻辑通常由各种Controller(Spring Bean)来实现,它的逻辑架构如下所示:
在单体架构中,本地方法调用都是同步方式,而且定义形式往往都是如下形式(请求参数 + 方法返回值):
String sayHello(String hello);
切换到RPC框架之后,很多都支持通过XML引用或者代码注解的方式引用远端的RPC服务,可以像使用本地接口一样调用远程的服务,这种开发模式与传统单体应用开发模式相似,编程简单,学习和切换成本低,调试也比较方便,因此,同步RPC调用成为大部分项目的首选。
以XML方式导入远端服务提供者的API接口示例如下:
\u0026lt;xxx:reference id=\u0026quot;echoService\u0026quot; interface=\u0026quot;edu.neu.EchoService\u0026quot; /\u0026gt;\u0026lt;bean class=\u0026quot;edu.neu.xxxAction\u0026quot; init-method=\u0026quot;start\u0026quot;\u0026gt; \u0026lt;property name=\u0026quot;echoService\u0026quot; ref=\u0026quot;echoService\u0026quot; /\u0026gt;\u0026lt;/bean\u0026gt;
导入之后业务就可以直接在代码中调用echoService接口,与传统单体应用调用本地Spring Bean一样,无需感知远端服务接口的具体部署位置信息。
同步RPC调用是最常用的一种服务调用方式,它的工作原理如下:客户端发起远程RPC调用请求,用户线程完成消息序列化之后,将消息投递到通信框架,然后同步阻塞,等待通信线程发送请求并接收到应答之后,唤醒同步等待的用户线程,用户线程获取到应答之后返回。它的工作原理图如下所示:
它的工作原理图如下所示:
主要流程如下:
1.消费者调用服务端发布的接口,接口调用由RPC框架包装成动态代理,发起远程RPC调用。
2.消费者线程调用通信框架的消息发送接口之后,直接或者间接调用wait()方法,同步阻塞等待应答。
3.通信框架的I/O线程通过网络将请求消息发送给服务端。
4.服务端返回应答消息给消费者,由通信框架负责应答消息的反序列化。
5.I/O线程获取到应答消息之后,根据消息上下文找到之前同步阻塞的业务线程,notify()阻塞的业务线程,返回应答给消费者,完成RPC调用。
同步RPC调用的主要缺点如下:
1.线程利用率低:线程资源是系统中非常重要的资源,在一个进程中线程总数是有限制的,提升线程使用率就能够有效提升系统的吞吐量,在同步RPC调用中,如果服务端没有返回响应,客户端业务线程就会一直阻塞,无法处理其它业务消息。
2.纠结的超时时间:RPC调用的超时时间配置是个比较棘手的问题。如果配置的过大,一旦服务端返回响应慢,就容易把客户端挂死。如果配置的过小,则超时失败率会增加。即便参考测试环境的平均和最大时延来设置,由于生产环境数据、硬件等与测试环境的差异,也很难一次设置的比较合理。另外,考虑到客户端流量的变化、服务端依赖的数据库、缓存、第三方系统等的性能波动,这都会导致服务调用时延发生变化,因此,依靠超时时间来保障系统的可靠性,难度很大。
3.雪崩效应:在一个同步调用链中,只要下游某个服务返回响应慢,会导致故障沿着调用链向上游蔓延,最终把整个系统都拖垮,引起雪崩,示例如下:
JDK原生的Future主要用于异步操作,它代表了异步操作的执行结果,用户可以通过调用它的get方法获取结果。如果当前操作没有执行完,get操作将阻塞调用线程。在实际项目中,往往会扩展JDK的Future,提供Future-Listener机制,它支持主动获取和被动异步回调通知两种模式,适用于不同的业务场景。
基于JDK的Future-Listener机制,可以实现异步RPC调用,它的工作原理如下所示:
异步RPC调用的工作流程如下:
1.消费者调用RPC服务端发布的接口,接口调用由RPC框架包装成动态代理,发起远程RPC调用。
2.通信框架异步发送请求消息,如果没有发生I/O异常,返回。
3.请求消息发送成功后,I/O线程构造Future对象,设置到RPC上下文中。
4.用户线程通过RPC上下文获取Future对象。
5.构造Listener对象,将其添加到Future中,用于服务端应答异步回调通知。
6.用户线程返回,不阻塞等待应答。
7.服务端返回应答消息,通信框架负责反序列化等。
8.I/O线程将应答设置到Future对象的操作结果中。
9.Future对象扫描注册的监听器列表,循环调用监听器的operationComplete方法,将结果通知给监听器,监听器获取到结果之后,继续后续业务逻辑的执行,异步RPC调用结束。
Java8的CompletableFuture提供了非常丰富的异步功能,它可以帮助用户简化异步编程的复杂性,通过Lambda表达式可以方便的编写异步回调逻辑,除了普通的异步回调接口,它还提供了多个异步操作结果转换以及与或等条件表达式的编排能力,方便对多个异步操作结果进行逻辑编排。
CompletableFuture提供了大约20类比较实用的异步API,接口定义示例如下:
利用JDK的CompletableFuture与Netty的NIO,可以非常方便的实现异步RPC调用,设计思路如下所示:
异步RPC调用的工作流程如下:
1.消费者通过RPC框架调用服务端。
2.Netty异步发送HTTP请求消息,如果没有发生I/O异常就正常返回。
3.HTTP请求消息发送成功后,I/O线程构造CompletableFuture对象,设置到RPC上下文中。
4.用户线程通过RPC上下文获取CompletableFuture对象。
5.不阻塞用户线程,立即返回CompletableFuture对象。
6.通过CompletableFuture编写Function函数,在Lambda表达式中实现异步回调逻辑。
7.服务端返回HTTP响应,Netty负责反序列化工作。
8.Netty I/O线程通过调用CompletableFuture的complete方法将应答设置到CompletableFuture对象的操作结果中。
9.CompletableFuture通过whenCompleteAsync等接口异步执行业务回调逻辑,实现RPC调用的异步化。
异步RPC调用相比于同步调用有两个优点:
1.化串行为并行,提升RPC调用效率,减少业务线程阻塞时间。
2.化同步为异步,避免业务线程阻塞。
假如一次阅读首页访问需要调用多个服务接口,采用同步调用方式,它的调用流程如下所示:
由于每次RPC调用都是同步阻塞,三次调用总耗时为T = T1 + T2 + T3。下面看下采用异步RPC调用之后的优化效果:
采用异步RPC调用模式,最后调用三个异步操作结果Future的get方法同步等待应答,它的总执行时间T = Max(T1, T2,T3),相比于同步RPC调用,性能提升效果非常明显。
通常在实验室环境中测试,由于网络时延小、模拟业务又通常比较简单,所以异步RPC调用并不一定性能更高,但在生产环境中,异步RPC调用往往性能更高、可靠性也更好。主要原因是网络环境相对恶劣、真实的RPC调用耗时更多等,这种恶劣的运行环境正好可以发挥异步RPC调用的优势。
服务框架支持多种RPC调用方式,在实际项目中如何选择呢?建议从以下几个角度进行考虑:
1.降低业务E2E时延:业务调用链是否太长、某些服务是否不太可靠,需要对服务调用流程进行梳理,看是否可以通过异步并行RPC调用来提升调用效率,降低RPC调用时延。
2.可靠性角度:某些业务调用链上的关键服务不太可靠,一旦出故障会导致大量线程资源被挂住,可以考虑使用异步RPC调用防止故障扩散。
3.传统的RPC调用:服务调用比较简单,对时延要求不高的场景,则可以考虑同步RPC调用,降低编程复杂度,以及调试难度,提升开发效率。
李林锋,10年Java NIO、平台中间件设计和开发经验,精通Netty、Mina、分布式服务框架、API Gateway、PaaS等,《Netty进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。
联系方式:新浪微博 Nettying 微信:Nettying
Email:neu_lilinfeng@sina.com
转载地址:http://imhnx.baihongyu.com/