微信号:infoqchina

介绍:有内容的技术社区媒体

深入剖析通信层和 RPC 调用的异步化:应用场景、实践及技术难点

2019-01-12 09:21 李林锋
作者 | 李林锋
编辑 | 小智
《Netty 进阶之路》、《分布式服务框架原理与实践》作者李林锋深入剖析通信层和 RPC 调用的异步化。李林锋在 InfoQ 上开设了 Netty 专题持续出稿,感兴趣的同学可以持续关注。后台回复关键词:Netty,可以获取专栏链接。
异步 RPC 调用的应用场景
缩短长流程的调用时延

随着业务分布式架构的发展,系统间的系统调用日趋复杂,以电商的商品购买为例,前台界面的购买操作涉及到底层上百次服务调用,形成复杂的调用链,示例如下:


图 1 分布式消息调用链

对于一些逻辑上不存在互相依赖关系的服务,可以通过异步 RPC 调用,实现服务的并行调用,通过并行调用来降低服务调用总耗时,以手游购买道具流程为例,消费次数限制鉴权、账户余额鉴权和下载记录鉴权三个服务可以通过异步的方式并行调用,来降低游戏道具购买的耗时:


图 2 购买道具异步 RPC 调用流程

服务调用耗时波动较大场景

对于一些业务场景,服务调用耗时与消息本身、调用的资源对象有关系,例如上传和下载接口,如果下载的资源较多则耗时就会相应的增加。对于这类场景,接口的调用超时时间比较难配置,如果配置过大,服务端自身响应慢之后会拖垮调用方,如果配置过小,万一遇到一个需要较长耗时的 RPC 调用就会超时。通过异步 RPC 调用,就不用再担心调用方业务线程被阻塞,超时时间可以相应配置大一些,减少超时导致的失败。

第三方接口调用

对于大部分的第三方服务调用,都需要采用防御性编程,防止因为第三方故障导致自身不能正常工作。如果采用同步 RPC 方式调用第三方服务,一旦第三方服务的处理耗时增加,就会导致客户端调用线程被阻塞,当超时时间配置不合理时,系统很容易被阻塞。通过异步化的 RPC 调用,可以防止被第三方服务端阻塞,Hystrix 的第三方故障隔离就是采用类似机制,只不过它底层创建了线程池,通过 Hystrix 的线程池将第三方服务调用与业务线程做了隔离,实现了非侵入式的故障隔离。

性能和资源利用率提升

对于一个同步串行化调用的系统,大量的业务线程都在等待服务端返回响应,系统的 CPU 使用率很低,但是性能却无法有效提升,这个问题几乎是所有采用同步 RPC 调用的业务都遇到的一个通病。要想充分利用 CPU 资源,需要让业务线程尽可能的跑满 CPU,而不是经常性的处于同步等待状态。采用异步 RPC 调用之后,在单位时间内业务线程可以接收并处理更多的请求消息,更充分的利用 CPU 资源,提升系统的吞吐量。

根据一些公开的测试数据,一些业务采用异步 RPC 替换同步 RPC 调用之后,综合性能提升 2-3 倍 +。

异步 RPC 调用实践
Tomcat + Servlet3.X 的异步化
工作原理

Servlet 异步是指 Servlet 3 规范中提供了对异步处理 Servlet 请求的支持,可以把 HTTP 协议处理线程和业务逻辑执行线程隔离开:

  1. Servlet3.0 对异步的支持:Servlet3 之前一个 HTTP 请求消息的处理流程,包括:HTTP 请求消息的解析、Read Body、Response Body,以及后续的业务逻辑处理都是由 Tomcat 线程池中的工作线程处理。Servlet3 之后可以让 I/O 线程和业务处理线程分开,进而对业务做隔离和异步化处理。还可以根据业务重要性进行业务分级,同时把业务线程池分类,实现业务的优先级处理,隔离核心业务和普通业务,提升应用可靠性。

  2. Servlet3.1 对非阻塞 I/O 的支持:Servlet3.1 以后增加了对非阻塞 I/O 的支持,根据 Servlet3.1 规范中描述:非阻塞 I/O 仅对在 Servlet 中的异步处理请求有效,否则,当调用 ServletInputStream.setReadListener 或 ServletOutputStream.setWriteListener 方法时抛出 IllegalStateException 异常。Servlet3.1 对非阻塞 I/O 的支持是对之前异步化版本的增强,配套 Tomcat8.X 版本。

