头图

组件发布效率提升15倍是怎么做到的——基于Gradle调度机制深度研究与优化

字节跳动终端技术

作者:字节跳动终端技术——兰军健 孙雄

一、背景

很多大型 Android 项目为了提高编译速度均采用了aar源码切换容器化框架,该方案通过定期发布aar产物来承担缓存的角色从而实现编译加速。在字节有些项目在接入框架的过程中遇到了奇怪的问题,比如飞书项目大概有200+的模块,首次接入时尝试全部发布,发现在Mac(12核,32G)上最快也要1h+,有时甚至会出现类似“卡死”的现象,最差情况出现过4h。抛开这个问题,相信负责研发流程建设的同学在高并发发布大量组件时应该也遇到过耗时严重的问题。

耗时的根本原因是什么呢?本文会借助该问题的排查过程,揭秘 Gradle 的核心调度机制!

二、初步分析

对于组件发布慢的若干疑问

遇到这个问题我们应该怎么去分析呢?针对编译构建速度异常缓慢的问题,通常会从以下几个维度进行考虑:

  • 是否存在异常 task 或者异常自定义代码
  • 内存问题
  • 并发度问题

这里排查过程就不展开介绍了,用尽一切手段排查后,得出了一些初步结论。

  1. 内存不是第一影响要素

通过更换高配置机器验证,将运行内存从 9g 调整到 40g,结果并没有明显改善

  1. 数据显示,一旦发生“卡死”现象,排名靠前的耗时Task几乎全部指向 VerifyLibraryResources这个Task

查看了该Task的源码,并没有发现明显的逻辑问题,此外,还有个现象是不卡死的时候,这个Task也不一定全部排名靠前。潜意识里觉得可能和这个Task有关,但即使有关也应该是某些调度机制出了问题。

  1. 并发度排查

通过控制台观察到绝大部分情况 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类提供的beforeExecuteafterExecute方法进行测量,结果显示确实是6min+。那问题到底出在了哪里呢?

为了彻底弄清楚我们发布组件的耗时问题与Gradle task耗时度量不准确的问题,我们正式进入Gradle 调 度机制的探索章节。

三、Gradle的调度机制

先放一张整体的调度机制架构图,这里面有些名词可能会让大家疑惑,后面会详细给大家解释。

Gradle项目,由一个或者多个Project构成,每个Project包含多个Task,如下图所示:

两个重要原则

Gradle调度要解决的核心问题归纳成一句话就是:以最合理的顺序执行完所有的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得到执行权,如下图所示:

从时间轴上看,Task1Task2是并行执行的。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 ThreadWorkItem Thread执行任务前,向管理员申请令牌,申请成功才可以工作。一旦线程主动进入WAITING状态,就需要归还,直到下次开始执行任务前,再去申请。Gradle把这个虚拟令牌叫做WorkLease(lease n.租约,租赁)。

由于WorkLease只是为了约束工作线程的数量,它的申请和释放机制非常简单,仅仅是数字的增减,而不涉及到加锁解锁这样很重的操作,gradle的实现如下:

下图模拟了两个线程在maxWorkerCount1的时候,申请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时间。当然,如果线程选择等待,框架应该在合适的时间将其唤醒。

Gradle定义了一个基础接口叫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执行的比原来快多了,假死的现象不再存在。

具体的优化数据见下表:
H7RmEoG7PJ.png

image.png

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 调度机制的介绍。欢迎交流与讨论!

火山引擎MARS

火山引擎应用开发套件MARS是字节跳动终端技术团队过去九年在抖音、今日头条、西瓜视频、飞书、懂车帝等 App 的研发实践成果,面向移动研发、前端开发、QA、 运维、产品经理、项目经理以及运营角色,提供一站式整体研发解决方案,助力企业研发模式升级,降低企业研发综合成本。

阅读 759
22 声望
14 粉丝
0 条评论
22 声望
14 粉丝
文章目录
宣传栏