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

一、背景

很多大型 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执行的比原来快多了,假死的景象不再存在。

具体的优化数据见下表:

无奈复制加载中的内容

并行度计算方法:

实在并行度 = (所有Task执行工夫之和 - 期待锁的工夫) / 构建破费的天然工夫

gradle默认统计的并行度 = 所有Task执行工夫之和 / 构建破费的天然工夫

并行度计算方法:

实在并行度 = (所有Task执行工夫之和 - 期待锁的工夫) / 构建破费的天然工夫

gradle默认统计的并行度 = 所有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 调度机制的介绍。欢送交换与探讨!

火山引擎MARS

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