在 Kotlin 官方的定义中,协程是一种轻量级的线程,但它不是线程。 协程可以在单个线程中挂起和恢复,支持异步、非阻塞的编程。
1、协程的本质
Kotlin 协程本质上是基于线程的封装,其核心优势在于允许开发者以线性、顺序的代码结构编写并发逻辑,从而避免了传统多线程编程中常见的回调嵌套。 简单的概括就是,协程提供了比线程更易用的并发管理方案。协程常被称为“轻量”,并非指其物理资源消耗低于线程,而是指能用更简洁的代码实现相同的并发效果。
2、线程的切换
从功能上看,协程与线程都是并发管理工具。并发管理主要涉及三件事:线程切换、线程同步、线程安全。
| 编号 | 事项 | 描述 |
|---|---|---|
| 1 | 线程切换 | 在子线程执行耗时任务,完成后切回主线程传回结果。 |
| 2 | 线程同步 | 线程间相互等待,实现流程协同。 |
| 3 | 线程安全 | 通过互斥锁等机制保护共享资源。 |
无论是从线程还是协程的角度,处理并发时都会面对这三个方面。其中线程切换是最基本的一环,在后续介绍协程时,通常会将它与线程进行对比说明。
2.1 线程的调度
传统的后台任务调度,通常会启动一个子线程:
1 | thread { |
或者启用一个线程池:
1 | val executor = Executors.newCachedThreadPool() |
对于 Android 而言,在子线程执行耗时任务,获取结果后切回主线程更新界面,可以使用 Handle 或 View 进行切换:
1 | val handler = Handler(Looper.getMainLooper()) |
2.2 启动协程
接受一个空的协程上下文,创建一个协程作用域,然后就可以用这个作用域来启动一个协程了:
1 | val scope = CoroutineScope(EmptyCoroutineContext) |
如果使用空的协程上下文创建协程作用域,则会使用默认的调度器执行任务。在 Kotlin 协程框架中,ContinuationInterceptor 是核心拦截器接口,负责在协程执行过程中拦截和修改协程的续体,主要作用是控制协程的线程调度。CoroutineDispatcher 是 ContinuationInterceptor 最主要、最常用的实现:
| 编号 | 调度器 | 自动切回线程 | 描述 |
|---|---|---|---|
| 1 | Dispatchers.Default | 是 | CPU 密集型任务调度,执行计算密集型任务。 |
| 2 | Dispatchers.Main | 是 | UI 线程调度,执行切换到住线程任务。 |
| 3 | Dispatchers.IO | 是 | IO 线程池调度,执行网络请求、磁盘读写任务。 |
| 4 | Dispatchers.Unconfined | 否 | 不绑定特定线程的调度器,挂起函数执行完后不会自动将线程切回去,而是由恢复它的线程继续执行。 |
✦ 继承链:Dispatchers.Default ▸ CoroutineDispatcher ▸ ContinuationInterceptor ▸ CoroutineContext.Element ▸ CoroutineContext
开启协程时可以在 CoroutineScope 或 launch() 中传入指定的调度器:
1 | // 1.默认使用 Default |
2.3 调度器优先级
协程会按照明确指定调度器 ▸ 作用域调度器 ▸ 父协程调度器 ▸ 系统默认调度器的优先级顺序来确定哪个调度器生效。下面通过几个例子来进行说明:
(一)最高优先级:明确指定调度器
1 | val scope = CoroutineScope(Dispatchers.Main) |
(二)次高优先级:作用域调度器
1 | val scope = CoroutineScope(Dispatchers.Main) |
(三)第三优先级:父协程调度器
1 | val scope = CoroutineScope(Dispatchers.Main) |
(四)默认回退:系统默认调度器
1 | /*** ① 全局作用域,没有指定调度器 ***/ |
2.4 切回主线程
最后,我们有必要了解下切回主线程的底层实现细节。在 Android 中,主线程其实就是一个有 MessageQueue 的 Looper 线程,所有“切回主线程”的操作都是对 Handler 的直接使用或包装后的调用,协程也不例外。Dispatchers.Main 的实际实现是 androidx.lifecycle.MainThreadCoroutineDispatcher(或旧版中的 HandlerContext),其核心是通过主线程的 Handler 发送消息:
它内部持有一个绑定到主线程
Looper的Handler。当你调用
withContext(Dispatchers.Main)时,协程框架会把后续代码封装成一个Runnable,通过该Handlerpost 到主线程执行。
相关源码位置(简化):
1 | /*** kotlinx.coroutines.android/HandlerDispather.kt/HandlerContext ***/ |
所以,真正“切回主线程”的动作是由 Handler.post() 完成的,而 Dispatchers.Main 就是对这个机制的协程封装。
3、挂起函数
Kotlin 的挂起函数(suspend function)是 Kotlin 协程(Coroutines)的核心概念之一,用于以非阻塞方式执行长时间运行的操作(如网络请求、文件读写等),同时保持代码的顺序性和可读性。
3.1 基本定义
挂起函数使用 suspend 关键字修饰:
1 | suspend fun fetchData(): String { |
✦ 注意:挂起函数只能在协程作用域内或其他挂起函数中被调用。
3.2 挂起函数的本质
在 Kotlin 协程中,挂起函数完成任务后切回原线程是由协程的上下文(Context)和调度器(Dispatcher)控制的,而不是由挂起函数本身完成的。挂起函数本身并“不具备线程调度能力”,而是通过 Kotlin 协程的底层机制(特别是 Continuation 接口)与协程调度器协作,从而支持“挂起”和“恢复”,并配合协程自动切回原线程。
挂起函数的本质是一个带有 Continuation 参数的普通函数。在编译后,每个 suspend fun 实际上会被 Kotlin 编译器转换成一个带有额外 Continuation 参数的普通函数:
1 | // 源码 |
这个 Continuation 就是协程的“回调接口”,它封装了:
当前协程的下文:
context恢复执行的方法:
resume(value)/resumeWithException(exception)
所以,挂起函数可以理解为是一个持有 “协程回调接口” 的普通函数,它通过 Continuation 与协程运行时通信。挂起函数是如何“配合协程切回线程”的呢?大致可以分为以下步骤:
调用挂起函数时,协程框架会传入一个绑定了当前协程上下文(包括 Dispatcher)的
Continuation。挂起函数执行异步操作(如网络请求、
delay、withContext等)。操作完成后,挂起函数调用
continuation.resume(result)。协程调度器(Dispatcher)收到
resume请求后,根据continuation.context中的 Dispatcher 决定在哪条线程上恢复执行。
✦ 关键点:切回哪条线程,是由 Continuation 的上下文决定的,而这个上下文来自启动协程的地方,不是挂起函数自己指定的。
3.3 流程分解
为了直观理解挂起函数的工作原理,我们通过一个示例来演示:模拟网络请求并将结果显示在文本控件上,同时逐步分解其执行流程。
1 | viewModelScope.launch(Dispatchers.Main) { // ← 协程绑定到 Main |
launch(Dispatchers.Main)启动协程,其Continuation的context包含Main调度器。调用
fetchData()时,协程传入这个Continuation。withContext(Dispatchers.IO):① 创建一个新的子协程在 IO 线程执行;② 执行完后,调用原始协程的continuation.resume()。调度器看到这个
continuation属于Dispatchers.Main,于是把恢复逻辑 post 到主线程。
3.4 生活案例
我们可以用一个生活中的例子来理解协程和挂起函数。现在尝试着将自己想象成一个线程,将要完成的耗时任务比作挂起函数。那么,我现在要完成的任务是什么呢?一、喝到烧开的热水;二、晾晒洗净的衣服。
- 喝到烧开的热水:分解为烧开热水、喝到热水。
- 烧开热水:耗时操作,挂起函数切到 IO 线程执行,热水器充当 IO 线程。
- 喝到热水:界面更新,需要切回到 Main 线程执行,自己充当 Main 线程。
- 晾晒洗净的衣服:分解为清洗衣服、晾晒衣服。
- 清洗衣服:耗时操作,挂起函数切到 IO 线程执行,洗衣机充当 IO 线程。
- 晾晒衣服:界面更新,需要切回到 Main 线程执行,自己充当 Main 线程。
有了这些前置条件,我们就可以开启协程,按照时间轴的顺序执行任务,获取结果,消费结果了。执行流程如下表所示:
| 编号 | 时间轴 | 执行 | 动作 | 耗时 | 描述 |
|---|---|---|---|---|---|
| 1 | 00:00 | 热水器 | 烧水开始 | 0分钟 | 我开启热水器执行烧水任务,并告诉它烧水结束后将结果告知我。 |
| 2 | 00:00 | 洗衣机 | 洗衣开始 | 0分钟 | 我开启洗衣机执行清洗任务,并告诉它清洗结束后将结果告知我。 |
| 3 | 10:00 | 热水器 | 烧水结束 | 10分钟 | 10 分钟后我接到烧水结束的通知,并意识到烧水的目的是喝水。 |
| 4 | 10:00 | 我自己 | 喝热水 | 10秒钟 | 此时洗衣机仍然在执行清洗任务,并不妨碍我将烧好的水喝掉。 |
| 5 | 30:00 | 洗衣机 | 洗衣结束 | 30分钟 | 30 分钟后我接到清洗结束的通知,并意识到清洗的目的是晾晒。 |
| 6 | 30:00 | 我自己 | 晾衣服 | 20秒钟 | 此时热水器、洗衣机停止工作,于是我又将清洗后的衣服晾晒。 |
表中的信息可以概括为:我同时开启了热水器、洗衣机来烧水和洗衣服,我没有被任何一个任务“绑住”傻等,而是让机器(IO操作)在后台工作,完成时通知我,我利用等待时间处理其他事。这就是协程挂起函数“不阻塞线程”的完美生活比喻。流程转换后的代码如下:
1 | /*** 在 Activity 或 Fragment 中 ***/ |
1 | /*** 示例代码中协程运行后的流程日志 ***/ |
以上就是协程方式的实现,如果用传统阻塞方式来实现,执行流程如下表所示:
| 编号 | 时间轴 | 执行 | 动作 | 耗时 | 描述 |
|---|---|---|---|---|---|
| 1 | 00:00 | 热水器 | 烧水开始 | 0分钟 | 我开启热水器执行烧水任务,并站在热水器旁等待。 |
| 2 | 10:00 | 热水器 | 烧水结束 | 10分钟 | 10 分钟后水烧开了,我知道下一个动作是喝水。 |
| 3 | 10:00 | 我自己 | 喝热水 | 10秒钟 | 我用了 10 秒钟的时间喝完了烧开的热水。 |
| 4 | 10:10 | 洗衣机 | 洗衣开始 | 0分钟 | 我开启洗衣机执行清洗任务,并站在洗衣机旁等待。 |
| 5 | 40:10 | 洗衣机 | 洗衣结束 | 30分钟 | 30 分钟后衣服洗净了,我知道下一个动作是晾衣服。 |
| 6 | 40:10 | 我自己 | 晾衣服 | 20秒钟 | 我用了 20 秒钟的时间晾晒洗净的衣服。 |
3.5 withContext
在生活案例的代码中,我们用到了 withContext,它是 Kotlin 协程中的挂起函数,用于临时切换协程的上下文(Dispatcher / Context),并在新上下文中执行一段代码块,最后自动切回原来的上下文。这意味着 withContext 会阻塞当前协程,直到代码块执行完成,并返回结果后,当前协程的后续代码才会继续执行。比如下面的打印会按照串行的方式执行。
1 | /*** 打印:Step:1 → Step:2 → Step:3 ***/ |
运行上述代码,打印结果验证了 withContext 会阻塞当前协程这一特性。由于「步骤 2」中包含耗时操作(睡眠 3 秒),「步骤 3」必须等待该操作完全执行完毕后,才能继续执行。那么,如果将 withContext 换成 launch,打印结果是一个什么顺序呢?
1 | /*** 打印:Step:1 → Step:3 → Step:2 ***/ |
通过打印结果,我们发现「步骤 3」在「步骤 2」的耗时操作结束前就执行了。这是因为 launch 会启动一个新的协程,不会阻塞当前协程,而是并发执行新协程。以下是二者的特性对比:
| 特性 | withContext | launch |
|---|---|---|
| 是否是 suspend 函数 | 是 | 不是(普通函数) |
| 是否启动新协程 | 否(在当前协程中切换上下文) | 是 |
| 是否挂起当前协程 | 挂起,直到代码块执行完成 | 不挂起 |
| 是否有返回值 | 返回代码块中的值 | 返回 Job(无业务结果) |
| 典型用途 | 执行需返回结果的异步操作 | 执行无需结果的后台任务 |
4、Android 中协程的写法
我们以 Retrofit 在 Android 中的网络请求为例演示协程的用法。首先,我们需要准备 Retrofit 实例和 Github 接口实例,还需要准备一个请求数据的挂起函数 contributors。
1 | package com.sunzn.coroutines |
然后,我们就可以在 Activity 中启动协程来发起网络请求了。这里启动了一个运行在主线程上的协程,挂起函数 contributors 切到 IO 线程池完成网络请求后,请求结果被 Handler 调用 post() 方法重新返回到主线程,然后在主线程执行 UI 更新操作。这里需要补充的一点是 Retrofit 库提供了对协程的支持,只需对声明的函数增加 suspend 关键字,Retrofit 就可以在接口的 suspend 函数内部,用 suspendCancellableCoroutine 将传统的回调式网络请求包装成挂起函数,让你能用同步的写法实现异步网络请求。
1 | class MainActivity : ComponentActivity() { |
虽然 CoroutineScope 可以启动一个协程来完成网络请求任务。但在真实的开发中,我们更习惯于使用 Jetpack 库为我们提供的各种便捷扩展,比如:lifecycleScope 和 viewModelScope。
4.1 LifecycleScope
这里的 lifecycleScope 是 LifecycleOwner 的扩展属性,而 LifecycleOwner 是一个接口类,ComponentActivity 和 Fragment 均实现了该接口。因此,在这些组件中可以直接使用 lifecycleScope,并使其具备感知对应生命周期的能力。其核心特性如下:
- 默认主线程:
lifecycleScope默认使用Dispatchers.Main.immediate(主线程),适合更新 UI。
- 生命周期感知
lifecycleScope会自动绑定到组件(如Activity/Fragment)的Lifecycle。- 协程仅在其关联的
Lifecycle处于 STARTED(或更高,如 RESUMED)状态时才会执行。 - 自动管理协程生命周期,无需手动取消。当
Lifecycle被销毁(如onDestroy()调用)时,所有通过lifecycleScope启动的协程会自动取消,避免内存泄漏。
- 仅适用于 LifecycleOwner
- 只能在实现了
LifecycleOwner的类中使用(如ComponentActivity、Fragment)。
- 只能在实现了
与 CoroutineScope 的使用方式类似,lifecycleScope 在 LifecycleOwner 中可以直接在主线程启动协程而无需显示的指定。调用方式如下:
1 | lifecycleScope.launch { |
相信你注意到了 lifecycleScope 默认使用了 Dispatchers.Main.immediate 调度器,而不是 Dispatchers.Main。那么,它们有什么区别呢?在协程中,Dispatchers.Main 和 Dispatchers.Main.immediate 都用于在主线程(UI 线程)上执行代码,但它们在调度时机上有关键区别:
Dispatchers.Main
- 将任务调度到主线程的消息队列末尾。
- 即使当前已经在主线程中,也会挂起当前协程,并将后续代码放入消息队列,等待下一次事件循环处理。
- 行为类似于
Handler.post()。
适用场景:需要确保操作在主线程执行,但不急于立即执行(例如响应网络结果后更新 UI)。
1
2
3
4
5
6
7lifecycleScope.launch(Dispatchers.IO) {
val data = fetchData()
withContext(Dispatchers.Main) {
// 此代码会被 post 到主线程队列末尾
updateUI(data)
}
}Dispatchers.Main.immediate
- 如果当前已经在主线程,则立即执行代码,不经过消息队列。
- 如果不在主线程,则行为与
Dispatchers.Main相同(切换到主线程并执行)。 - 可避免不必要的上下文切换开销,提升性能。
适用场景:希望在主线程中尽可能快地执行后续代码(例如在 UI 回调或生命周期方法中启动协程并立即更新状态)。
1
2
3
4
5lifecycleScope.launch(Dispatchers.Main.immediate) {
showLoading() // 立即执行,无需排队
val result = withContext(Dispatchers.IO) { doWork() }
updateUI(result) // 返回主线程时仍使用 immediate,若已在主线程则立即执行
}
最后,我们以表格的形式将 Dispatchers.Main 和 Dispatchers.Main.immediate 的关键区别进行对比总结:
| 特性 | Dispatchers.Main | Dispatchers.Main.immediate |
|---|---|---|
| 当前在主线程时 | 排队到消息队列末尾(延迟执行) | 立即执行 |
| 不在主线程时 | 切换到主线程并排队执行 | 切换到主线程并排队执行(与 Main 相同) |
| 性能 | 有轻微调度开销 | 减少不必要的调度,更高效 |
| 使用建议 | 通用主线程调度 | 在已知处于主线程且需立即执行时使用 |
✦ 注意:不要滥用
immediate,仅在确实需要“立即执行”且确认上下文安全时使用。在错误的时机立即执行 UI 操作(如 View 尚未 attach)可能导致崩溃。
4.2 ViewModelScope
这里的 viewModelScope 是 ViewModel 的扩展属性,同样基于 Dispatchers.Main.immediate 调度器。其核心特性如下:
- 默认主线程:
- 与
lifecycleScope一样,默认在主线程运行,适合更新 LiveData 或 StateFlow。
- 与
- 绑定 ViewModel 的生命周期:
viewModelScope是ViewModel类的扩展属性。- 协程的生命周期与
ViewModel的作用域一致:从ViewModel创建开始,到ViewModel被清除(即关联的Activity/Fragment永久销毁,如配置变更不会销毁ViewModel)时自动取消。
- 自动取消,避免内存泄漏
- 当
ViewModel调用onCleared()时,所有通过viewModelScope启动的协程会自动取消。 - 特别适合执行与 UI 状态无关但需保留跨配置变更的数据操作(如网络请求、数据库读写)。
- 当
- 仅限 ViewModel 中使用
- 只能在继承
androidx.lifecycle.ViewModel的类中直接访问。
- 只能在继承
调用方式如下:
1 | class MyViewModel : ViewModel() { |
4.3 关键区别对比
| 特性 | viewModelScope | lifecycleScope |
|---|---|---|
| 所属组件 | ViewModel | LifecycleOwner(Activity / Fragment) |
| 生命周期范围 | 从 ViewModel 创建 → onCleared()(跨配置变更存活) | 从组件创建 → onDestroy()(配置变更时被销毁) |
| 适用场景 | 业务逻辑、数据加载、跨配置变更的任务 | 与 UI 生命周期强绑定的操作(如动画、临时监听) |
| 协程取消时机 | ViewModel 被清除时(如 Activity 完全 finish) | Activity/Fragment 销毁时(包括配置变更) |
| 是否受配置变更影响 | 不受影响(ViewModel 保留) | 受影响(旧 scope 被取消) |
| 典型用途 | 加载数据、调用 Repository、更新 StateFlow/LiveData | 启动一次性 UI 动画、监听短暂事件、Toast 等 |
4.4 如何选择?
- 用
lifecycleScope: 当任务与UI 生命周期紧密耦合,且在界面销毁后不应继续(如播放动画、注册临时回调、显示 Snackbar)。 - 用
viewModelScope: 当任务与业务逻辑或数据状态相关,且希望在屏幕旋转等配置变更后继续执行或保留结果(如网络请求、数据库操作)。
5、结构化并发
Kotlin 协程的结构化并发是一种编程范式,它的核心思想是:将协程的生命周期进行显式管理,确保异步代码像普通同步代码一样,具有明确的开始和结束,不会“泄漏”或失控。
简单来说,它要求你不能像放飞风筝一样随意启动协程(如使用 GlobalScope),而是要将协程绑定在一个特定的“作用域”内。当这个作用域结束时,其内部所有的协程都会被自动取消,从而避免内存泄漏和无效计算。为了深刻的理解这个概念,我们将其核心机制拆解为以下几个关键点:
5.1 生命周期绑定
在结构化并发中,协程不再是独立的个体,而是像树一样存在严格的父子关系。并且,父子层级的协程与生命周期深度绑定。
- 父负责子: 父协程会自动等待所有子协程执行完毕(类似于
join)。 - 级联取消: 如果父协程被取消,所有子协程会立即被递归取消。
- 自动回收: 作用域销毁 = 协程自动取消,无需手动干预。
在 Android 中,典型的应用场景是使用 lifecycleScope(绑定 Activity/Fragment 生命周期)或 viewModelScope(绑定 ViewModel 生命周期)。
- 当页面销毁时,这些作用域会自动取消。
- 所有在这个作用域内启动的网络请求或数据库操作会自动停止,防止在销毁的页面上更新 UI 导致崩溃。
我们还是以第 4 节「Android 中协程的写法」中的代码为例,事实上,当你使用 launch、async 或 GlobalScope.launch 等方式启动协程时,会返回一个 Job 对象。该 Job 是协程的句柄(handle),用于管理和控制协程的生命周期。如果在某个 Activity 中启动了协程,但在网络请求完成前关闭了该 Activity,那么当请求结果返回时,协程可能仍尝试访问已销毁的 UI 组件,从而导致崩溃或引发内存泄漏。为避免此类问题,我们可以在 Activity 的生命周期结束时(如 onDestroy())主动取消与之关联的协程。
1 | /*** 启动协程,请求数据,获取句柄 ***/ |
除了 Job 可以用于取消协程外,我们还可以使用 lifecycleScope 来取消协程。但二者在作用机制、适用场景以及与生命周期的绑定方式上存在本质区别。Job 代表一个可取消的任务。你可以手动创建、启动、取消它。而 lifecycleScope 是 Android Jetpack 提供的一个与 Lifecycle 绑定的协程作用域,其内部封装了一个关联了 Job + Main 调度器的 LifecycleCoroutineScopeImpl,并自动将其注册为 LifecycleObserver。当所关联的 Activity 或 Fragment 进入 DESTROYED 状态时,lifecycleScope 会自动调用其 coroutineContext.cancel(),从而取消所有在其作用域内启动的协程。
5.2 异常处理机制
这是结构化并发中非常关键的一点。当子协程发生异常时,父协程该如何反应?Kotlin 提供了两种截然不同的作用域来处理:
| 作用域/Job 类型 | 异常传播规则 | 适用场景 |
|---|---|---|
| coroutineScope (或普通 Job) |
严格模式 任一子协程抛出未捕获异常 → 取消所有子协程 + 向上传播异常。 |
事务性任务 例如:必须同时获取“用户信息”和“订单列表”才能展示页面,缺一不可。 |
| supervisorScope (或 SupervisorJob) |
宽容模式 子协程异常仅自身终止 → 不影响其他子协程 + 不向上传播。 |
独立并行任务 例如:同时下载多个文件,一个文件下载失败不应中断其他文件的下载。 |
5.3 避免 GlobalScope
你可能会看到 GlobalScope.launch 这种写法,但在结构化并发的理念下,这通常被视为反模式(Anti-pattern)。
- 非结构化: 使用
GlobalScope启动的协程没有父作用域,也没有绑定任何业务组件。 - 使用风险: 它就像一个“野线程”,如果不在代码中显式调用
cancel(),它会一直运行,导致内存泄漏(例如持有 Activity 引用却在页面销毁后还在运行)。
5.4 代码示例
为了更直观地感受非结构化、结构化的区别,这里有两个简单的对比示例:
1 | /*** 非结构化,不推荐,存在风险 ***/ |
1 | /*** 结构化,推荐,不存在风险 ***/ |
Kotlin 协程的结构化并发,本质上是一种责任机制。它通过强制要求你将协程放入一个“容器”(CoroutineScope)中,解决了传统异步编程中“线程泄漏”、“异常难以追踪”和“生命周期不可控”的三大痛点。只要遵循这一原则,你的异步代码就会变得可预测、可维护且安全。
6、并行协程的启动和交互
在 Kotlin 协程中实现并行处理及交互,核心在于选择正确的启动方式和同步机制。Kotlin 协程的设计理念是“结构化并发”,这意味着所有的协程都应该在一个作用域(CoroutineScope)内启动,并且其生命周期是受管理的。Kotlin 提供了两个主要的构建器来启动协程,分别是 launch 和 async。其中 async 是实现并行的关键。
6.1 启动并行协程
Kotlin 提供了两个主要的构建器来启动协程,其中 async 是实现并行的关键。
“一劳永逸”的任务:
launch用于启动一个不返回结果的协程,通常用于执行后台任务,如日志上传或数据库操作。它返回一个Job对象,可用于控制协程的生命周期(如取消),但无法获取执行结果。并行计算与结果聚合:
async是实现并行的核心。它用于启动一个需要返回结果的协程。它返回一个Deferred<T>对象(Job的子类),代表一个“延迟的结果”。通过await()方法,可以在需要时获取这个结果。
在代码层面,async 启动协程的方式与 launch 类似,但需要通过 await() 方法获取结果。如下所示:
1 | val deferred = lifecycleScope.async { |
为了直观区分 launch 与 async 的适用场景,我们继续以 Github 贡献者查询为例。假设需要获取两个库的贡献者列表:
顺序执行(低效串行):在使用
launch的代码块中,如果直接调用挂起函数,请求将是顺序执行的。代码会在第一行挂起并等待第一个请求完全返回后,才会开始执行第二行代码发起第二个请求。这种模式下,总耗时约为两个网络请求耗时的总和,未能发挥并发优势。1
2
3
4
5
6
7/*** 串行请求,挂起函数逐个执行,最后合并结果,耗时为两个请求任务之和 ***/
private fun contributors() = lifecycleScope.launch {
val contributors1 = github.contributors("square", "retrofit") // 挂起,等待
val contributors2 = github.contributors("square", "okhttp")。 // 挂起,等待
showContributors(contributors1 + contributors2)
}并发执行(利用 Async):为了实现并发,我们需要利用
async构建器。async的作用是立即启动一个“异步任务”,并返回一个Deferred对象(代表未来的结果)。通过先启动两个任务,再分别调用await()获取结果,我们实现了两个网络请求的并行发起。此时,总耗时仅取决于耗时最长的那个子任务。1
2
3
4
5
6
7
8
9
10/*** 并行请求,挂起函数并发执行,最后合并结果,耗时取决于最慢的子任务 ***/
private fun contributors() = lifecycleScope.launch {
// 1. 立即启动两个异步任务(非阻塞)
val contributors1 = lifecycleScope.async { github.contributors("square", "retrofit") }
val contributors2 = lifecycleScope.async { github.contributors("square", "okhttp") }
// 2. 等待结果并合并
showContributors(contributors1.await() + contributors2.await())
}并发执行(最佳实践):上述代码虽然实现了并发执行,也缩短了请求耗时,但在异常处理和作用域管理上存在明显的缺陷。
- 异常静默失败:如果
contributors1的请求失败了(例如抛出了异常),当执行到contributors1.await()时,异常会被抛出。此时,代码会直接跳出协程体,导致contributors2的await()永远不会被执行。 - 生命周期风险:由于
contributors2的await()没有被执行,这个协程任务变成了“孤儿”任务。它虽然在后台继续运行,但其结果已经无人关心。更严重的是,如果contributors2的作用域是lifecycleScope,当Activity/Fragment销毁时,这个“孤儿”任务虽然会被取消,但在被取消前如果它完成了,试图更新 UI,就可能导致应用崩溃。
简单来说,就是如果一个请求失败,另一个请求的结果就丢失了,且可能导致资源浪费或潜在的生命周期问题。所以,为了确保两个任务都能正确完成或被正确处理,应该使用
coroutineScope或supervisorScope来包裹async代码块。这样可以实现结构化并发,确保所有子任务在同一个作用域内,任何一个子任务失败都会取消其他任务,且必须等待所有任务完成(无论是成功还是失败)。最佳实践代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/*** 并行请求,挂起函数并发执行,最后合并结果,耗时取决于最慢的子任务 ***/
private fun contributors() = lifecycleScope.launch {
// 使用 coroutineScope 确保块内的所有协程都完成或被取消
val (contributors1, contributors2) = coroutineScope {
// 启动两个异步任务
val task1 = async { github.contributors("square", "retrofit") }
val task2 = async { github.contributors("square", "okhttp") }
// 挂起等待两个任务都完成
// 如果 task1 失败,task2 会被自动取消,异常会向上传播
task1.await() to task2.await()
}
showContributors(contributors1 + contributors2)
}- 异常静默失败:如果
6.2 并行协程陷阱
要实现真正的并行,必须先启动所有的 async 协程,最后再统一调用 await() 获取结果。如果在启动一个 async 后立即调用 await(),就会导致代码变成串行执行。
1 | val task1 = async { github.contributors("square", "retrofit") }.await() // 等待第一个完成 |
6.3 协程间的交互与同步
当多个协程需要协调执行顺序时,可以使用以下机制。
await等待结果:这是最常见的交互方式。一个协程需要另一个协程的计算结果才能继续执行。通过Deferred.await(),当前协程会挂起,直到结果可用。join仅等待完成:如果你不关心协程的返回结果,只希望等待它执行完毕,可以使用Job.join()。这在需要确保某个初始化任务完成后再执行后续任务时非常有用。用一个点外卖的例子来说明下,想象你(主线程)点了两份外卖(两个协程任务):①一份米 ②一份菜。你很饿,但你是个讲究人,要 “饭和菜都到了才开吃” 。如果不加
join:你点了外卖,然后立刻就开始吃(程序结束),哪怕饭和菜还在路上。加了join:你点了米饭,然后对送米饭的小哥说:“你到了叫我(riceJob.join())”;点了菜,对送菜的小哥说:“你到了也叫我(dishJob.join())”。只有两人都到了,你才开始吃。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23import kotlinx.coroutines.*
fun main() = runBlocking {
println("点外卖")
// 启动两个送餐任务
val riceJob = launch {
delay(1000) // 送餐耗时
println("米饭送到了")
}
val dishJob = launch {
delay(1500) // 送菜耗时
println("红烧肉送到了")
}
// 重点来了:我要等这两个都到了才开动
// 如果没有这两行 join,主程序(我)会直接打印“开饭”,根本不管饭到了没
riceJob.join()
dishJob.join()
println("开饭!")
}coroutineScope结构化并发:coroutineScope是一个挂起函数,它会创建一个新的作用域,并等待该作用域内所有协程都完成才会返回。这保证了在函数返回结果前,所有启动的协程都已结束。
6.4 join 和 await 的区别
这是最容易混淆的地方,我们继续用外卖来解释:
join(等外卖小哥): 你点了个外卖,你站在门口等。你只关心“小哥到了没”。小哥到了,你就回家。至于外卖盒里装的是什么,你没接过来,你不知道(没有返回值)。await(拿外卖盒子): 你点了个外卖,你站在门口等。小哥到了,你必须伸手接过来那个盒子(获取返回值),然后才回家。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 1. 使用 launch + join (只等结束)
val job = launch {
delay(1000)
println("任务完成!")
// 这里不能 return 一个值,或者 return Unit (无意义)
}
job.join() // 只是等待打印完成,拿不到任何数据
println("继续执行")
// 2. 使用 async + await (等结果)
val deferred = async {
delay(1000)
"这是返回的数据" // 这里 return 了一个字符串
}
val result = deferred.await() // 拿到了字符串
println("拿到数据了:$result")
7、协程与线程交互
在 Kotlin 协程的环境中,我们习惯了 launch、async、await 等高级函数,但当需要将传统的回调式异步代码(如网络请求回调、数据库监听、事件驱动 API)融合到协程体系中时,标准的协程构建器往往力不从心。这时,Kotlin 提供了两个底层但极其强大的工具:suspendCoroutine 和 suspendCancellableCoroutine,用于将传统的异步回调代码转换为现代化的挂起函数。简单来说,它们是桥接阻塞/回调代码与协程世界的关键枢纽。
7.1 回调函数转换为挂起函数
挂起函数 suspendCoroutine 和 suspendCancellableCoroutine 都能让协程挂起,直到异步操作完成,但它们在 “善后处理” 上有着本质区别。
suspendCoroutine
这是最基础的挂起工具。当你调用它时,协程会挂起,你拿到一个
Continuation对象。你需要在异步回调的“成功”或“失败”分支中调用resume()或resumeWithException()来恢复协程。缺点:如果外部协程在异步任务完成前调用了
job.cancel(),suspendCoroutine感知不到外部协程的取消。底层的线程或请求还在跑,这不仅浪费 CPU 和网络资源,还可能导致在挂起的协程恢复时操作已销毁的 UI 组件而引发崩溃。suspendCancellableCoroutine
这是
suspendCoroutine的增强防御版,能感知协程取消,避免内存泄漏或无效操作。它接收一个CancellableContinuation,其核心优势在于提供了invokeOnCancellation { }方法。
| 特性 | suspendCoroutine |
suspendCancellableCoroutine |
|---|---|---|
| 核心能力 | 挂起协程,等待回调 | 挂起协程 + 支持取消 |
| 返回类型 | Continuation<T> |
CancellableContinuation<T> |
| 取消机制 | 无感知。即使协程被取消,底层异步任务(如网络请求、线程)仍在运行,可能导致资源泄漏。 | 可响应。协程取消时,可自动触发清理逻辑(如取消请求、关闭流)。 |
| 适用场景 | 简单的、不可取消的、瞬时的操作(如 View 绘制完成监听)。 | 绝大多数异步操作(如网络请求、数据库读写、长耗时任务)。 |
下面我们继续以 Github 贡献者查询为例,演示 suspendCoroutine 与 suspendCancellableCoroutine 的差异。首先,我们准备一个 Github 的接口函数:
1 | interface Github { |
使用 suspendCoroutine 将异步回调代码转换为挂起函数,在异步回调的“成功”或“失败”分支中调用 resume() 或 resumeWithException() 来恢复协程。
1 | /*** 将异步回调代码转换为挂起函数,无法感知协程取消 ***/ |
使用 suspendCancellableCoroutine 将异步回调代码转换为挂起函数,协程恢复与 suspendCoroutine 类似,核心的区别是提供了 invokeOnCancellation { } 方法,在感知到协程取消时自动触发,并可在该回调中释放资源。
1 | /*** 将异步回调代码转换为挂起函数,可以感知协程取消 ***/ |
7.2 桥接阻塞代码与挂起代码
除了 launch 和 async,runBlocking 也可用于启动协程,它的核心作用是桥接阻塞代码与挂起代码。简单来说,就是启动一个新的协程,并阻塞当前线程,直到该协程执行完成。在深入了解之前,必须强调一个最重要的原则:runBlocking 是协程世界的“紧急刹车”,而不是“正常驾驶模式”。因为它有以下特点:
- 不需要 CoroutineScope:
runBlocking启动协程时不需要显式的CoroutineScope。 - 它会阻塞线程: 调用
runBlocking的线程(比如 Android 的主线程)会被完全占用,无法处理其他任务,直到内部协程结束。如果在主线程滥用,会导致界面卡死(ANR)。 - 不应在协程内部使用: 如果你已经在一个
suspend函数或协程作用域(CoroutineScope)内,应该使用coroutineScope或withContext,而不是runBlocking。
既然 runBlocking 既不能在协程内部使用,又会阻塞线程。那为什么还要提供这样一个函数呢?这是由 runBlocking 设计初衷决定的,它的主要用途不是启动协程,而是将协程风格的挂起代码封装成传统线程可用的阻塞式接口。其主要的使用场景为测试环境和主函数入口。
测试环境(Tests)
在编写单元测试或集成测试时,测试框架通常是同步的。你需要等待异步的协程代码执行完毕后再进行断言。下方代码如果没有
runBlocking,测试方法可能在协程执行前就结束了。1
2
3
4
5
fun testAsyncFunction() = runBlocking {
val result = someSuspendFunction()
assertEquals("expected", result)
}主函数入口(Main Function)
在普通的 Kotlin 应用(非 Android)中,
main函数是程序入口。为了让程序等待后台协程完成后再退出,可以使用runBlocking。1
2
3
4
5
6
7
8fun main() = runBlocking {
launch {
delay(1000)
println("World!")
}
println("Hello")
// 程序会在这里等待,直到 launch 中的任务完成
}
鉴于 runBlocking 的特殊设计初衷及其阻塞线程的特性,应严格避免在以下场景中使用:
- Android 主线程:绝对不要在
Activity或Fragment的onCreate等生命周期方法中直接使用runBlocking,这会导致界面完全无响应。 - 协程内部嵌套:在
viewModelScope.launch或其他协程内部再次使用runBlocking,会导致线程阻塞,破坏协程的非阻塞特性。
在 6.3 节“协程间的交互与同步”中,我们提到 coroutineScope 是一个挂起函数,它会创建一个新的作用域,并等待该作用域内所有协程都完成才会返回。虽然 coroutineScope 在代码风格和“等待完成”的概念上与 runBlocking 极为相似,但二者底层机制截然不同:runBlocking 是阻塞线程,而 coroutineScope 是挂起协程。下面通过几个维度来了解它们的区别:
| 特性 | runBlocking |
coroutineScope |
|---|---|---|
| 本质 | 阻塞函数 (Blocking function) | 挂起函数 (Suspending function) |
| 对线程的影响 | 阻塞当前线程,线程在此处“卡住”不动 | 挂起当前协程,释放底层线程供其他任务使用 |
| 机制 | 它会在当前线程上创建一个事件循环,并一直占用这个线程,不干别的事,直到里面的任务做完。 | 它会暂停当前协程的执行,把线程的控制权交还给线程池,让线程去干别的活。当 coroutineScope 里的所有子协程都完成后,当前协程会恢复执行。 |
| 调用位置 | 可以在普通函数(非协程)中调用 | 只能在协程内部(挂起函数中)调用 |
| 主要用途 | 连接“非协程代码”与“协程世界”;测试 | 在协程内部实现“结构化并发”,等待子任务完成 |
| 返回值 | 可以返回一个结果 | 通常不返回特定值,主要用于控制流程 |