Tomcat + Servlet3 的异步化处理原理如下所示:


图 3 Tomcat + Servlet3 异步处理原理

异步化处理流程

关键处理流程如下:

  1. 声明 Servlet,增加 asyncSupported 属性,开启异步支持,例如 @WebServlet(urlPatterns=“/AsyncLongRunningServlet”,asyncSupported=true)。

  2. 通过 request 获取异步上下文 AsyncContext, AsyncContext context = request.startAsync(), 相关接口定义如下:

  3. 启动业务逻辑处理线程,并将 AsyncContext 对象传递给业务线程。例如:Executor.execute(()->{context, request, response…})。

  4. 在业务线程中,通过获取 request 进行业务逻辑处理,完成之后填充 response 对象。

  5. 业务逻辑处理完成之后,调用 AsyncContext 的 complete() 方法完成响应消息的发送。

Spring MVC 异步化
工作原理

SpringMVC 3.2+ 版本基于 Servlet3 做了封装,以简化业务使用。它的工作原理如下所示:


图 4 SpringMVC 异步工作原理

异步的几种实现方式

SpringMVC 支持多种异步化模式,常用的有两种:

  1. Controller 的返回值为 DeferredResult,在业务 Controller 方法中构造 DeferredResult 对象,然后将请求封装成 Task 投递到业务线程池中异步执行,业务执行完成之后,构造 ModelAndView,调用 deferredResult.setResult(ModelAndView) 完成异步化处理和响应消息的发送。

  2. Controller 的返回值为 WebAsyncTask,实现 Callable, 在 call 方法中完成业务逻辑处理,由 SpringMVC 框架的线程池来异步执行业务逻辑(非 Tomcat 工作线程)。

以 DeferredResult 为例,它的异步处理流程如下所示:


图 5 SpringMVC DeferredResult 工作原理

Apache ServiceComb 的异步化服务调用

Apache ServiceComb 是一个开箱即用、高性能、兼容流行生态、支持多语言的一站式开源微服务解决方案。它同时支持同步和异步服务调用,下面一起分析下它的异步化服务调用机制。

纯 Reactive 模式

纯 Reactive 模式的特点是:

  1. 异步化接口,消费端不需要同步等待服务提供端返回响应,不会产生阻塞。

  2. 与传统流程不同的,所有功能都在 eventloop 中执行,并不会进行线程切换。

  3. 只要有任务,线程就不会停止,会一直执行任务,可以充分利用 cpu 资源,也不会产生多余的线程切换,去无谓地消耗 cpu。

它的处理流程如下所示:


图 6 ServiceComb 的 Reactive 工作模式

关键流程解读:

  1. 异步:橙色箭头走完后,对本线程的占用即完成了,不会阻塞等待应答,该线程可以处理其他任务。

  2. 当收到远端应答后,由网络数据驱动开始走红色箭头的应答流程。

对应的代码示例如下所示:


通过代码示例可以看出,ServiceComb 的 Reactive 工作模式采用了 JDK8 的 CompletableFuture 作为异步编程模型,利用 CompletableFuture 可以方便的对多个异步操作结果做编排,以及做级联异步操作,功能强大,使用灵活。

