前言
这不是一篇 API 速查表。
接下来 10 关,每一关都先别急着运行。
先凭直觉判断,再打开 IntelliJ IDEA 验证,最后回头看协程到底是怎么运行的。
闯关规则
- 不要直接复制代码运行,先预测结果。
- 尽量不要查 API,看看自己对协程调度、挂起和生命周期到底掌握了多少。
- 在 IntelliJ IDEA 中逐关运行验证。
- 对照解析,看看自己能走到第几关。
Kotlin 协程闯关地图
10 关协程挑战
从
launch、delay,一路闯到结构化并发与生命周期。
| 关卡 | 核心挑战 |
|---|---|
| Level 01 | launch:到底是不是异步? |
| Level 02 | delay:到底会不会切线程? |
| Level 03 | join:到底在等什么? |
| Level 04 | async:并发执行,顺序 await |
| Level 05 | withContext:到底干了什么? |
| Level 06 | suspend:如何响应取消? |
| Level 07 | CPU 密集型任务:取消为何失效? |
| Level 08 | 结构化并发:谁在等待谁? |
| Level 09 | 作用域嵌套:任务如何结束? |
| Level 10 | BOSS 战:协程生命周期综合实战 |
Level 01:launch 是异步的吗?
难度: ⭐
核心考点: launch 的启动方式与 runBlocking 的等待机制
1. 看代码
kotlin
fun main() = runBlocking {
println("A")
launch {
println("B")
}
println("C")
}
2. 先猜
下面哪一种输出是正确的?
- A → B → C
- A → C → B
- B → A → C
先别运行。
3. 运行验证
css
A
C
B
4. 为什么?
这里最容易产生一个误区:
launch是异步的,所以一定会立刻跑到另一个线程?
不是。
在这个例子里,runBlocking 默认使用当前线程执行协程。launch 创建子协程后,调用本身不会等待子协程执行完成,因此当前协程可以继续向下执行,先打印 C。
随后,子协程得到执行机会,打印 B。
但程序并不会因为 runBlocking 已经执行到末尾就立刻退出。
runBlocking 会等待自己的子协程完成。
所以最终顺序是:
css
A
C
B
这里应该记住第一条:
launch不等于切线程,也不等于立即并行执行。它首先意味着:创建一个子协程,并立即返回一个Job。
Level 02:delay 到底会不会切线程?
难度: ⭐⭐
核心考点: delay 的挂起与线程阻塞
1. 看代码
kotlin
fun main() = runBlocking {
println("1: ${Thread.currentThread().name}")
launch {
println("2: ${Thread.currentThread().name}")
delay(1000)
println("3: ${Thread.currentThread().name}")
}
println("4: ${Thread.currentThread().name}")
}
2. 先猜
两个问题:
2和3打印出来的线程名称一定一样吗?delay(1000)会不会阻塞当前线程 1 秒?
3. 运行验证
通常可以看到:
makefile
1: main
4: main
2: main
3: main
4. 为什么?
delay() 最重要的特点不是"等一秒"。
而是:
等待一秒,但不阻塞线程。
调用 delay(1000) 后,当前协程会挂起。
线程并没有因此被 Thread.sleep() 一样卡住。
在这个例子里,runBlocking 使用当前线程执行,因此协程恢复后仍然可能由 main 线程继续执行。
所以:
arduino
delay ≠ Thread.sleep
两者都可以"等一段时间",但机制完全不同。
Thread.sleep():
线程停在那里
↓
什么也干不了
delay():
协程挂起
↓
线程可以继续执行其他工作
↓
时间到了
↓
协程恢复
需要注意:
挂起不代表一定换线程。
这是理解 Kotlin 协程时非常重要的一点。
Level 03:join 到底在等什么?
难度: ⭐⭐⭐
核心考点: Job.join() 的挂起等待
1. 看代码
kotlin
fun main() = runBlocking {
val job = launch {
delay(1000)
println("B")
}
println("A")
job.join()
println("C")
}
2. 先猜
输出顺序是什么?
- A → C → B
- A → B → C
3. 运行验证
css
A
B
C
4. 为什么?
关键就在这一句:
csharp
job.join()
join() 的意思可以简单理解为:
等这个 Job 执行完成。
但它不是:
bash
Thread.sleep(...)
也不是把线程堵在那里。
join() 是挂起当前协程。
于是执行流程变成:
css
打印 A
↓
等待 job
↓
当前协程挂起
↓
job 执行
↓
job 完成
↓
当前协程恢复
↓
打印 C
所以最终:
css
A
B
C
这里可以顺便建立一个很重要的认知:
协程中的"等待",很多时候等待的是任务状态,而不是等待线程。
Level 04:async 与 launch 的并发陷阱
难度: ⭐⭐⭐
核心考点: async 的启动时机与 await() 的挂起
我们来比较两段代码。
1. 代码段一
kotlin
fun main() = runBlocking {
val time = measureTimeMillis {
val a = async {
delay(1000)
100
}
val b = async {
delay(1000)
200
}
println("Result = ${a.await() + b.await()}")
}
println("Cost: $time ms")
}
2. 代码段二
kotlin
fun main() = runBlocking {
val time = measureTimeMillis {
val a = async {
delay(1000)
100
}
val resultA = a.await()
val b = async {
delay(1000)
200
}
val resultB = b.await()
println("Result = ${resultA + resultB}")
}
println("Cost: $time ms")
}
3. 先猜
两段代码最终都是:
ini
Result = 300
但耗时一样吗?
- 代码段一:约 1000ms
- 代码段二:约 2000ms
4. 运行验证
实际运行时会存在调度、机器负载等误差,但大致可以观察到:
代码段一:≈ 1000ms
代码段二:≈ 2000ms
5. 为什么?
第一段:
ini
val a = async { ... }
val b = async { ... }
创建 a 后,代码继续创建 b。
因此两个任务已经同时开始执行。
可以理解成:
css
a ──────────────── 1000ms
b ──────────────── 1000ms
最后:
csharp
a.await()
b.await()
只是获取结果。
所以整体大约需要:
1000ms
第二段就不一样了:
ini
val a = async { ... }
val resultA = a.await()
这里已经开始等待 a。
只有 a 完成以后,代码才会继续创建 b。
于是变成:
css
a ───────── 1000ms
↓
b ───────── 1000ms
最终约:
2000ms
所以:
async负责启动并发任务,await()负责等待并获取结果。
而把 async 和 await() 紧紧挨在一起,很容易把并发写成串行。
Level 05:withContext 到底干了什么?
难度: ⭐⭐⭐⭐
核心考点: CoroutineContext 切换与线程调度
1. 看代码
kotlin
fun main() = runBlocking {
println("A: ${Thread.currentThread().name}")
val result = withContext(Dispatchers.Default) {
println("B: ${Thread.currentThread().name}")
delay(1000)
println("C: ${Thread.currentThread().name}")
100
}
println("D: $result, Thread: ${Thread.currentThread().name}")
}
2. 先猜
两个问题:
withContext会不会创建一个新的协程?D打印时的线程名称是否和A一致?
3. 运行验证
通常可以看到类似:
yaml
A: main
B: DefaultDispatcher-worker-1
C: DefaultDispatcher-worker-1
D: 100, Thread: main
4. 为什么?
这里不要简单把 withContext 理解成:
"开启一个新协程。"
更准确地说:
withContext挂起当前协程,并在新的 CoroutineContext 中执行代码块,代码块完成后再恢复当前协程。
这里:
javascript
withContext(Dispatchers.Default) {
...
}
意味着进入 Dispatchers.Default 上下文。
因此 B、C 通常会在 Default dispatcher 的线程上执行。
代码块结束后:
scss
withContext(...)
返回 100,当前协程继续恢复到原来的上下文,所以 D 又回到了 main。
可以把它理解成:
scss
main
↓
withContext(Default)
↓
Default worker
↓
代码执行完成
↓
恢复原来的上下文
↓
main
所以:
withContext的核心不是"切线程",而是"切换 CoroutineContext"。线程变化只是调度器变化可能带来的结果。
Level 06:suspend 如何响应取消?
难度: ⭐⭐⭐⭐
核心考点: 协作式取消
这一关开始,真正进入协程生命周期。
1. 看代码
scss
fun main() = runBlocking {
val job = launch {
repeat(10) {
println("working $it")
delay(500)
}
}
delay(1200)
println("cancel")
job.cancel()
println("cancel done")
}
2. 先猜
调用:
scss
job.cancel()
之后,子协程还能继续打印吗?
一共会打印几个 working?
3. 运行验证
通常可以看到:
bash
working 0
working 1
working 2
cancel
cancel done
4. 为什么?
时间轴大致是:
ini
T = 0ms
working 0
↓
delay(500)
T = 500ms
working 1
↓
delay(500)
T = 1000ms
working 2
↓
delay(500)
T = 1200ms
cancel
job.cancel()
此时子协程正处于:
scss
delay(500)
而 delay() 是支持取消的挂起函数。
因此取消发生后,delay() 会响应取消,子协程结束。
所以不会再出现:
working 3
这里真正应该记住的是:
取消不是强制杀死协程,而是协程与取消机制之间的一次协作。
Level 07:CPU 密集型任务,取消为什么失效?
难度: ⭐⭐⭐⭐⭐⭐
核心考点: 非挂起代码与协作式取消
1. 看代码
把上一关的 delay() 换成:
bash
Thread.sleep()
scss
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
repeat(5) {
println("working $it")
Thread.sleep(500)
}
}
delay(700)
println("cancel")
job.cancel()
println("cancel done")
}
2. 先猜
调用:
scss
job.cancel()
之后:
- 子协程会立刻停止吗?
- 最终会打印几个
working?
3. 运行验证
你可能看到类似:
bash
working 0
working 1
cancel
cancel done
working 2
working 3
working 4
4. 为什么?
这里恰恰暴露出了协程取消最容易被误解的地方:
cancel()不会强行终止正在执行的普通代码。
Thread.sleep(500) 是一个阻塞调用。
协程在执行:
bash
Thread.sleep(500)
的时候,并没有挂起,也没有主动检查取消状态。
所以即使其他地方调用:
scss
job.cancel()
当前这段代码依然可能继续执行。
这就是:
协作式取消。
如果是 CPU 密集型循环,则通常需要主动检查:
arduino
while (isActive) {
// CPU work
}
或者在合适的位置调用:
scss
yield()
让协程有机会检查取消状态。
因此:
scss
cancel()
↓
发送取消信号
↓
协程是否停止?
↓
取决于代码是否能够响应取消
这也是为什么:
suspend 函数不是"自动可取消",而是具体的挂起函数需要支持取消;普通同步代码则不会因为调用了 cancel() 就被强行打断。
Level 08:结构化并发,谁在等待谁?
难度: ⭐⭐⭐⭐⭐⭐
核心考点: coroutineScope 的生命周期与等待规则
1. 看代码
scss
fun main() = runBlocking {
coroutineScope {
launch {
delay(1000)
println("A")
}
launch {
delay(2000)
println("B")
}
println("C")
}
println("D")
}
2. 先猜
D 会什么时候打印?
- C 之后、A 之前
- A 之后、B 之前
- B 之后
3. 运行验证
css
C
A
B
D
4. 为什么?
进入:
markdown
coroutineScope {
...
}
之后创建了两个子协程:
css
coroutineScope
├── launch → A
└── launch → B
当前协程可以先执行:
go
println("C")
但 coroutineScope 不会在子协程还没完成时就返回。
因此:
css
C
↓
等待 A
↓
A
↓
等待 B
↓
B
↓
coroutineScope 完成
↓
D
所以:
css
C
A
B
D
这里就是结构化并发最核心的思想之一:
作用域不会在自己的子任务还没结束时就悄悄消失。
Level 09:作用域嵌套,任务如何结束?
难度: ⭐⭐⭐⭐⭐⭐⭐
核心考点: 嵌套作用域与执行流
1. 看代码
scss
fun main() = runBlocking {
launch {
delay(1000)
println("A")
}
coroutineScope {
launch {
delay(2000)
println("B")
}
}
println("C")
}
2. 先猜
输出顺序是什么?
- A → B → C
- B → A → C
- A → C → B
3. 运行验证
css
A
B
C
4. 为什么?
这里有两个不同层级的任务。
第一个:
scss
launch {
delay(1000)
println("A")
}
已经被创建出来,可以和后面的 coroutineScope 并行推进。
然后当前协程进入:
scss
coroutineScope {
launch {
delay(2000)
println("B")
}
}
问题来了。
coroutineScope 会等待自己的子协程完成。
所以当前协程无法继续执行:
go
println("C")
直到内部的 B 完成。
时间轴:
ini
T = 0
A 开始
B 开始
T = 1000
A
T = 2000
B
coroutineScope 完成
C
最终:
css
A
B
C
这一关真正要理解的不是输出顺序,而是:
作用域不仅决定"任务属于谁",还决定"当前执行流什么时候可以继续"。
Level 10:BOSS 战------协程生命周期综合实战
难度: ⭐⭐⭐⭐⭐⭐⭐⭐
核心考点: supervisorScope、手动取消、finally 与生命周期
1. 看代码
scss
fun main() = runBlocking {
supervisorScope {
val job1 = launch {
repeat(5) { i ->
println("Job 1: $i")
delay(200)
}
}
val job2 = launch {
try {
repeat(5) { i ->
println("Job 2: $i")
delay(200)
}
} finally {
println("Job 2 finally")
}
}
delay(300)
println("Cancelling Job 2...")
job2.cancel()
}
println("END")
}
2. BOSS 战
一次回答下面 5 个问题:
Job 1会被Job 2的取消影响吗?Job 2会打印几次?Job 2的finally会执行吗?Job 1最终会打印到哪个i?END什么时候打印?
3. 运行验证
通常可以看到类似:
yaml
Job 1: 0
Job 2: 0
Job 1: 1
Job 2: 1
Cancelling Job 2...
Job 2 finally
Job 1: 2
Job 1: 3
Job 1: 4
END
具体打印时序在边界时间点可能受到调度影响,但生命周期关系不会改变。
4. 为什么?
第一问:Job 1 会被取消吗?
不会。
这里是:
scss
job2.cancel()
取消的是 job2 自己。
并不是取消整个:
supervisorScope
所以:
Job 1 ───────────────→ 正常完成
Job 2 ─────→ cancel → 结束
第二问:Job 2 打印几次?
正常情况下:
yaml
Job 2: 0
Job 2: 1
在 300ms 左右执行:
scss
job2.cancel()
此时 job2 正处于下一次 delay() 或即将进入下一轮的过程中,因此取消后不会继续打印 2。
第三问:finally 会执行吗?
会。
csharp
try {
...
} finally {
println("Job 2 finally")
}
取消导致协程结束时,正常的结构化清理代码仍然会进入 finally。
所以:
csharp
Job 2 finally
会被打印。
这也是为什么资源清理代码经常放在:
csharp
finally
中。
第四问:Job 1 会执行到哪里?
Job 1 没有被取消。
所以它会继续:
yaml
Job 1: 0
Job 1: 1
Job 1: 2
Job 1: 3
Job 1: 4
最终正常结束。
第五问:END 什么时候打印?
注意:
markdown
supervisorScope {
...
}
同样是结构化作用域。
它不会因为 job2 被取消,就立刻结束。
它仍然需要等待自己的子协程完成。
因此:
vbnet
Job 2 被取消
↓
Job 2 finally
↓
Job 1 继续执行
↓
Job 1 完成
↓
supervisorScope 完成
↓
END
最终:
sql
END
才会出现。
通关总结
走完这 10 关,实际上可以把 Kotlin 协程浓缩成三件事情。
1. 协程不是线程
协程是运行在线程之上的执行单元。
协程
↓
挂起 / 恢复
↓
调度器
↓
线程
所以:
scss
delay()
不代表切线程。
scss
withContext(Dispatchers.Default)
真正发生的是:
CoroutineContext 发生了变化,调度器可能因此选择不同的线程执行。
2. 取消是协作式的
调用:
scss
job.cancel()
本质上是:
告诉协程:你应该结束了。
但协程是否能够及时结束,取决于它是否能够响应取消。
挂起函数:
scss
delay()
通常可以响应取消。
而这种代码:
scss
while (true) {
doHeavyWork()
}
如果没有检查:
isActive
或者主动:
scss
yield()
那么即使调用:
scss
cancel()
也可能继续执行。
所以:
取消不是 kill,而是一种协作。
3. 结构化并发管理的是生命周期
这是最后也是最重要的一层。
launch 创建任务。
join 等待任务。
coroutineScope 管理子任务。
而这些机制最终都在解决一个问题:
这个任务到底属于谁?什么时候结束?谁负责等待它?
把整个闯关地图串起来:
bash
launch
↓
创建任务
delay / suspend
↓
挂起与恢复
join / await
↓
等待任务
withContext
↓
切换执行上下文
cancel
↓
协作式结束
coroutineScope
↓
管理子任务生命周期
supervisorScope
↓
隔离子任务失败
最终
↓
结构化并发
↓
让任务的生命周期有边界