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