很多 Kotlin 开发者都会先记住协程“好用”,却说不清它到底为什么成立:明明代码看起来是顺序执行,为什么遇到耗时任务时线程却不会被堵住?而一个被 suspend 修饰的函数,挂起之后又是如何准确回到原来的位置继续往下跑的?这篇文章就从这些最容易让人心里发虚的问题出发,先看协程解决了什么,再结合反编译代码拆开它背后的状态机逻辑,最后把挂起与恢复流程压缩成一条能直接带走的理解路径。
为什么 Kotlin 需要协程
在 Android 开发里,Kotlin 已经是官方第一语言。它提供了不少语法糖和高阶函数,但真正让大量业务代码写法发生变化的,往往还是协程。

协程最直观的价值,不是“更高级”,而是它把原本难读、难维护的异步回调,改写成了接近同步代码的形式。开发者照着业务顺序往下写,编译器再去处理背后的挂起、恢复和回调衔接。
没有 suspend 时:回调层层嵌套
如果不使用 suspend,一段连续的异步流程通常会写成这样:
fun fetchData(callback: (Result)-> Unit){
fetchUser{ user->
fetchProfile(user){ profile ->
saveToDatabase(profile){ result->
callback(result) //嵌套越来越深
}
}
}
}
问题很明显:代码不是按业务顺序展开,而是被迫按回调层级展开。流程一长,就容易出现所谓的“回调地狱”,阅读、调试和异常处理都会变得别扭。
有 suspend 时:异步流程写成同步风格
换成挂起函数后,同样的逻辑可以写成更接近自然思路的样子:
suspend fun fetchData(): Result{
val user = fetchUser() //这里实际是异步的
val profile = fetchProfile(user)
return saveToDatabase(profile)
}
代码从上到下就是业务执行顺序,但这里真正值得追问的是三件事:
- 协程怎么做到执行耗时任务,却又不阻塞线程?
- 被挂起的函数,之后怎么恢复?
- 恢复之后,又为什么能自动接着执行后面的挂起函数?
要回答这几个问题,就不能只停留在“协程很好用”这个层面,而要看到 suspend 经过编译之后到底变成了什么。
从一个简单例子,看协程如何被编译
先看一个很小的协程例子:在协程内部连续执行两个耗时的挂起函数。
package com.ssz.kotlindemo.grammar
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.ssz.kotlindemo.R
import kotlinx.coroutines.*
class CoroutineActivity : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_coroutine);
CoroutineScope(Dispatchers.IO).launch {
Log.d("sszLog", "开始")
delay(2000) //耗时任务
delay(1000) //耗时任务
Log.d("sszLog:", "结束")
}
}
}
这段 Kotlin 代码本身并不复杂,但理解协程原理的关键,不在源码表面,而在编译结果。
在 Android Studio 里,可以通过下面的路径查看 Kotlin 生成的字节码:
Tools -> Kotlin -> Show Kotlin Bytecode
字节码不太适合直接阅读,一般还会进一步点击 Decompile 反编译成 Java,再观察编译器到底生成了什么结构。反编译后能看到类似下面的代码:
package com.ssz.kotlindemo.grammar;
import android.os.Bundle;
....忽略...
public final class CoroutineActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(1300025);
BuildersKt.launch$default(CoroutineScopeKt.CoroutineScope((CoroutineContext)Dispatchers.getIO()), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
label17: {
Object var2 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("sszLog", "开始");
this.label = 1;
if (DelayKt.delay(2000L, this) == var2) {
return var2;
}
break;
case 1:
ResultKt.throwOnFailure($result);//主要是用于检查上一个挂起函数是否正常执行
break;
case 2:
ResultKt.throwOnFailure($result);
break label17;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
this.label = 2;
if (DelayKt.delay(1000L, this) == var2) {
return var2;
}
}
Log.d("sszLog:", "结束");
return Unit.INSTANCE;
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new (completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return (()this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 3, (Object)null);
}
}
即使不逐行深究,也能先抓到几个最重要的信号:
- 协程代码被改写成了一个带
label的结构。 - 核心执行入口不再只是普通函数调用,而是围绕
invoke、create、invokeSuspend展开。 - 每遇到一个挂起点,都会配合
label保存当前位置。 delay(2000L, this)这样的调用,把当前对象this传了进去,而这个this正是后续恢复执行的关键。
协程背后的三个关键点:挂起、恢复、继续执行
把上面反编译后的代码拆开看,协程运行的核心其实可以归结为三个问题,也对应三个关键机制。

