
作者:悬衡
一、前言
在 Java 中有一个非常经典的死锁问题, 就是明明自己已经占用了线程池, 却还继续去申请它, 自己等自己, 就死锁了, 如下图和代码:

// 这段代码将死锁到天荒地老
final ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
Future<?> subTask = executorService.submit(() -> System.out.println("Hello dead lock"));
try {
subTask.get();
} catch (ExecutionException | InterruptedException ignore) { }
});
相比别的死锁问题, 这一类问题的坑点在于, 因为线程池的实现问题, jstack 等 jvm 工具无法对其自动诊断, 只能肉眼看出。
在 Kotlin 协程中, 因为底层的线程池申请更加黑盒, 如果不是足够了解, 很容易踩到这类坑。
本文不会再去重复 Kotlin 协程的基本语法, 而是专注于死锁的话题。
下面两段代码你觉得是否有死锁风险?:
-
第一段代码看起来很恶心, 但是它反而是没有死锁风险的
runBlocking(Dispatchers.IO) {
runBlocking {
launch (Dispatchers.IO) {
println("hello coroutine")
}
}
}
-
第二段代码看着 "挺简洁的", 其实是有死锁风险的
runBlocking(Dispatchers.IO) {
runBlocking {
launch (Dispatchers.IO) {
println("hello coroutine")
}
}
}
只要同一时间有 64 个请求同时进入这个代码块, 就永远不要想出来了, 而且因为协程的线程池都是复用的, 其他协程也别想执行了, 比如下面这段代码就能锁死整个应用:
// 用传统 Java 线程池来模拟 64 个请求
val threadPool = Executors.newFixedThreadPool(64)
repeat(64) {
threadPool.submit {
runBlocking(Dispatchers.IO) {
println("hello runBlocking $it")
// 在协程环境中本不应该调用 sleep, 这里为了模拟耗时计算和调用,不得已使用
// 正常协程休眠应该用 delay
Thread.sleep(5000)
runBlocking {
launch (Dispatchers.IO) {
// 因为死锁, 下面这行永远都打印不出来
println("hello launch $it")
}
}
}
}
}
Thread.sleep(5000)
runBlocking(Dispatchers.IO) {
// 别的协程也执行不了, 下面这行也永远打印不出来
println("hello runBlocking2")
}
随便翻翻代码仓库, 就能看到大量存在类似风险的代码, 之前还差点因此发生事故。
本文将会剖析 Kotlin 协程死锁的根本原因, 以及如何彻底地从坑中爬出来。
笔者主要是做服务端的, 文中内容可能更贴近服务端开发场景, 如果移动端场景有所不同, 也欢迎在评论区讨论。
二、runBlocking 线程调度常识
2.1 主线程的独角戏
runBlocking 从表面上理解就是开启一个协程, 并且等待它结束。
Java 的线程思维总让人觉得 runBlocking 会用一个新线程异步执行其中的代码块, 实际上不是这样。runBlocking 在不加参数时, 默认使用当前线程执行:
fun main() {
println("External Thread name: ${Thread.currentThread().name}")
runBlocking {
println("Inner Thread name: ${Thread.currentThread().name}")
}
}
输出如下:
External Thread name: main
Inner Thread name: main
如果我