Kotlin 协程闯关:看代码,猜结果

前言

这不是一篇 API 速查表。

接下来 10 关,每一关都先别急着运行。

先凭直觉判断,再打开 IntelliJ IDEA 验证,最后回头看协程到底是怎么运行的。

闯关规则

  1. 不要直接复制代码运行,先预测结果。
  2. 尽量不要查 API,看看自己对协程调度、挂起和生命周期到底掌握了多少。
  3. 在 IntelliJ IDEA 中逐关运行验证。
  4. 对照解析,看看自己能走到第几关。

Kotlin 协程闯关地图

10 关协程挑战

launchdelay,一路闯到结构化并发与生命周期。

关卡 核心挑战
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. 先猜

两个问题:

  1. 23 打印出来的线程名称一定一样吗?
  2. 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() 负责等待并获取结果。

而把 asyncawait() 紧紧挨在一起,很容易把并发写成串行。


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. 先猜

两个问题:

  1. withContext 会不会创建一个新的协程?
  2. 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 上下文。

因此 BC 通常会在 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()

之后:

  1. 子协程会立刻停止吗?
  2. 最终会打印几个 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 个问题:

  1. Job 1 会被 Job 2 的取消影响吗?
  2. Job 2 会打印几次?
  3. Job 2finally 会执行吗?
  4. Job 1 最终会打印到哪个 i
  5. 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
  ↓
隔离子任务失败

最终
  ↓
结构化并发
  ↓
让任务的生命周期有边界
相关推荐
2501_932750263 小时前
Android 跑马灯:从一行 XML 到自定义控件
android
BoomHe15 小时前
Android Framework 文件应用移植到 AndroidStudio
android
传奇开心果编程16 小时前
【Jetpack Compose基础语法学与练】第8课 rememberSaveable,页面旋转/系统重建保留状态
android·学习·ui·kotlin·android jetpack
>Andre<17 小时前
UFS5.0标准中文全译·卷一:范围、术语与架构
android·linux·嵌入式硬件
事圆则缓17 小时前
Java 8 Lambda、Stream、Optional 与 Android 边界:从回调语法到运行时兼容
android·java
mmsx18 小时前
Android 地图十万要素不卡顿:空间网格 + 渐进式加载的移动端实践
android·大数据·opengl
Godikov19 小时前
Android 工业终端保活实战:前台服务 + 开机自启 + 更新自启的三重保障
android
字节暗面19 小时前
SO加固强度怎么量化?腾讯ACE与FairGuard静态分析实测
android·逆向
终端安全笔记21 小时前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机