纯 Reactive 模式的使用约束:所有在 eventloop 中执行的逻辑,不允许有任何的阻塞动作,包括不限于 wait、sleep、巨大循环、同步查询 DB 等等。实际上就是如果业务的微服务采用了 Reactive,则需要做全栈异步,否则会阻塞 eventloop 线程,导致消息收发出现问题。如果业务的微服务想做异步化,但是由于数据库、缓存等原因无法实现全栈异步,则可以采用后面介绍的混合 Reactive 模式。

混合 Reactive 模式

混合 Reactive 模式的实现策略如下:

  1. 服务端接口返回值为 CompletableFuture,这样采用透明 RPC 调用时就可以实现异步化。

  2. 对于可能产生同步阻塞的业务逻辑代码,采用独立线程池的方式进行处理,防止阻塞平台的 eventloop 线程。

混合 Reactive 模式与纯 Reactive 模式相比,主要有两点差异:

存在线程切换。

可能导致同步阻塞的业务逻辑放到独立的线程池中执行,纯 Reactive 模式所有业务逻辑都在 eventloop 线程中执行(与 I/O 线程相同)。

它的处理流程如下所示:


图 7 ServiceComb 的混合 Reactive 工作模式

异步模式的几个特点

相比于其它的微服务框架(RPC 框架),ServiceComb 的 Reactive 有如下几个特点:

对于微服务提供端:

  1. producer 是否使用 reactive 与 consumer 如何调用,没有任何联系。

  2. 当 operation 返回值为 CompletableFuture 类型时,默认此 operation 工作于 reactive 模式,此时如果需要强制此 operation 工作于线程池模式,需要在微服务的配置文件中(microservice.yaml)中明确配置,指定业务线程池。这样业务逻辑的执行就可以由 eventloop 线程(I/O 线程)切换到业务线程。

对于微服务消费端:

  1. consumer 是否使用 reactive 与 producer 如何实现,没有任何联系。

  2. 当前只支持透明 RPC 模式,使用 JDK 原生的 CompletableFuture 来承载此功能 ompletableFuture 的 when、then 等等功能都可直接使用。

对于 ServiceComb,无论服务端定义的接口是同步还是异步的,消费端都可以采用异步的方式调用它,对具体细节感兴趣的读者可以到 ServiceComb 官网下载 Demo 示例学习。

I/O 线程和业务线程的交互优化

ServiceComb 微服务的完整线程模型如下图所示:


图 8 I/O 线程和业务线程交互

ServiceComb 通过线程绑定技术来减少锁竞争,提升性能:

  1. 业务线程在第一次调用时会绑定某一个网络线程, 避免在不同网络线程之间切换, 无谓地增加线程冲突的概率。

  2. 业务线程绑定网络线程后, 会再绑定该网络线程内部的某个连接, 同样是为了避免线程冲突。

gRPC 的异步化

gRPC 的服务调用有三种方式:

  1. 同步阻塞式服务调用,通常实现类是 xxxBlockingStub(基于 proto 定义生成)。

  2. 异步非阻塞调用,基于 Future-Listener 机制,通常实现类是 xxxFutureStub。

  3. 异步非阻塞调用,基于 Reactive 的响应式编程模式,通常实现类是 xxxStub。

基于 Future 的异步 RPC 调用

业务调用代码示例如下:


调用 GreeterFutureStub 的 sayHello 方法返回的不是应答,而是 ListenableFuture,它继承自 JDK 的 Future,接口定义如下:


将 ListenableFuture 加入到 gRPC 的 Future 列表中,创建一个新的 FutureCallback 对象,当 ListenableFuture 获取到响应之后,gRPC 的 DirectExecutor 线程池会调用新创建的 FutureCallback,执行 onSuccess 或者 onFailure,实现异步回调通知。

接着我们分析下 ListenableFuture 的实现原理,ListenableFuture 的具体实现类是 GrpcFuture,代码如下:


获取到响应之后,调用 complete 方法:


将 ListenableFuture 加入到 Future 列表中之后,同步获取响应(在 gRPC 线程池中阻塞,非业务调用方线程):


获取到响应之后,回调 callback 的 onSuccess,代码如下:


