如何从超时的GitHub Actions测试中获取线程转储
我如何从超时的 GitHub Actions 测试中获取线程转储
一个测试在 CI 中挂起,作业在那里卡了 30 分钟,随后 GitHub Actions 将其取消。日志只说明作业超出了时间限制,却没有说明测试卡住时在做什么。在本地重新运行几乎无济于事,因为在 2 核运行器上出现的时序问题,在 16 核笔记本上可能几乎无法复现。
那一刻你想要的是测试 JVM 的线程转储,而且要在测试仍挂起时抓取。我花了一些时间确认 GitHub Actions 是否为此留出了空间,尝试了几种获取方式,并最终基于发现构建了一个 action。我构建了 ThreadMine,它是该 action 将转储发送到的分析器;不过下面这些收集方法都不需要它。
超时后 JVM 还在运行吗?
这是首先要检查的事情,因为如果 GitHub 立即杀死测试 JVM,就没有东西可收集了。我写了一个故意死锁的测试,在 ubuntu-latest 和 macos-latest 上运行,分别使用步骤级和作业级超时。每一次,分叉出的测试 JVM 在超时后都仍然存活。jcmd 可以附加到它,转储显示两个线程都停在它们发生死锁的锁上。它会一直保持这种状态,直到运行器最后执行“Cleaning up orphan processes”,这就留下了一个抓取证据的窗口。
它能奏效的原因写在 GitHub 的取消文档中。运行器会向步骤的入口进程发送 SIGINT,等待 7.5 秒,再发送 SIGTERM,再等 2.5 秒,然后才杀死整个进程树。对于 run 步骤,入口进程是 shell,而 bash 会在收到第一个 SIGINT 时退出,因此进程树杀灭从未发生,它启动的 JVM 被留在运行状态。如果你的步骤使用了 exec,或者使用了会捕获这些信号的脚本,你就失去了这个窗口,因此我会把该窗口当作需要在你自己的环境中确认的事项。
方案 1:在测试旁边放一个看门狗
Apache Kafka 就是这样做的。它的线程转储脚本在 Gradle 运行测试时于后台启动,休眠到超时前五分钟,对每个 GradleWorkerMain 进程运行 jstack,然后工作流将文件保存为构件。它不依赖 JVM 在取消后继续存活,这一点适合 Kafka,因为他们的步骤用系统的 timeout 命令包住了 Gradle。
问题在于时机的选择。在限制前五分钟,你可能转储的是一个只是慢、本来会完成的测试;如果把时间卡得更近,又可能完全错过进程。
方案 2:让 Surefire 来做
使用 Maven 时,Surefire 3.6.0 及更高版本可以在分叉进程触发自身超时时进行转储:
Surefire 会在杀死分叉进程之前写入一个 surefire-timeout-jstack-*.txt 文件,你可以将其作为构件上传。Surefire 的超时必须短于 GitHub 的超时,否则 GitHub 会在 Surefire 来得及处理之前停止作业。仅适用于 Maven。
方案 3:在后续步骤中转储
由于 JVM 仍然存活,测试之后一个带有 if: failure() || cancelled() 的步骤仍能访问到它。我在 ubuntu-latest 上分别用步骤超时和作业超时运行了这一点,两种情况下该步骤都运行了,并发现 Surefire 分叉进程仍卡在死锁中。这个 action 就建立在这种方法之上。
无需提前猜测时间,同一个步骤既能覆盖 Surefire 分叉进程,也能覆盖 Gradle 测试 worker。jcmd 来自 actions/setup-java 安装的 JDK。我会在步骤超时和作业超时之间留几分钟,让转储和上传有足够时间完成。
解读转储
测试 JVM 中有很多与你的测试无关的线程:GC、JIT 编译器,以及 Surefire 或 Gradle 用来与构建通信的各种线程。经典死锁是简单情况。当两个线程互相等待对方的 synchronized monitor 或 ReentrantLock 时,转储会以 Found one Java-level deadlock 结尾,并列出这两个线程和相关的锁。
不过,大多数挂起并不是环路。一个没人执行倒计数的 CountDownLatch、一个没有超时的 Future.get(),或者一个所有 worker 都阻塞在同一调用上的线程池,都不会在转储中得到专门的小节。我会从测试线程开始(Surefire 下是 main,Gradle 下是 Test worker),看看它停驻在什么上,然后寻找本应唤醒它的线程。有时这只需要看一个栈,有时要看四十个栈。
把它做成一个 action
我不想把那段脚本粘贴到每个仓库中,所以它变成了 threadmine-action:
把它放在测试命令之前。它是一个 JavaScript action,真正的工作发生在它的 post 步骤中,该步骤只在作业失败或被取消时运行,并且在我测试过的每一次超时中,它都在孤儿进程清理之前运行。它用 jcmd -l 列出 JVM,保留 Surefire 和 Failsafe 分叉进程以及 Gradle 测试 worker。Gradle daemon 会被跳过,因为它在每次构建后都存活,无法说明哪个测试卡住了。当 forkCount=0 时,测试在 Maven 自身内部运行,因此这种情况下它会转储 Maven 进程。它也会收集 Surefire 在自身超时时写入的文件。
转储始终会进入工作流构件。开启分析后,其中最多三个会发送到 ThreadMine,结果会出现在作业摘要中;如果拥有 pull request 权限,则会生成一条 PR 评论,后续运行会更新这条评论,而不是不断堆积新评论。在该 action 自身的测试中,两个名为 worker-A 和 worker-B 的线程各自等待对方的锁,评论指出了死锁、这两个线程,以及它们在 DeadlockTest.java 中停下的行。
普通的断言失败不会触发这些,因为 post 步骤运行时测试 JVM 已经退出。不过,如果在测试过程中手动取消一次运行,确实会留下一个存活的 JVM,因此该 action 只会在分析确实发现问题时才评论 PR。
会发送什么
开启分析后,转储会发送到 threadmine.dev。仓库名称不会被发送,未使用账户生成的报告会在七天后过期。如果线程转储不能发送给第三方,请设置 analyze: false。你仍然会在正确时机获得转储,并且它只会进入工作流构件,GitHub 会像存储其他 CI 输出一样存储它。
没有账户时,每个 IP 每小时最多分析五次,每天最多 20 次。GitHub 托管的运行器共享 IP,因此在繁忙的日子,摘要会说明分析被跳过,而转储仍保存在构件中。暂不支持 Windows 运行器;如果 action 本身出问题,你会收到警告,而不会让作业多一个失败原因。
该 action 采用 MIT 许可证,源代码在 GitHub 上。即使你从不使用它,也请在测试之后放一个转储步骤。下一次测试在 CI 上挂起时,你看到的是线程栈,而不是只能重跑并指望它再次挂起。## 加入讨论
Java [LOADING...]
[LOADING...]Felipe Maschio2026年10月27日 262 次浏览
当你的 Java 服务使用虚拟线程时,jstack 会遗漏什么
JavaJava 核心调试
Java [LOADING...]
[LOADING...]Felipe Maschio2026年7月16日 1,487 次浏览
你的 Loom 应用悄悄又变回了线程池:虚拟线程固定实战指南
JavaJava 核心
Java [LOADING...]
[LOADING...]Felipe Maschio2026年9月9日 600 次浏览
在不破坏分析的情况下对 JVM 线程转储进行脱敏
JavaJava 核心安全
AI [LOADING...]
[LOADING...]Bruno Borges2026年2月16日 3,463 次浏览