两周前,我就向 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 要进行兼容性测试,每个测试用例分为 AppLibrary 工程,所以,当兼容性测试就有 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
johnsonlee@johnsonlee:~/.gradle $ du -sh *
12K 6.0.1
92K build-scan-data
8.0K buildOutputCleanup
12G caches
1.7G daemon
4.0K gradle.properties
4.0K init.gradle
0 jdks
832K kotlin-profile
1.1M native
0 notifications
78M test-kit-daemon
0 workers
1.6G wrapper

特么逗我呢,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
johnsonlee@johnsonlee:$TMPDIR/.gradle-test-kit-johnsonlee/caches $ ll
total 4
drwxr-xr-x 15 johnsonlee 510 Oct 31 21:08 ./
drwxr-xr-x 6 johnsonlee 204 Oct 31 20:23 ../
drwxr-xr-x 8 johnsonlee 272 Oct 31 20:23 4.1/
drwxr-xr-x 12 johnsonlee 408 Oct 31 21:31 4.10.1/
drwxr-xr-x 7 johnsonlee 238 Oct 31 20:23 4.6/
drwxr-xr-x 13 johnsonlee 442 Oct 31 21:14 5.4.1/
drwxr-xr-x 13 johnsonlee 442 Oct 31 21:14 5.6.4/
drwxr-xr-x 13 johnsonlee 442 Oct 31 21:12 6.2/
drwxr-xr-x 12 johnsonlee 408 Oct 31 21:16 6.5/
drwxr-xr-x 901 johnsonlee 30634 Oct 31 21:58 jars-3/
drwxr-xr-x 779 johnsonlee 26486 Oct 31 21:58 jars-8/
drwxr-xr-x 5 johnsonlee 170 Oct 31 20:23 journal-1/
lrwxr-xr-x 1 johnsonlee 43 Oct 31 21:08 modules-2 -> /Users/johnsonlee/.gradle/caches/modules-2//
drwxr-xr-x 6 johnsonlee 204 Oct 31 21:31 transforms-1/
drwxr-xr-x 5 johnsonlee 170 Oct 31 21:12 transforms-2/

实测效果如下:

同样是 12 分钟的位置,明显比之前要快了许多,最终全部构建完成花了 22m 40s ,速度差不多提升了一倍。

尽管性能大有改善,然而对于追求极致的工程师来说,还是存在一些瑕疵,虽然 ~/.gradle/caches/build-cache-1 这个锁已经没有了,但是另一个锁会偶尔出现,如下图所示:

至于这个问题嘛,未完待续。。。