除了将 ListenableFuture 加入到 Futures 中由 gRPC 的线程池执行异步回调,也可以自定义线程池执行异步回调,代码示例如下:


Reactive 风格异步 RPC 调用

业务调用代码示例如下:


构造响应 StreamObserver,通过响应式编程,处理正常和异常回调,接口定义如下:


将响应 StreamObserver 作为入参传递到异步服务调用中,该方法返回空,程序继续向下执行,不阻塞当前业务线程,代码如下所示:

下面分析下基于 Reactive 方式异步调用的代码实现,把响应 StreamObserver 对象作为入参传递到异步调用中,代码如下:


当收到响应消息时,调用 StreamObserver 的 onNext 方法,代码如下:


当 Streaming 关闭时,调用 onCompleted 方法,如下所示:


通过源码分析可以发现,Reactive 风格的异步调用,相比于 Future 模式,没有任何同步阻塞点,无论是业务线程还是 gRPC 框架的线程都不会同步等待,相比于 Future 异步模式,Reactive 风格的调用异步化更彻底一些。

异步双向 streaming 调用

gRPC 的通信协议基于标准的 HTTP/2 设计,除了普通的 RPC 调用,还支持 streaming 调用。

客户端发送 N 个请求,服务端返回 N 个或者 M 个响应,利用该特性,可以充分利用 HTTP/2.0 的多路复用功能,在某个时刻,HTTP/2.0 链路上可以既有请求也有响应,实现了全双工通信(对比单行道和双向车道),示例如下:


图 9 双向 streaming 模式

proto 文件定义如下:


业务代码示例如下:


构造 Streaming 响应对象 StreamObserver 并实现 onNext 等接口,由于服务端也是 Streaming 模式,因此响应是多个的,也就是说 onNext 会被调用多次。

通过在循环中调用 requestObserver 的 onNext 方法,发送请求消息,代码如下所示:


requestObserver 的 onNext 方法实际调用了 ClientCall 的消息发送方法,代码如下:


对于双向 Streaming 模式,只支持异步调用方式。

总结

gRPC 服务调用支持同步和异步方式,同时也支持普通的 RPC 和 streaming 模式,可以最大程度的满足业务的需求。

对于 streaming 模式,可以充分利用 HTTP/2.0 协议的多路复用功能,实现在一条 HTTP 链路上并行双向传输数据,有效的解决了 HTTP/1.X 的数据单向传输问题,在大幅减少 HTTP 连接的情况下,充分利用单条链路的性能,可以媲美传统的 RPC 私有长连接协议:更少的链路、更高的性能:


图 10 传统 RPC 和双向 streaming 模式的对比

gRPC 的网络 I/O 通信基于 Netty 构建,服务调用底层统一使用异步方式,同步调用是在异步的基础上做了上层封装。因此,gRPC 的异步化是比较彻底的,对于提升 I/O 密集型业务的吞吐量和可靠性有很大的帮助。

异步化的一些技术难点
异步异常传递

当采用异步编程之后,异步抛出的异常传递给调用方会变得非常困难,例如 Runnable, 当异步执行它时,异常需要在 run 方法中捕获和处理,否则会导致线程跑飞,run 方法中的异常是无法回传到调用方的。

使用 JDK8 的 CompletableFuture 之后,它的常用方法参数基本是 Lambda 表达式,由于函数接口中的方法通常不允许检查期异常,在表达式中发生的异常无法回传给调用方,相比于以前同步调用可以将异常抛给调用方处理的方式有很大差异。

异步异常的解决策略:

如果异步的编程模型基于 JDK8 的 CompletableFuture,可以通过 whenComplete 对返回值的异常进行非空判断,当异常非空时,进行异常逻辑处理,相关接口如下:


也可以通过 exceptionally 方法来处理异步执行发生的异常,相关接口如下所示:


