读不下去不要紧,我写的真的很辛苦的,帮忙拉到最后点个赞吧。
本文目录
第一节:官方发布
本小节主要是通过官方发布的一篇名为《Dubbo 发布里程碑版本,性能提升30%》的文章作为引子,引出本文所要分享的内容:客户端线程模型优化。
第二节:官网上的介绍
在介绍优化后的消费端线程模型之前,先简单的介绍一下Dubbo的线程模型是什么。同时发现官方文档对于该部分的介绍十分简略,所以结合代码对其进行补充说明。
第三节:2.7.5版本之前的线程模型的问题
通过一个issue串联本小节,道出并分析一些消费端应用,当面临需要消费大量服务且并发数比较大的大流量场景时(典型如网关类场景),经常会出现消费端线程数分配过多的问题。
第四节:thredless是什么
通过第三节引出了新版本的解决方案,thredless。并对其进行一个简单的介绍。
第五节:场景复现
由于条件有限,场景复现起来比较麻烦,但是我在issues#890中发现了一个很好的终结,所以我搬过来了。
第六节:新旧线程模型对比
本小节通过对比新老线程模型的调用流程,并对比2.7.4.1版本和2.7.5版本关键的代码,起到一个导读的作用。
第七节:Dubbo版本介绍。
趁着这次的版本升级,也趁机介绍一下Dubbo目前的两个主要版本:2.6.X和2.7.X。
官方发布
2020年1月9日,阿里巴巴中间件发布名为《Dubbo 发布里程碑版本,性能提升30%》的文章:
文章中说这是Dubbo的一个里程碑式的版本。
在阅读了相关内容后,我发现这确实是一个里程碑式的跨域,对于Dubbo坎坷的一生来说,这是展现其强大的生命力和积极探索精神的一个版本。
强大的生命力体现在新版本发布后众多的或赞扬、或吐槽的社区反馈。
探索精神体现在Dubbo在多语言和协议穿透性上的探索。
在文章中列举了9大改造点,本文仅介绍2.7.5版本中的一个改造点:优化后的消费端线程模型。
本文大部分源码为2.7.5版本,同时也会有2.7.4.1版本的源码作为对比。
官网上的介绍
在介绍优化后的消费端线程模型之前,先简单的介绍一下Dubbo的线程模型是什么。
直接看官方文档中的描述,Dubbo官方文档是一份非常不错的入门学习的文档,很多知识点都写的非常详细。
可惜,在线程模型这块,差强人意,寥寥数语,图不达意:
官方的配图中,完全没有体现出线程"池"的概念,也没有体现出同步转异步的调用链路。仅仅是一个远程调用请求的发送与接收过程,至于响应的发送与接收过程,这张图中也没有表现出来。
所以我结合官方文档和2.7.5版本的源码进行一个简要的介绍,在阅读源码的过程中你会发现:
在客户端,除了用户线程外,还会有一个线程名称为DubboClientHandler-ip:port的线程池,其默认实现是cache线程池。
上图的第93行代码的含义是,当客户端没有指定threadpool时,采用cached实现方式。
上图中的setThreadName方法,就是设置线程名称:
org.apache.dubbo.common.utils.ExecutorUtil#setThreadName
可以清楚的看到,线程名称如果没有指定时,默认是DubboClientHandler-ip:port。
在服务端,除了有boss线程、worker线程(io线程),还有一个线程名称为DubboServerHandler-ip:port的线程池,其默认实现是fixed线程池。
启用线程池的dubbo.xml配置如下:
<dubbo:protocol name="dubbo" threadpool="xxx"/>
上面的xxx可以是fixed、cached、limited、eager,其中fixed是默认实现。当然由于是SPI,所以也可以自行扩展:
所以,基于最新2.7.5版本,官方文档下面红框框起来的这个地方,描述的有误导性:
从SPI接口看来,fixed确实是缺省值。
但是由于客户端在初始化线程池之前,加了一行代码(之前说的93行),所以客户端的默认实现是cached,服务端的默认实现是fixed。
我也看了之前的版本,至少在2.6.0时(更早之前的版本没有查看),客户端的线程池的默认实现就是cached。
关于Dispatcher部分的描述是没有问题的:
Dispatcher部分是线程模型中一个比较重要的点,后面会提到。
这里配一个稍微详细一点的2.7.5版本之前的线程模型,供大家参考:
图片来源:https://github.com/apache/dubbo/issues/890
2.7.5之前的线程模型的问题
那么改进之前的线程模型到底存在什么样的问题呢?
在《Dubbo 发布里程碑版本,性能提升30%》一文中,是这样描述的:
对 2.7.5 版本之前的 Dubbo 应用,尤其是一些消费端应用,当面临需要消费大量服务且并发数比较大的大流量场景时(典型如网关类场景),经常会出现消费端线程数分配过多的问题。
同时文章给出了一个issue的链接:
https://github.com/apache/dubbo/issues/2013
这一小节,我就顺着这个issue#2013给大家捋一下Dubbo 2.7.5版本之前的线程模型存在的问题,准确的说,是客户端线程模型存在的问题:
首先,Jaskey说到,分析了issue#1932,他说在某些情况下,会创建非常多的线程,因此进程会出现OOM的问题。
在分析了这个问题之后,他发现客户端使用了一个缓存线程池(就是我们前面说的客户端线程实现方式是cached),它并没有限制线程大小,这是根本原因。
接下来,我们去issue#1932看看是怎么说的:
https://github.com/apache/dubbo/issues/1932
可以看到issue#1932也是Jaskey提出的,他主要传达了一个意思:为什么我设置了actives=20,但是在客户端却有超过10000个线程名称为DubboClientHandler的线程的状态为blocked?这是不是一个Bug呢?
仅就这个issue,我先回答一下这个:不是Bug!
我们先看看actives=20的含义是什么:
按照官网上的解释:actives=20的含义是每个服务消费者每个方法最大并发调用数为20。
也就是说,服务端提供一个方法,客户端调用该方法,同时最多允许20个请求调用,但是客户端的线程模型是cached,接受到请求后,可以把请求都缓存到线程池中去。所以在大量的比较耗时的请求的场景下,客户端的线程数远远超过20。
这个actives配置在《一文讲透Dubbo负载均衡之最小活跃数算法》这篇文章中也有说明。它的生效需要配合ActiveLimitFilter过滤器,actives的默认值为0,表示不限制。当actives>0时,ActiveLimitFilter自动生效。由于不是本文重点,就不在这里详细说明了,有兴趣的可以阅读之前的文章。
顺着issue#2013捋下去,我们可以看到issue#1896提到的这个问题:
问题1我已经在前面解释了,他这里的猜测前半句对,后半句错。不再多说。
这里主要看问题2(可以点开大图看看):服务提供者多了,消费端维护的线程池就多了。导致虽然服务提供者的能力大了,但是消费端有了巨大的线程消耗。他和下面issue#4467的哥们表达的是同一个意思:想要的是一个共享的线程池。
我们接着往下捋,可以发现issue#4467和issue#5490
对于issue#4467,CodingSinger说:为什么Dubbo对每一个链接都创建一个线程池?
从Dubbo 2.7.4.1的源码我们也可以看到确实是在WarppedChannelHandler构造函数里面确实是为每一个连接都创建了一个线程池:
issue#4467想要表达的是什么意思呢?
就是这个地方为什么要做链接级别的线程隔离,一个客户端,就算有多个连接都应该用共享线程池呀?
我个人也觉得这个地方不应该做线程隔离。线程隔离的使用场景应该是针对一些特别重要的方法或者特别慢的方法或者功能差异较大的方法。很显然,Dubbo的客户端就算一个方法有多个连接(配置了connections参数),也是一视同仁,不太符合线程隔离的使用场景。
然后chickenij大佬在2019年7月24日回复了这个issue:
现有的设计就是:provider端默认共用一个线程池。consumer端是每个链接共享一个线程池。
同时他也说了:对于consumer线程池,当前正在尝试优化中。
言外之意是他也觉得现有的consumer端的线程模型也是有优化空间的。
这里插一句:chickenlj是谁呢?
刘军,GitHub账号Chickenlj,Apache Dubbo PMC,项目核心维护者,见证了Dubbo从重启开源到Apache毕业的整个流程。现任职阿里云云原生应用平台团队,参与服务框架、微服务相关工作,目前主要在推动Dubbo开源的云原生化。
他这篇文章的作者呀,他的话还是很有分量的。
之前也在Dubbo开发者日成都站听到过他的分享:
如果对他演讲的内容有兴趣的朋友可以在公众号的后台回复:1026。领取讲师PPT和录播地址。
好了,我们接着往下看之前提到的issue#5490,刘军大佬在2019年12月16日就说了,在2.7.5版本时会引入threadless executor机制,用于优化、增强客户端线程模型。
threadless是什么?
根据类上的说明我们可以知道:
这个Executor和其他正常Executor之间最重要的区别是这个Executor不管理任何线程。
通过execute(Runnable)方法提交给这个执行器的任务不会被调度到特定线程,而其他的Executor就把Runnable交给线程去执行了。
这些任务存储在阻塞队列中,只有当thead调用waitAndDrain()方法时才会真正执行。简单来说就是,执行task的thead与调用waitAndDrain()方法的thead完全相同。
其中说到的waitAndDrain()方法如下:
execute(Runnable)方法如下:
同时我们还可以看到,里面还维护了一个名称叫做sharedExecutor的线程池。见名知意,我们就知道了,这里应该是要做线程池共享了。