入口是怎样跑起来的
从调用链上看,协程不是直接“神奇地执行”,而是被框架按既定入口启动:
协程框架 → invoke() → create() → 新状态机实例 → invokeSuspend()
调用栈可以看到这条链路:
at BuildersKt.launch$default(BuildersKt.kt:-1) // 生成的桥接方法
at BuildersKt.launch(Builders.kt:60) // 实际的launch方法
at AbstractCoroutine.start(AbstractCoroutine.kt:112) // 启动协程
at CoroutineStart.invoke(CoroutineStart.kt:145) // 启动模式
这里最值得注意的是两步:
BuildersKt.launch 会通过协程,CoroutineStart 去调用 invoke(Object var1, Object var2) ,
然后执行 (()this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
当走到这里,有两个关键点:
1、通过 create(var1, (Continuation) var2) 创建状态机实例. (这个在后面很有用)
2、调用 invokeSuspend
也就是说,协程体在编译后,已经不再是“普通代码块”,而是被包装进了一个状态机对象里。后续每次继续执行,本质上都是再次调这个状态机。
为什么不会阻塞线程
先看最关键的一段:
this.label = 1;
if (DelayKt.delay(2000L, this) == var2) {
return var2;
}
这里的 var2 实际上就是 COROUTINE_SUSPENDED。意思是:如果当前调用的是一个挂起函数,并且它需要先挂起当前协程,那么就立即返回这个标记值。
if (DelayKt.delay(2000L, this) == var2) {//这里var2其实是COROUTINE_SUSPENDED
return var2;
}
这一步非常关键。它不是把线程堵在那里死等,而是直接把当前协程让出去。线程因此可以被释放出来,继续处理别的任务,这就是协程“看起来顺序执行,但不会阻塞线程”的关键原因。
准确地说,挂起的是协程,不是线程。线程只是执行到这里发现需要等待,于是先退出当前协程的后续流程,等条件满足后再回来继续。
挂起之后为什么还能恢复
再看同一行调用里的另一个重点:
DelayKt.delay(2000L, this)
这里把 this 传给了 delay。而这个 this,不是普通业务对象,而是当前的状态机实例。它本身承担了“我执行到哪了、之后该从哪接着跑”的职责。
当 delay 这个挂起函数执行结束后,就会通过这个状态机对象触发恢复逻辑,也就是调用 resumeWith,然后重新回到 invokeSuspend。
所以,协程之所以能恢复,不是因为它“自动记住了上下文”这么抽象,而是因为编译器已经把现场保存进了状态机里,并把这个状态机作为 Continuation 传给了挂起函数。
恢复后为什么会自动执行后面的代码
恢复之后能接着往下跑,关键靠的是 label。
在第一次调用挂起函数之前,代码已经先做了这一步:
this.label = 1;
它相当于先把“下次回来要去哪里”记下来。等 delay 完成后,再次进入 invokeSuspend,就会根据当前的 label 值进入对应分支:
label = 0:第一次进入,执行开头逻辑。label = 1:说明第一个delay(2000)已经完成,接着往后执行。label = 2:说明第二个delay(1000)也完成了,进入收尾逻辑。
这样一来,每一个挂起点都像是在状态机里打了一个坐标。恢复时并不是从头执行,而是准确跳回上一次记录的位置,继续执行后面的代码。
把原文里的三个问题重新收束一下,就是:
1、协程怎么做到,执行耗时任务,却又不阻塞线程?
delay(2000L, this) == var2 如果发现是挂起函数,会直接 return var2,
这也就是所谓的释放当前的线程,这也就是不会阻塞线程的关键。
2、被挂起的函数,又怎么重新恢复?
DelayKt.delay(2000L, this) 会把状态机,传递给耗时任务,
执行完再次回调invokeSuspend,回到原来挂起的地方。
3、恢复后,怎么自动去执行后面的挂起函数?
就是主要是通过label 这个状态,通过每执行完一次挂起函数,状态就递增,
当 invokeSuspend 再次被调用,就可以执行下一个挂起函数。
把协程原理压缩成一条可记住的流程
如果前面的反编译代码还是显得有点重,可以把它再压缩一层,只看“怎么挂起”和“怎么恢复”。

怎么挂起
怎么挂起的呢:
状态机{ //实现了 Continuation
invokeSuspend(){
case 0:
this.label = 1 //设置下一个状态
if(delay(2000L, this) == COROUTINE_SUSPENDED){ //传递this(也就是 Continuation)
return COROUTINE_SUSPENDED; //挂起 (有耗时的任务,先不去理会它,这是挂起)
}
case 1:
...
}
resumeWith(){//这个是Continuation 中的方法,因为状态机实际上是实现了 Continuation,自然就有这个方法了
}
}
这个简化版已经足够说明问题:协程并不是“停在原地等”,而是先记录状态,再把控制权交出去。
怎么恢复
怎么恢复的呢:
//恢复时
delay(long milliseconds, Continuation continuation){//执行完耗时,就会调用resumeWith
continuation.resumeWith(Result.success(Unit))
}
resumeWith(){
//this其实就是状态机,也就是进行了回调,这就是为什么,挂起为什么能够恢复的关键。
this.invokeSuspend()//此时label = 1,就会去执行下一个挂起函数
}
恢复的本质也就一句话:耗时任务完成后,通过传进去的 Continuation 回调状态机,状态机再根据当前 label 继续往下执行。
把状态机理解成 Continuation,会更容易看懂
如果还想再往前走一步,可以把编译器生成的结构,近似理解成一个实现了 Continuation 的类:
// 我们的状态机类可以这么去理解,其实是实现了Continuation接口
public class CoroutineStateMachine implements Continuation {
int label;
public void resumeWith(Result result) { //因为实现 Continuation,也就有了这个方法
Object outcome = this.invokeSuspend(result);
// ...
}
// 状态机逻辑
public Object invokeSuspend(Object result) {
switch(this.label) {
// 状态逻辑...
}
}
}
这样理解之后,很多行为都会变得顺理成章:
- 为什么会有
resumeWith:因为状态机本身实现了Continuation。 - 为什么恢复时会重新进
invokeSuspend:因为恢复本质上就是再次调度这个状态机。 - 为什么能从上次的位置继续:因为
label已经提前记录了状态。
所以,从实现上说,协程并不是凭空消灭了回调,而是把回调、状态保存、恢复入口这些繁琐工作交给编译器和协程框架去处理。开发者看到的是顺序代码,底层做的仍然是状态机加回调这一套机制。
理解 Kotlin 协程时,最该记住什么
如果只保留最核心的结论,可以记住下面几条:
suspend不是简单的语法标记,它会触发编译器把协程代码改写成状态机。- 协程不会阻塞线程,关键在于遇到挂起点时返回
COROUTINE_SUSPENDED,把当前线程让出来。 - 挂起之所以能恢复,是因为状态机作为
Continuation被传给了挂起函数,任务完成后会回调resumeWith。 - 恢复后能继续往下执行,是因为
label记录了当前状态,再次进入invokeSuspend时会跳到对应分支。
换句话说,协程让你写出“像同步一样的异步代码”,靠的并不是运行时魔法,而是编译器生成的状态机、挂起标记以及恢复回调共同配合。把这条主线想明白之后,再去看 launch、async、withContext 这些更常用的协程 API,心里会稳很多。