异步回调(Lambda 表达式)代码块中的异常处理有两种策略:1)一定要通过 exceptionally 方法或者 whenComplete 对异常进行捕获处理,否则会导致 Lambda 表达式异常退出,后续操作被忽略,最终导致业务逻辑跑飞。2)运行期异常,通常是无法抛出来由调用方处理的,需要在发生异常的地方就地捕获和处理。

超时控制

异步代码块(Lambda 表达式)中可能会涉及到多种业务逻辑操作,例如:

  1. 数据库、缓存、MQ 等中间件平台调用。

  2. 第三方接口调用。

  3. 级联嵌套其它微服务调用。

对于异步的超时控制,建议策略如下:

  1. 对单个原子的中间件、第三方接口、微服务做超时控制。

  2. 不建议直接对异步代码块(Lambda 表达式)整体做超时控制,例如包装出一个支持异步超时的 CompletableFuture,主要原因如下:

    超时并不能确保中断当前正在执行的业务逻辑,例如同步 Redis 缓存调用。

    如果超时发生时,正好又发起了一次异步 RPC 调用,创建了一个新的 CompletableFuture,外层超时之后,已经创建的 CompletableFuture 异步回调仍然可能会被执行,这会带来各种混乱。

    由于异步代码块(Lambda 表达式)中的业务逻辑可能会非常复杂,所以超时之后的补偿操作非常困难。例如充值操作已经成功了,但是外层调用方超时失败了,这会给后续业务的处理带来很多困难,因为超时发生时调用方并不知道异步代码块中的哪些操作被执行,哪些没被执行。

没有超时控制之后,要确保 CompletableFuture 能够正常或者异常的结束,否则会导致 CompletableFuture 积压,最终发生 OOM。

上下文传递

在传统的同步 RPC 调用时,业务往往通过线程变量来传递上下文,例如:TraceID、会话 Session、IP 等信息。异步化之后,由于潜在的线程切换和线程被多个消息交叉复用,通常不建议继续使用线程变量传递上下文。

异步化之后,上下文传递的建议策略:

  1. 如果是 Lambda 表达式,可以直接引用局部变量,通过变量引用的方式将上下文信息传递到 Lambda 表达式中,后续可以通过方法传参等层层传递下去。

  2. 在所有发生线程切换的地方,显式的进行上下文信息的拷贝和清理,特别需要注意的是隐式线程切换,例如 Hystrix,底层会自己启线程池。

  3. 建议通过调用级的消息上下文来做参数传递,每个上下文都关联一次 RPC 调用,调用完成之后自动清理掉。

  4. 异步化之后,需要排重点查所有使用 ThreadLocal 的地方,通常情况下都会存在问题,需要做改造。

异步回调地狱问题

如果使用的是 JDK8 的 CompletableFuture,它支持对异步操作结果做编排以及级联操作,能够比较好的解决类似 JS 和传统 Future-Listener 的回调地域问题,感兴趣的读者可以体会下 CompletableFuture 的异步化接口。

作者介绍

李林锋,10 年 Java NIO、平台中间件设计和开发经验,精通 Netty、Mina、分布式服务框架、API Gateway、PaaS 等,《Netty 进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。联系方式:

  • 新浪微博:Nettying

  • 微信:Nettying

  • Email:neu_lilinfeng@sina.com

点一下好看试试微信的新功能?👇

 
InfoQ 更多文章 京东云金山云合并确有其事,或拉开云计算兼并序幕 十年前只想混一个Apache邮箱装逼,十年后却成了顶级项目创始人 腾讯正式宣布成立技术委员会!要对组织架构下狠手 如何快速入门TensorFlow ?丨极客时间 动辄年薪百万招聘程序员的研究院都是什么来头?
猜您喜欢 用 consul + consul-template + registrator + nginx 打造真正可动态扩展的服务架构 iOS优化实践方案 CocoaPods组件平滑二进制化解决方案 谈谈papi酱的价值 Small:强大的轻量级 Android 插件框架