It's not hard. It's just new.

2026 年了,为什么还在研究协程
协程是 Kotlin 中非常重要的一个功能,我从2017年开始使用 Kotlin,当时才刚告别 AsyncTask 没多久,初见协程简直像是看到外星语言一样。断断续续用了这么多年,已经非常熟悉协程用法,也了解过其中的一些原理,却一直没有系统梳理过。虽然当下大部分代码已经可以通过 AI 完成,但仍然避免不了人工的介入和审核,程序员可以不写代码,但一定要看得懂 AI 写的代码,并且识别其中可能存在的风险。
因此,想在这篇文章里,简单记录下这一周多以来,对协程的一点简单理解,也算做个小结。
"丑陋"的回调
协程(Coroutine)不是Kotlin独有的东西,它是一种设计思想,一种编程理念。既然要聊协程,就不得不从讲讲 异步编程。在程序设计的世界里,并不是所有任务都是瞬时完成的,有些任务需要依赖CPU运算,还有些任务需要网络、磁盘IO。因此,在一个任务A被创建并执行时,也许要等待一段时间,才能继续下一步。在这个等待的时间里,它可以把CPU让出来,供任务B使用。待任务A执行完成后,B再把CPU交还给A,处理A的计算结果。
在传统的写法里,启动任务时,会传入一个回调(callback),供任务完成后执行,这就是 异步编程。
java
public void callRemoteService() {
apiService.doRequest(new Callback() {
@Override
void onSuccess(Object obj) {
// ... 请求成功回调
}
}
}
回调式的写法语义明确,在简单的业务中使用无可厚非,但在复杂业务场景下,它有两个不可避免的缺陷。
缺陷一:回调地狱
一旦涉及多层调用,导致回调嵌套回调,就会产生极不利于阅读的代码,所谓"回调地狱(callback hell)",人眼根本识别不出来,某次callback是哪一行触发的。

缺陷二:并发写法复杂
当同时触发两个异步请求,并且必须等待它们全都返回结果进行操作时,对于异步写法,通常是使用 CountdownLatch 来完成,写法复杂。
java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class CountDownLatchDemo {
interface Callback<T> {
void onSuccess(T result);
void onError(Throwable throwable);
}
// 模拟异步任务1
static void asyncTask1(Callback<String> callback) {
new Thread(() -> {
try {
Thread.sleep(1000);
callback.onSuccess("Task1 Result");
} catch (Exception e) {
callback.onError(e);
}
}).start();
}
// 模拟异步任务2
static void asyncTask2(Callback<Integer> callback) {
new Thread(() -> {
try {
Thread.sleep(1500);
callback.onSuccess(200);
} catch (Exception e) {
callback.onError(e);
}
}).start();
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(2);
// 用于保存异步结果
final String[] result1 = new String[1];
final Integer[] result2 = new Integer[1];
// 用于保存异常
final Throwable[] error = new Throwable[1];
asyncTask1(new Callback<String>() {
@Override
public void onSuccess(String result) {
result1[0] = result;
latch.countDown();
}
@Override
public void onError(Throwable throwable) {
error[0] = throwable;
latch.countDown();
}
});
asyncTask2(new Callback<Integer>() {
@Override
public void onSuccess(Integer result) {
result2[0] = result;
latch.countDown();
}
@Override
public void onError(Throwable throwable) {
error[0] = throwable;
latch.countDown();
}
});
// 等待两个异步任务完成
boolean completed = latch.await(5, TimeUnit.SECONDS);
if (!completed) {
System.out.println("任务超时");
return;
}
if (error[0] != null) {
System.out.println("任务失败: " + error[0].getMessage());
return;
}
// 汇总结果
System.out.println("两个任务都完成");
System.out.println("result1 = " + result1[0]);
System.out.println("result2 = " + result2[0]);
}
}
在这个代码里,存在大量 胶水代码,它们不是业务逻辑,而是为了把异步callback拼装成同步结果,所产生的控制流代码。
在这样的背景下,诞生了多种 现代异步框架,例如 Future、Promise、Coroutine、Flow 等。
"优雅"的协程
同样的逻辑,用协程来实现,代码只有10行左右。
kotlin
suspend fun task1(): String
suspend fun task2(): Int
coroutineScope {
val r1 = async { task1() }
val r2 = async { task2() }
println(r1.await())
println(r2.await())
}
协程优点一:高性能
在"古法编程"的方式中,通过创建和切换线程切换,来完成异步任务,这会带来巨大的性能开销。产生这种现象的根本原因在于,线程是 OS 内核调度单位 。可以理解为,"线程"是系统内核提供的运行单元,当OS进行线程调度时,将产生大量昂贵的开销,包括且不限于:保存CPU现场,切换内核态(用户态->内核态->用户态),切换线程栈,CPU Cache/TLB 失效等。
而协程则是设计在用户态的轻量级调度系统,将调度权从 OS内核 移交到了 语言运行时。它通常不涉及OS层面线程抢占式的调度,而是进行 函数暂停 + 保存局部变量 + 封装下一步操作,待异步调用结果返回后,从中断的地方恢复执行。
因此,协程调度可以避免线程调度所产生的昂贵开销,在操作系统中,同时运行10万条协程的场景是允许发生的,而如果同时运行10万条线程,这是不可能的事情。
协程优点二:更简洁
协程的第二个优点,是它的写法更加简洁,从前文与 CountDownLatch 的对比就可以看出。在下文中,也将介绍协程的几种常见写法,可用于不同的场景中。
Kotlin 协程是如何实现的?
前文中提到,"协程"本质是一种思想,是为了规避OS内核进行线程管理带来的昂贵开销。在不同的程序运行时下,协程有着不同的实现。那么,在我们最先接触到协程的Kotlin编程语言中,它是如何通过JVM实现的?
协程本质是"状态机"
协程是运行在线程之上的异步框架 ,在Kotlin中,它是通过 状态机 来实现的,通过将函数拆成多个状态,对每一个挂起点,都会产生一个状态。当代码中有n个挂起点时,意味着共有(n+1)种状态。
Kotlin 代码:
kotlin
suspend fun test() {
delay(1000)
println("hello")
}
自动生成的状态机:
kotlin
class TestCoroutine : Continuation<Unit> {
var label = 0
fun invokeSuspend() {
when(label) {
0 -> {
label = 1
delay(this)
return
}
1 -> {
println("hello")
}
}
}
}
在状态机中,有 0、1 两种状态,初次进入 invokeSuspend(),变量 label=0,进入分支:
kotlin
0 -> {
label = 1
delay(this)
return
}
此时协程将后续操作封装成一个 Continuation,并交出线程控制权,将线程用于其它任务。当耗时任务(IO、网络、CPU等)返回后,它会执行刚才封装好的 Continuation,继续完成接下来的任务。在上述代码里,对应的就是 label=1 的分支,打印 hello。
kotlin
1 -> {
println("hello")
}
本质上是对函数进行"切块",State0 -> State1 -> State2,每次恢复都是从上次保存的状态继续
"挂起"时究竟发生了什么?
在挂起时,协程框架内部依次执行以下四个步骤:
Step1:保存现场
协程会执行快照,保存以下信息:
- 当前执行位置
- 局部变量值
- 调用栈状态
此时创建了一个"续体" Continuation。
Step2:让出线程
协程挂起后,当前线程回到"空闲"状态,可以执行其它任务。
Step3:等待恢复
kotlin
delay(1000)
例如上述代码,delay挂起后,内部会注册一个1s的定时器,以回调 resume()。
kotlin
continuation.resume(Unit)
Step4:恢复执行
恢复后,状态机从 label=1 处继续执行。
协程本质是对"回调地狱"的编译器封装,究其根本仍然是异步回调,只不过是由编译器承担了管理状态机的职责。
Kotlin 中启动协程的几种方式
先说结论,从 有无返回值、是否阻塞线程、子作用域结构化、失败传递机制、上下文切换 方面,将最核心的协程启动方式整理如下:
| API | 核心用途 |
|---|---|
| launch | 启动无返回值协程 |
| async | 启动有返回值协程 |
| runBlocking | 阻塞线程桥接 |
| coroutineScope | 创建结构化子作用域 |
| supervisorScope | 隔离失败传播 |
| withContext | 切换上下文 |
| flow/channelFlow | 启动流式协程 |
针对最常用的几种方式,说明如下。
最常见:launch
kotlin
suspend fun startLoadData() {
viewModelScope.launch {
loadData()
}
}
特点
| 特征 | 说明 |
|---|---|
| 返回值 | Job |
| 是否有结果 | 无 |
| 是否阻塞线程 | 否 |
| 是否可取消 | 是 |
| 是否结构化 | 是 |
用途
适合:
- UI事件
- 后台任务
- fire-and-forget
- 页面逻辑
取返回值:async
结构化并发,代替 CountDownLatch。
kotlin
coroutineScope {
val a = async {
requestA()
}
val b = async {
requestB()
}
println(a.await() + b.await())
}
特点
| 特征 | 说明 |
|---|---|
| 返回值 | Deferred |
| 可 await | 是 |
| 支持并发 | 是 |
谨慎使用:runBlocking
阻塞当前线程,直到协程结束,本质是在内部创建 BlockingCoroutine。
不要在 UI 线程使用,会导致 ANR。
kotlin
runBlocking {
// 阻塞当前线程直到执行结束
}
用途
| 场景 | 用途 |
|---|---|
| main函数 | 桥接 |
| 单元测试 | 常用 |
| demo | 常用 |
创建结构化子作用域:coroutineScope
只有当子协程全都返回时,外部协程才结束。
kotlin
coroutineScope {
launch { a() }
launch { b() }
}
特征
| 特征 | 说明 |
|---|---|
| 是 suspend | 是 |
| 不阻塞线程 | 是 |
| 等待子协程完成 | 是 |
容错作用域:supervisorScope
在普通的 coroutineScope 中,一个子协程失败,会导致全部兄弟、祖先协程取消。在 supervisorScope 里,失败子协程的异常不会向外扩散。
切换协程上下文:withContext
不是新协程,而是为当前协程切换上下文执行。
kotlin
withContext(Dispatchers.IO) {
}
特点
| 特征 | 说明 |
|---|---|
| 是否创建Job | 通常否 |
| 是否切线程 | 可能 |
| 是否返回值 | 有 |
| 是否挂起 | 是 |
UI作用域:MainScope
kotlin
val scope = MainScope()
scope.launch {
}
快捷创建UI作用域:Dispatchers.Main + SupervisorJob()
不推荐:GlobalScope
| 特征 | 说明 |
|---|---|
| 无生命周期管理 | 是 |
| 不结构化 | 是 |
| 易泄漏 | 是 |
CoroutineScope 是什么
可以理解为 "协程的生命周期管理容器",它描述了"一组协程的运行上下文+生命周期边界"。
CoroutineScope 的源码非常简单:
kotlin
interface CoroutineScope {
val coroutineContext: CoroutineContext
}
它本质上只是一个 Context 容器。
| Context元素 | 作用 |
|---|---|
| Job | 生命周期 |
| Dispatcher | 线程调度 |
| CoroutineName | 名字 |
| ExceptionHandler | 异常处理 |
真正的结构化并发核心:Job树
kotlin
val scope = CoroutineScope(
Dispatchers.Main + Job()
)
上述代码里,Job 是整个协程树的根节点,在对此 scope 进行调用,例如:scope.launch { ... } 时,本质上是创建子协程,并将子协程挂到scope的Job下面,形成如下结构:
sql
Scope Job
├── coroutine1
├── coroutine2
└── coroutine3
从而达到 当scope.cancel()时能够递归取消所有子协程 的效果。
launch 真正干了什么
launch 内部核心是:
kotlin
newCoroutineContext(context)
即从scope中取出coroutineContext,然后执行如下操作:
- 合并新context
- 创建Job
- 创建Continuation
- 创建协程状态机
scope更偏语义概念,context才真正容纳了作用域里的实际信息。
Android尤其依赖Scope
对 Android 系统而言,其生命周期管理非常复杂,这就要求协程必须跟随宿主的生命周期,否则当Activity销毁时,如果协程没有同步取消,极易发生内存泄漏。
Android中启动协程的标准工程写法
最标注的创建协程写法方式如下,它等价于官方提供的快捷写法 MainScope()。
kotlin
val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob()
)
如果要创建子scope,则应当使用 coroutineScope,这会挂起当前协程,并等待所有子协程完成:
kotlin
suspend fun test() {
coroutineScope {
}
}
例如下文代码,会等待两个 launch 都结束:
kotlin
coroutineScope {
launch{}
launch{}
}
如果希望子协程失败不互相影响,则应该用 supervisorScope 替代 coroutineScope。
Android 中的推荐写法
Android SDK 中有一些官方的协程作用域:
lifecycleScope:跟随组件生命周期,旋转屏幕会cancelviewModelScope:跟随ViewModel,页面旋转不销毁,onCleared时自动cancel
在工程中,根据代码分层,应当使用不同的 scope:
ViewModel
kotlin
viewModelScope.launch {
//
}
Repository
自己不持有scope,而是:
kotlin
withContext(Dispatchers.IO) {
//
}
长生命周期组件
kotlin
private val scope =
CoroutineScope(
SupervisorJob() + Dispatchers.Main
)
并且及时取消:
kotlin
scope.cancel()