这就是 Gradle 所谓的并行构建?
两周前,我就向 Booster 用户承诺 10 月底会发布 3.0.0
,集成测试在 10 月中旬其实就已经完成了,后来把集成测试框架 testkit-gradle-plugin 又重写了一遍(已经是第 3 次重写了),加上 Travis-CI 正在从 https://travis-ci.org 迁移到 https://travis-ci.com ,而 Booster 还是在 https://travis-ci.org 上,导致 CI 长时间的排队,加上跑集成测试时间过长(超过 50 分钟),任务被 Travis-CI 强行终止,所以,直到 10 月的最后一天才发布 v3.0.0-alpha-3,其实,在发布 alpha 版本的时候,任务被 Travis-CI 强行终止的问题都还没解决,只好把集成测试从 CI 中暂时移除掉,不过,这个问题已经有了解决方案。
CI 超时
Travis-CI 有个构建超时的规定,在下面这几种情况下构建任务会被认定为超时而被强行终止:
- 任务 10 分钟没有 log 输出
- 公共仓库的任务超过 50 分钟
- 私有仓库的任务超过 120 分钟
为什么 Booster 的集成测试要这么长时间呢?这得从 Booster 的兼容性适配方案说起。
AGP 版本适配
为了让 Booster 能在 Android Gradle Plugin 3.0.0
以上的所有版本稳定运行,Booster 针对 Android Gradle Plugin 的每个 minor 版本都做了适配,目前共适配了 7 个版本:
3.0.0
3.2.0
3.3.0
3.5.0
3.6.0
4.0.0
4.1.0
每个版本将近 30+ 个 API 要进行兼容性测试,每个测试用例分为 App 和 Library 工程,所以,当兼容性测试就有 7 * 30 * 2 = 420 个用例要跑,而 Travis-CI 的虚拟机配置是双核 CPU 加 7.5GB 内存,所以,性能可想而知,在我的 Mac Book Pro (i7 8 核,16GB 内存) 上都要跑将近 40 分钟,平均 5 秒跑完一个测试用例,这是在未开启并行构建的情况下。
由于 Travis-CI 的 50 分钟超时限制,于是尝试启用 Gradle 的并行构建:
1 | org.gradle.parallel=true |
本以为速度会有所改善,结果却让我大吃一惊,原来串行执行 5 秒跑完一个用例,改并行后却将近 20 秒才跑完,完全不合常理,常言道:事出反常必有妖。
为什么这么慢?
在 Gradle OOM 问题 这篇文章中有提到跑 Gradle 测试会用到 Gradle TestKit,它为会每个测试用例启一个 Gradle Runner ,每个测试用例就是一个 Gradle Android 工程,共有两种类型的工程:
- Android App
- Android Library
所以,总共有 420 个 Android 工程要进行构建,如果并行数量为 7 的话(有 7 个 Android 工程在同时进行构建),也要跑 60 轮,以每轮 5 秒跑完 7 个用例的速度,应该只需要 5 分钟就能跑完,即使时间再翻倍,也不过 10 分钟,为什么实际情况却大相径庭呢?
进程间同步锁
难道 Gradle 有做进程间同步?集成测试根据 Android Gradle Plugin 的版本,对应的有 7 个 Gradle 版本:
Android Gradle Plugin | Gradle |
---|---|
3.0.0 | 4.1 |
3.2.0 | 4.6 |
3.3.0 | 4.10.1 |
3.5.0 | 5.4.1 |
3.6.0 | 5.6.4 |
4.0.0 | 6.2 |
4.1.0 | 6.5 |
如果开启并行的话,就会同时启动 7 个不同版本的 Gradle ,莫非是这 7 个 Gradle 之间有竞争锁?于是用 lsof
命令看了一下 Gradle 正在用的文件锁,果然不出所料,有 7 个进程在竞争同一个文件锁,如下图所示:
缓存共享问题
看到这里,不禁想到 testkit-gradle-plugin 可以通过 org.gradle.testkit.dir
系统属性或者 GradleRunner.withTestKitDir(File) 设置 Gradle Runner 的缓存目录,默认是在 $TMPDIR/.gradle-test-kit-$USER
下,为了共享已下载的依赖缓存,我给改成了 ~/.gradle/
,这样可以让所有不同版本的 Gradle 共享缓存目录,但这带来的问题是所有不同版本的 Gradle 会竞争 ~/.gradle/caches/build-cache-1/build-cache-1.lock
这个锁,如果不共享,所有的依赖包都要重新下载一遍。这个问题在 2016 年就有人提了 issue-851,但直到 Gradle 6.1
官方才给出解决方案 —— Copying and reusing the cache,可见 Gradle 团队在这个问题上并不是很重视。
顺着这思路,我看了一下 ~/.gradle/
目录下缓存大小,默默的关了浏览器。
1 | johnsonlee@johnsonlee:~/.gradle $ du -sh * |
特么逗我呢,COPY 12 个 G,我还不如直接下载好了,难道就没有别的办法了么?其实 2015 年在滴滴做 The One 项目的时候,已经发现 Gradle 并不能实现不同工程的并行构建问题,只不过用了别的方式给绕过去了,在 第三章:被吐槽的反人类设计 这篇文章里有提到,没想到 5 年过去了,Gradle 还没有完全解决这个问题。
鱼和熊掌兼得
突然灵光一闪 —— 要是用软链接来共享缓存呢?据我所知,所有的 Gradle 依赖包都放在 ~/.gradle/caches/modules-2/
路径下,那么,我只要在 $TMPDIR/.gradle-test-kit-$USER/caches/
创建一个 modules-2
的软链接指向 ~/.gradle/caches/modules-2/
不就行了?
1 | $ ln -s ~/.gradle/caches/modules-2 $TMPDIR/.gradle-test-kit-johnsonlee/caches/modules-2 |
这样,目录链接就建好了。
1 | johnsonlee@johnsonlee:$TMPDIR/.gradle-test-kit-johnsonlee/caches $ ll |
实测效果如下:
同样是 12 分钟的位置,明显比之前要快了许多,最终全部构建完成花了 22m 40s ,速度差不多提升了一倍。
尽管性能大有改善,然而对于追求极致的工程师来说,还是存在一些瑕疵,虽然 ~/.gradle/caches/build-cache-1
这个锁已经没有了,但是另一个锁会偶尔出现,如下图所示:
至于这个问题嘛,未完待续。。。
- 本文链接:https://johnsonlee.io/2020/11/01/gradle-parallel-build/
- 版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。