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

43次阅读

共计 8935 个字符,预计需要花费 23 分钟才能阅读完成。

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

一、背景

很多大型 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、运维、产品经理、项目经理以及经营角色,提供一站式整体研发解决方案,助力企业研发模式降级,升高企业研发综合老本。

正文完
 0