组件发布效率提升 15 倍是怎么做到的——基于 Gradle 调度机制深度研究与优化
一、背景
很多大型 Android 项目为了提高编译速度均采用了 aar 源码切换容器化框架,该方案通过定期发布 aar 产物来承担缓存的角色从而实现编译加速。在字节有些项目在接入框架的过程中遇到了奇怪的问题,比如飞书项目大概有 200+的模块,首次接入时尝试全部发布,发现在 Mac(12 核,32G)上最快也要 1h+,有时甚至会出现类似“卡死”的现象,最差情况出现过 4h。抛开这个问题,相信负责研发流程建设的同学在高并发发布大量组件时应该也遇到过耗时严重的问题。
耗时的根本原因是什么呢?本文会借助该问题的排查过程,揭秘 Gradle 的核心调度机制!
二、初步分析
对于组件发布慢的若干疑问
遇到这个问题我们应该怎么去分析呢?针对编译构建速度异常缓慢的问题,通常会从以下几个维度进行考虑:
是否存在异常 task 或者异常自定义代码
内存问题
并发度问题
这里排查过程就不展开介绍了,用尽一切手段排查后,得出了一些初步结论。
内存不是第一影响要素
通过更换高配置机器验证,将运行内存从 9g 调整到 40g,结果并没有明显改善
数据显示,一旦发生“卡死”现象,排名靠前的耗时 Task 几乎全部指向了
VerifyLibraryResources
这个 Task
查看了该 Task 的源码,并没有发现明显的逻辑问题,此外,还有个现象是不卡死的时候,这个 Task 也不一定全部排名靠前。潜意识里觉得可能和这个 Task 有关,但即使有关也应该是某些调度机制出了问题。
并发度排查
通过控制台观察到绝大部分情况 Gradle 的并行线程数是打满的,也就是“表面”上并发度还可以,又经过了一系列的猜测与排查,最终决定降低并发度试试。
这里简单的提一下,max-workers
可以指定 Gradle 在并发执行 task 时真正工作的线程个数。如果不指定,其大小与 cpu 核数一致,如上图所示配置代表我们将并行度由原来的 16 个线程(16 核 CPU)调整为 2 个线程。惊喜出现!出乎意料地在 30min 内完成了打包。这现象就非常有意思了,我们降低了并发度,编译速度却明显加快了,是不是有点毁三观?那岂不是说用高配机器反而会更慢?来验证一下。
在高配机(92 核,300G 内存)上开了 20 个线程,用的 JDK11,G1 垃圾回收器,Xmx 设置为 40G,速度依旧让人大跌眼镜,一共花了 50 分钟的时间,甚至还不如笔记本的表现。
进一步用jstack
打印线程堆栈,发现虽然编译时控制台显示有大量 Task 在执行,但其中大多数执行线程处于WAIT
或者BLOCK
状态,真正工作的线程只有一两个。
上面两张图分别是 Gradle 显示的并行执行状况和使用 jvm 分析工具抓到的线程实际执行情况。虽然 Gradle 显示有 10 个线程正在干活,但是只有一个线程的状态为RUNNABLE
状态,其他都为BLOCKED
状态。其他的线程为什么 BLOCK 住了呢?
这里就出现了很多疑问:
为什么线程数设置少了,效率反而提高了
为什么高配机毫无作用
为什么大量线程处于 BLOCKED 状态
带着这些疑问,我们决定针对 Gradle 的调度机制做一次彻底的分析。分析之前我们先插播一段关于 Task 的执行时间的统计准确性问题。
你真的能准确收集到 Task 的执行时间吗?
如何去度量编译过程中某些 task 的耗时呢?我们一般是通过在 gradle-scan 或者 hummer(内部自研)上查看 Timeline,如下图所示。然后针对耗时排名靠前的 task 进行优化,之前也有不少的同学来咨询,比如 mergeDebugNativeLibs 等 Task 比较耗时,但是查看逻辑也不复杂,然后可能就没思路了。
以抖音项目为例,会发现上图显示的这两个 Task 在某些编译过程中非常耗时,耗时 6min+,这里通过修改源码及一些 hook 方式进行了测量,真实的逻辑执行时间其实只需要 20s。是我们收集方式有问题吗?我们一般是通过监听器,例如TaskExecutionListener
类提供的beforeExecute
和afterExecute
方法进行测量,结果显示确实是 6min+。那问题到底出在了哪里呢?
为了彻底弄清楚我们发布组件的耗时问题与 Gradle task 耗时度量不准确的问题,我们正式进入 Gradle 调 度机制的探索章节。
三、Gradle 的调度机制
先放一张整体的调度机制架构图,这里面有些名词可能会让大家疑惑,后面会详细给大家解释。
G
radle
项目,由一个或者多个Project
构成,每个Project
包含多个Task
,如下图所示:
两个重要原则
G
radle
调度要解决的核心问题归纳成一句话就是:以最合理的顺序执行完所有的 Task,并且充分发挥多核计算机的并行处理能力。
最合理的顺序
用户定义Task
之间的依赖关系,这些Task
的依赖关系构成 DAG 图(有向无环图),而 Gradle 根据 DAG 图的顺序进行调度,下图给出了一个 DAG 图的示例,其中绿色的为叶子节点,没有其他依赖,应该优先执行:
调度时,对所有节点根据出度进行拓扑排序,并按照拓扑顺序执行,可以达到理论上最优。
并行处理能力
现在不管是个人 PC 还是大型服务器几乎都是多核 CPU 的配置。用户通常愿意使用多核 CPU 来执行 Gradle 任务,以达到更优的构建效率。作为框架本身来说,要想支持好并行构建,既要保证并行带来的线程安全问题,又要有办法提供足够高的并行度以满足客户需求。
为了保证线程安全,Gradle 有一个重要限定:同一个Project
下的不同Task
不可以并行执行。这个限定是出于线程安全考虑,因为每个 Task 执行的时候,都可以拿到所属 Project 的上下文信息,Task 间并不是完全隔离的,存在资源耦合的情况。
这个约定可能很多 Android 开发的同学平时都没有注意到,使用过 Gradle-Scan 的同学对上图是非常熟悉的,心里肯定会质疑,比如上图中 app:mergeExtDexDebug 和 app:mergeDebugNativeLibs 这两个 task 很明显就并行了啊。别着急,我们先写个最简单的代码测试下。
如下的代码在简单不过了,finalTask 依赖了 task1 和 task2,task1 和 task2 无依赖关系,task1 休眠 4s 模拟一下运行耗时,task2 休眠 2s,运行./gradlew finalTask,你猜一下打印的结果是什么,task1 和 task2 会并行吗?
答案揭晓:可以看到,确实三个 task 都是由同一个线程执行的,整个过程是完全串行的,task1 先执行后,休眠了 4s,task2 才开始执行,2s 后,finalTask 开始执行。起码到这里这个理论都是成立的,同一个 project 下的 Task 是不允许并发执行的。
那怎么解释我们平时开发时看到的 timeline 上显示的并发现象呢?其实是依赖了 Worker API 来实现的,这里就要正式介绍一下 Worker API 了。
关于 Worker API
前文说道,同一个 project 下的 task 不允许并发执行,那问题来了,在 Android 编译过程中,我们经常会遇到同一个Project
下有很多Task
需要执行,且它们大多都没有依赖关系。如果不能并行,那整个 Gradle 构建的并发度就很有问题了,理论最高并行度受制于Project
的个数。
为了解决这个问题,Gradle 给出了一种叫做Worker API的解决方案。不了解 WorkerAPI 的同学,建议先大致看一下 Gradle 的官方文档。这里简单的对比下其与普通 Action 的书写区别:
站在使用者的角度,可以简单的理解为 Gradle 内部提供了一个线程池,我们想让耗时的操作异步执行,可以借助 WorkerAPI 进行 submit,每 submit 一次就会产生一个任务,这个任务下文统称为WorkItem
。感兴趣的同学可以去做个试验,同一个 project 下的 task 全部改成 workAPI 来实现,你会惊喜的在 Gradle-Scan 的 timeline 上看到,这些 task 都并行执行了。所以,为什么前文中的 app 模块下的很多同 project 下的 Task 看起来是并行的,就是因为 Android Gradle Plugin 中大量使用了 WorkerAPI,还有质疑的同学可以看一下相关 task 的实现去验证下结论,不知道到这里有没有勾起你的好奇心,请继续往下看深层次的原因。
WorkerAPI 到底是怎么运转起来的呢?它的设计理念是,让Task
的一部分不包含 Project 信息的内容在后台执行,从而让出对Project
的控制权,使得Project
内的其他Task
得到执行权,如下图所示:
从时间轴上看,Task1
和Task2
是并行执行的。Task1
使用Work
er
API
提交了WorkItem
, 然后Task1
的执行线程会让出Project
的控制权,并使线程进入WAITING
状态,等WorkItem
执行完毕,再将其唤醒。
其实,早期 gradle 使用
ParalleizableTask
注解,将一个 Task 标记为可并行的 Task,但在 4.0 版本之后移除了这个 feature,换成了现在的Worker API
,原因是 Task 可以直接获取 Project 对象,后者包含太多的可变状态,造成线程不安全。关于Worker API
替代ParalleizableTask
,有一个很有趣的讨论帖,感兴趣的话可以了解一下。
如果你的线程数足够,甚至可以把 Background 的任务分解成多个更小的WorkItem
,丢进队列里让线程执行。
这样的话,工作线程就分成了两类。
Task Thread:找到可以执行的
Task
,并执行它所有的Action
。WorkItem Thread:从队列里消费
WorkItem
,并执行它。
Gradle 内部实现的时候,把 Task Thread 叫做 Execution Worker Thread, 而 WorkItem Thread 叫做 WorkExecutor Queue Thread,由于这两个名字过于相近,不便于理解,这里在表述的时候起了两个别名,特此注明一下。
我们再从单个Task
的视角看一下,看一下它的各个执行阶段是由哪类线程去执行的。我们把一个Task
的生命周期分为前置处理,检查增量缓存,执行 Action,后置处理 4 个环节。WorkItem
是在执行 Action 的过程中提交的。
从图中可以看出, Task Thread 在提交了WorkItem
之后,会进入WAITING
状态,直到WorkItem
执行完毕。
Task Thread 可以提交多个WorkItem
,这样就可以起到并行执行的作用。但如果在一个 Task 中大量提交WorkItem
,是否会导致线程过多,造成 CPU 负载过重呢。
Gradle 考虑到了这一点,虽然没有限制 WorkItem 的总线程数,但是严格控制了实际工作的线程数,不能超过用户设定的上限。
这样设计的合理性在于,用户定义的 worker 数上限表示自己愿意分出多少 CPU core 给 gradle 使用。而 gradle 内部不管怎么划分 worker 的职责,都应该保证对 CPU 的总消耗不超过用户的限制。
如何保证实际工作的线程数不超过上限呢?
可以通过发放令牌的方式。有一个管理员负责发放有限数量的令牌,Task Thread
和WorkItem Thread
执行任务前,向管理员申请令牌,申请成功才可以工作。一旦线程主动进入WAITING
状态,就需要归还,直到下次开始执行任务前,再去申请。Gradle 把这个虚拟令牌叫做WorkLease
(lease n.租约,租赁)。
由于WorkLease
只是为了约束工作线程的数量,它的申请和释放机制非常简单,仅仅是数字的增减,而不涉及到加锁解锁这样很重的操作,gradle 的实现如下:
下图模拟了两个线程在maxWorkerCount
为 1 的时候,申请 WorkLease 的过程。
调度机制对时间统计的影响
对于没有使用 Worker API 的Task
,该Task
的执行过程是连贯的。但使用 Worker API 之后,线程会在提交WorkItem
的那个Action
执行完之后,进入WAITING
状态,直到WorkItem
执行完毕。
在进入WAITING
状态之前,线程会将Project
的控制权释放出来,从WAITING
状态恢复后,如果还有其他的Action
要执行,该线程又需要重新夺回Project
的控制权。
然而,Project
的控制权真的能立刻夺回来吗?答案是否定的。我们把上面的描述单个 Task 线程状态的图扩展一下。
红色的部分就是抢回Project
锁的过程, 这个过程可能很长,甚至是Task
本身执行时间的几十倍,但却被统计在了Task
的执行时间里,确实是不太合理的一件事情。
锁竞争
从上面的描述中可以看到,无论是 Task Thread,还是 WorkItem Thread,都涉及到对某些虚拟资源的竞争,拿到控制权才可以执行。
资源竞争并不是用户关心的,这部分的时间开销应该越小越好。从框架实现的角度来说,线程一旦竞争某个虚拟资源失败,就应该立刻作出是等待还是直接放弃的响应,而不应该无休止的重试,占用 CPU 时间。当然,如果线程选择等待,框架应该在合适的时间将其唤醒。
Grandle 定义了一基础接口叫ResourceLock
(资源锁),无论是WorkLease
还是ProjectLock
,都是基于这个接口扩展或实现的。
线程一次可以操作多个ResourceLock
,通过全局锁机制保证它是一个原子操作。Gradle 规定,原子操作的返回值只有可能是 3 种: RETRY, FINISHED, FAILED。 对于每一种返回值,都采用固定的处理方式,如下表所示:
无法复制加载中的内容
这样设计的好处是,调用方在定义一个“操作”的时候,只需简单记录操作过程中持有或释放的ResourceLock
, 以及这个操作的结果是什么就可以了,而不需要关心ResourceLock
的释放以及线程的等待唤醒等等,这些都由底层组件实现掉了。
以一个 gradle 中的具体调用为例,看一下实际的场景。
withStateLock 是 gradle 提供的原子接口,这里是对一堆 ResourceLock 加锁,lock(locks)返回一个对象,这个对象所属的类有一个很重要的 transform 方法,用来定义原子操作的返回值。再看一下 transform 方法的实现:
这个 transform 操作方法刚好包括了 RETRY, FAILED, FINISHED 三种返回值,以满足不同的场景。
如果上面的例子依然觉得难以理解,下面的这个例子可以给大家一个更直观的感受:
假设有 3 个线程和 2 种不同的虚拟资源,线程对虚拟资源的需求关系如下所示:
我们以一种可能的顺序,对资源的申请释放进行模拟:
从这个例子中可以看出,一旦有线程释放ResourceLock
,就会唤醒处于WAITING
状态的线程。因为 Gradle 假定,任何一个处于WAITING
状态的线程,都有可能需要获取被释放出的ResourceLock
。
这并非是一个完美的解决方案,如果记录每一个线程所需要的资源,当有资源被释放时,只唤醒相关的线程,效率可能会更高一些。
四、度量与优化
以上内容阐述了 Gradle 调度框架的设计理念和实现细节,现在我们再回过头看一下 Lark 项目发布过慢的问题。
一个Task
,从被线程选中,到执行完毕,经历了非常多的过程:
而 Gradle 提供的钩子,只能在前置处理和后置处理那里打上时间戳,统计整个过程的耗时,这样的统计粒度无疑太粗了。要想深入挖掘问题的本质,只能采用魔改 Gradle 的方式,在 Task 执行的内部插入更多的钩子。上图中的每一个小部分的开始和结束,都应该记录时间戳,用于数据分析,除此之外,还应该统计 WorkItem 的提交次数和每一个 WorkItem 的执行时间,因为这和线程调度和锁竞争有很大的关系。
可以用一个TaskStatistic
类统计这些信息,用静态成员taskStateMap
,记录所有Task
的属性和耗时情况。每个Task
又持有一个actionStateList
,用于统计所有的Action
的属性和耗时情况,类结构如下所示:
构建结束后,将原始数据以 JSON 格式输出,并通过脚本进行二次分析,可以挖掘更多有用信息。
分析脚本去掉了每个Task
花在抢Project
锁的时间后,可以得到较为准确的 Task 实际执行的时间。此外,通过累加WorkItem
的执行时间,也可以计算出Task
实际消耗的 CPU 时间,从而可以更加精确的计算出实际的并行度。
用脚本分析后的数据如下图所示:
从数据上可以看出,有些Task
提交了几千个WorkItem
,这是一个非常不合理的数字,会带来极大的锁竞争开销。
主要体现在两个地方:
(1)提交WorkItem
的时候,需要上锁,多个线程同时大量提交WorkItem
,会产生激烈的竞争
(2)每一个WorkItem
执行完毕的时候,会调用一个公共对象的notifyAll
方法,唤醒所有处于wait
的线程。
先看第一点,找到 Gradle 源码中关于提交WorkItem
的部分:
如果多个线程都在提交大量WorkItem
,由于提交这个动作本身的执行是很快的,锁竞争开销就会在总时间中占用相当大的比例。我们在高配机器上开 20 个线程测试的时候,锁竞争导致某个Task
在执行的时候,submit
几千次WorkItem
的时间达到了 300 多秒,实际上后来我们发现这个动作真正执行的时间不到 1 秒。
再看第二点,我们在讲 Gradle 调度机制的时候提到过,gradle 抽象出一种叫ResourceLock
的资源锁,释放资源锁的时候,会调用notifyAll
方法通知所有等待资源的线程。
每一个WorkItem
的执行都需要占用一个令牌WorkLease
,它是ResourceLock
的一种,执行完毕会调用notifyAll
通知所有等待的线程。而由于我们提交了大量的WorkItem
,也就导致了这里的notifyAll
调用的频率非常高,造成了大量的线程切换的开销。
Gradle 的作者大概没有想到会遇到这样的场景吧。
为了验证我们只执行单个Task
会怎样呢。我们从采样数据里找到执行时间最长的 Task,直接执行:
结果这个 Task 只用了 3 秒多的时间, 300 秒 -> 3 秒,看来去掉这些额外的开销后,执行效率非常明显的提高了。
通过上文的分析可以看到,VerifyResources 相关的 Task 进行了大量的 WorkItem 的提交。查看下源码(AGP 3.5.3)发现在 VerifyResources 中确实针对每个输入资源都创建了一个 WorkItem,这对于大型工程而言简直是灾难了。
如果你理解了本文讲的调度机制,修复方案就很简单了,直接将 workExecutor 替换成一个自定义的线程池 executor,放弃掉 workerAPI 即可。
代码修改完毕后,只要在 Android Gradle Plugin 的 classpath 前进行覆盖即可。
针对同样的代码进行了测试,和优化前相比,构建时间从 2726 秒下降到了 178 秒,提升了 15 倍。并且原来霸占 Top10 的那些 verify 开头的 Task 通通不见了,从视觉效果上,也发现 Task 执行的比原来快多了,假死的现象不再存在。
具体的优化数据见下表:
Android Gradle Plugin 可能也意识到了这个问题,在高版本上进行了优化,对于未升级 AGP 版本但存在类似问题的项目可以采用类似方案来解决。
五、总结
本文从一次性能很差的组件发布过程开始分析,先初步定位到和影响并发度的参数有关,再通过仔细研究 Gradle 调度机制和精确的统计 Gradle 调度阶段的各种耗时,最终定位到性能差的问题是由 Worker API 的不正确使用导致的。Gradle 提供 Worker API 的功能,本意是把 Task 中一些可以后台执行的任务解放出来,不要占据Project
锁,以提高整体的构建效率。但如果大量提交耗时很小(微秒级)的WorkItem
,就会导致调度框架自身的开销占据了整体开销很大一部分比重。
这里再解释下最初引入的两个疑问:
为什么发布组件时调低并发度反而更快了
调低并发度,会减少锁竞争的程度。因为锁竞争导致的上下文切换已经成为性能瓶颈,换句话说,此时完全串行发布都可能比多线程发布效果好。同理,在高配机器上,由于机器性能强劲而人为调大并行度,反而会导致更多的线程争抢同一把锁,加大框架自身的调度开销,这也就是为什么换了高配机器,效率反而降低的原因。
为什么我们看到某些 Task 极其耗时,但是找不到原因
也同样是因为 gradle 的调度机制,一个含有 Worker API 调用的 Task 在执行过程中并不是"一口气"执行完的,中间会存在一次或多次释放锁等待,重新获取锁的过程。这个过程的长短就有点看运气了,取决的因素比较多,可能远大于 Task 的执行时间,从而造成某些 Task 执行巨耗时的假象。这一点如果不魔改 Gradle 暂时无法优化,我们团队也尝试找了一些点,基本上很难做到微小改动实现精确时间统计,这里只要注意下如果从事 Android 编译优化,不要被这些“表面数据”带偏了优化方向即可。
回顾这次 profile 的过程,有两点是非常关键的。
整套调度机制从代码层面的详细理解,需要深入的去研究透彻原理
是对所有可能的耗时点的精确统计,从数据角度去度量
以上就是这次问题排查的详细过程以及 Gradle 调度机制的介绍。欢迎交流与讨论!
🔥 火山引擎 APM 应用性能监控 是火山引擎应用开发套件 MARS 移动应用开发套件下的性能监控产品。我们通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。目前我们面向中小企业 特别推出「APM 应用性能监控 企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得 60 天免费性能监控服务,最高可享 6000 万条事件量。
版权声明: 本文为 InfoQ 作者【字节跳动终端技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/dedd0da295692bde223743b53】。文章转载请联系作者。
评论