先用起来,再理解,关于协程Coroutine应该知道的事

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")
            }
        }
    }
}

在状态机中,有 01 两种状态,初次进入 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:跟随组件生命周期,旋转屏幕会cancel
  • viewModelScope:跟随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()
相关推荐
Java爱好狂.5 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
kernelcraft5 小时前
cuongpmyoutube-dl-android:多平台视频下载的Android客户端
android·其他
tongluowan0076 小时前
以ReentrantLock为例解释AQS的工作流程
java·模板方法模式·aqs·reentrantlock
佚泽6 小时前
Android Studio 如何配置gradle
android·ide·android studio
身如柳絮随风扬7 小时前
Java 项目打包与部署完全指南:JAR vs WAR,从构建到运行
java·firefox·jar
云烟成雨TD7 小时前
Spring AI Alibaba 1.x 系列【62】时光旅行(Time-Travel)
java·人工智能·spring
浩少7028 小时前
【无标题】
java·开发语言
一棵白菜8 小时前
java 学习
java
卷毛的技术笔记8 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
java·人工智能·redis·后端·spring·ai·系统架构