一、引言
在 Android 开发中,异步任务处理是绕不开的话题。传统的线程、Handler、AsyncTask 等方案要么过于繁琐,要么存在生命周期管理问题。Kotlin 协程的出现,以优雅的语法和强大的结构化并发能力,成为解决异步编程难题的理想方案。本文将结合核心概念、使用场景和实战经验,带您全面掌握协程的精髓。
二、协程核心概念:轻量级的异步处理
首先要明确的是,协程不是线程。线程是操作系统调度的基本单位,由操作系统负责管理和调度。而协程是在程序层面实现的一种并发编程机制,由协程库(如 kotlinx.coroutines
)进行管理。
1. 什么是协程?
- 定义:协程是基于线程的轻量级 “线程”,通过
suspend
挂起函数实现异步逻辑同步化。核心特点是可挂起恢复,避免阻塞主线程。 - 优势:比线程更轻量(一个线程可运行多个协程),通过调度器灵活切换线程,确保 UI 线程安全。
2. 挂起函数(suspend function)
- 定义:用
suspend
关键字修饰的函数,只能在协程体内或其他挂起函数中调用。 - 核心作用:在不阻塞线程的前提下暂停执行,例如
delay(1000)
、网络请求等耗时操作。
3. 挂起 vs 阻塞
- 挂起:不阻塞主线程,UI 可正常刷新(如
withContext(Dispatchers.IO)
处理耗时任务)。 - 阻塞:导致主线程 ANR(如直接在 UI 线程执行磁盘读写)。
协程的挂起和恢复的工作原理(Continuation)
1. CPS 转换
Java 中没有 suspend
函数,suspend
是 Kotlin 特有的关键字。在编译时,Kotlin 编译器会对含有 suspend
关键字的函数进行转换,这种转换在 Kotlin 中被称为 CPS 转换(continuation - passing - style)。
例如,程序员编写的挂起函数代码:
suspend fun getUserInfo(): User {
val user = User("asd123", "userName", "nickName")
return user
}
经过假想的中间态转换后(便于理解):
fun getUserInfo(callback: Callback<User>): Any? {
val user = User("asd123", "userName", "nickName")
callback.onSuccess(user)
return Unit
}
最终转换后的代码:
fun getUserInfo(cont: Continuation<User>): Any? {
val user = User("asd123", "userName", "nickName")
cont.resume(user)
return Unit
}
通过 Kotlin 生成字节码工具查看字节码并反编译成 Java 代码,也能验证确实会引入一个 Continuation
对象来实现恢复流程,这个 Continuation
对象包含了 Callback
的形态。它有两个重要作用:暂停并记住执行点位;记住函数暂停时刻的局部变量上下文。这就是我们可以用同步方式写异步代码的原因,因为 Continuation
帮我们完成了回调流程。
2. Continuation 源码分析
以下是 Continuation
源码的部分内容:
internal abstract class BaseContinuationImpl() : Continuation<Any?> {
public final override fun resumeWith(result: Result<Any?>) {
// 省略好多代码
invokeSuspend()
// 省略好多代码
}
protected abstract fun invokeSuspend(result: Result<Any?>): Any?
}
internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {
protected abstract fun invokeSuspend(result: Result<Any?>): Any?
}
3. 状态机原理
以一个包含多个挂起函数的协程代码为例:
suspend fun testCoroutine() {
val user = apiService.getUserInfoSuspend() // 挂起函数 IO 线程
tvNickName.text = user?.nickName // UI 线程更新界面
val unReadMsgCount = apiService.getUnReadMsgCountSuspend(user?.token) // 挂起函数 IO 线程
tvMsgCount.text = unReadMsgCount.toString() // UI 线程更新界面
}
经过 Kotlin 编译器编译后:
fun testCoroutine(completion: Continuation<Any?>): Any? {
class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
var label: Int = 0
lateinit var user: Any
lateinit var unReadMsgCount: Int
var result = continuation.result
var suspendReturn: Any? = null
val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED
override fun invokeSuspend(_result: Result<Any?>): Any? {
result = _result
label = label or Int.Companion.MIN_VALUE
return testCoroutine(this)
}
}
val continuation = if (completion is TestContinuation) {
completion
} else {
TestContinuation(completion)
}
var loop = true
while (loop) {
when (continuation.label) {
0 -> {
throwOnFailure(result)
continuation.label = 1
suspendReturn = getUserInfoSuspend(continuation)
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
}
}
1 -> {
throwOnFailure(result)
user = result as Any
continuation.label = 2
suspendReturn = getUnReadMsgCountSuspend(user.token, continuation)
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
}
}
2 -> {
throwOnFailure(result)
user = continuation.mUser as Any
unReadMsgCount = continuation.unReadMsgCount as Int
loop = false
}
}
}
}
通过一个 label
标签控制分支代码的执行。当 label
为 0 时,首先将 label
设置为下一个分支的数值,然后执行第一个 suspend
方法并传递当前 Continuation
,得到返回值。如果返回值是 COROUTINE_SUSPENDED
,协程框架就直接返回,协程挂起。当第一个 suspend
方法执行完成后,会回调 Continuation
的 invokeSuspend
方法,进入第二个分支执行,以此类推,直到执行完所有 suspend
方法。每一个挂起点和初始挂起点对应的 Continuation
都会转化为一种状态,协程恢复只是跳转到下一种状态中。挂起函数将执行过程分为多个 Continuation
片段,并且利用状态机的方式保证各个片段是顺序执行的。
综上所述,协程的挂起和恢复的本质是 CPS + 状态机。
(一)内存占用
1. 线程的内存占用
每个线程在创建时都会分配一定数量的栈内存,默认情况下大约为 1MB。当系统需要启动大量线程时,会消耗大量的内存资源,这可能导致系统资源枯竭,影响系统的稳定性和性能。
2. 协程的内存占用
协程运行在现有的线程中,它们不需要单独的栈内存,而是共享调用栈。这使得协程的内存开销非常小,通常每个协程只占用几个 KB。这种特性使得同一线程可以管理和运行大量的协程,不受传统线程数量的限制。
(二)任务切换
1. 线程切换
线程切换由操作系统管理,涉及到用户态和内核态之间的切换。这个过程代价较高,需要保存和恢复 CPU 寄存器、程序计数器、内存栈等信息。频繁的线程切换会带来显著的性能开销。
2. 协程切换
协程切换在用户态完成,不涉及内核态切换,只是切换函数的上下文。与线程切换相比,协程切换的代价相对低很多,能够提高程序的执行效率。
(一)线程内存模型
在 JVM 中,每个线程都有自己独立的线程栈,每个栈帧包含局部变量、操作数栈和相关信息。当线程被挂起时,所有这些信息必须保存并在重新调度时恢复,这涉及到较多的内存操作和上下文切换。
(二)协程内存模型
协程的栈帧通常是堆上的对象。当协程挂起时,不需要切换线程,只是函数调用的上下文发生变化,协程状态会被保存到堆中。这种模型使得内存利用更加高效和灵活。
(三)协程堆栈帧
协程在挂起时,会将当前的堆栈帧转换为对象并存储在堆中。这个对象包含了所有当前帧的局部变量、挂起点以及其他必要信息。当协程恢复执行时,这个对象会重新转换为堆栈帧并继续执行。
(四)Continuation
Kotlin 中的挂起函数实质上会被编译器转换成带有回调的 Continuation
对象。该对象包含两个主要部分:
- 上下文(Continuation Context):绑定的执行环境。
- 恢复逻辑(Resume Logic):保存和处理挂起点的逻辑。
以下是 Continuation
接口的定义:
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
三、协程调度器:线程切换的指挥官
协程通过Dispatchers
指定运行线程,常见调度器:
调度器 | 用途 | 示例场景 |
---|---|---|
Dispatchers.Main |
主线程,处理 UI 更新、LiveData 通知 | 更新 TextView 文本 |
Dispatchers.IO |
非主线程,优化磁盘 / 网络 IO | 文件读写、Retrofit 网络请求 |
Dispatchers.Default |
非主线程,CPU 密集型任务 | JSON 解析、数据排序 |
Dispatchers.Unconfined |
不受限调度(不推荐) | 测试或简单异步逻辑 |
最佳实践:耗时操作(IO/CPU)务必切换到非主线程调度器,避免阻塞 UI。
四、作用域与生命周期:避免协程泄漏的关键
1. 结构化并发:协程的 “作用域牢笼”
- 协程必须在作用域(
CoroutineScope
)内启动,作用域负责追踪和取消子协程,避免内存泄漏。 - 核心原则:协程的生命周期绑定到创建它的组件(如 Activity、ViewModel)。
2. 常用作用域
作用域 | 生命周期绑定 | 使用场景 | 取消时机 |
---|---|---|---|
GlobalScope |
进程级(危险!) | 全局监听(极少使用) | 进程结束时自动取消 |
MainScope() |
Activity/Fragment | onCreate 中启动协程 |
onDestroy 时自动取消 |
ViewModelScope |
ViewModel | ViewModel 内异步数据加载 | ViewModel 销毁时取消 |
lifecycleScope |
Activity/Fragment | AndroidX 组件专用 | 组件生命周期结束时取消 |
示例:在 Activity 中使用lifecycleScope
button.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
// 耗时操作
withContext(Dispatchers.Main) { textView.text = "加载完成" }
}
}
3. 手动创建作用域:CoroutineScope()
// 自定义作用域,手动管理取消
val myScope = CoroutineScope(Dispatchers.IO + Job())
// 启动协程
val job = myScope.launch { ... }
// 取消作用域
myScope.cancel()
五、协程构建器:启动协程的 “工具箱”
1. 核心构建器对比
构建器 | 阻塞当前线程? | 返回值 | 执行模式 | 用途 |
---|---|---|---|---|
launch |
否 | Job (无返回值) |
非阻塞异步 | 启动无需返回值的协程 |
async |
否 | Deferred<T> (可获取结果) |
非阻塞异步 | 启动需要返回值的协程 |
runBlocking |
是(测试专用) | Unit | 阻塞当前线程 | 测试或简单协程入口 |
withContext |
否 | 指定类型结果 | 串行切换线程 | 切换调度器并返回结果 |
2. 高级用法:启动模式
LAZY
:延迟启动,调用start()
或await()
时才执行(适合按需加载)。
val deferred = async(start = CoroutineStart.LAZY) { loadData() }
// 手动启动
deferred.start()
ATOMIC
:创建后立即调度,首个挂起前不响应取消。UNDISPATCHED
:在当前线程执行直到首个挂起点(用于性能优化)。
(一)异步场景示例
以一个常见的异步场景为例:
- 第一步:通过接口获取当前用户的 token 及用户信息。
- 第二步:将用户的昵称展示在界面上。
- 第三步:利用获取到的 token 获取当前用户的消息未读数。
- 第四步:将未读数展示在界面上。
(二)现有方案实现及问题
以下是使用现有方案(回调函数)实现上述异步场景的代码:
apiService.getUserInfo().enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
val user = response.body()
tvNickName.text = user?.nickName
apiService.getUnReadMsgCount(user?.token).enqueue(object : Callback<Int> {
override fun onResponse(call: Call<Int>, response: Response<Int>) {
val tvUnReadMsgCount = response.body()
tvMsgCount.text = tvUnReadMsgCount.toString()
}
})
}
})
可以看到,当需要处理多个异步任务且存在嵌套关系时,代码会变得复杂,形成所谓的「回调地狱」(callback hell),这会降低代码的可读性和可维护性。
(三)协程实现及优势
使用协程实现上述异步场景的代码如下:
mainScope.launch {
val user = apiService.getUserInfoSuspend() // IO 线程请求数据
tvNickName.text = user?.nickName // UI 线程更新界面
val unReadMsgCount = apiService.getUnReadMsgCountSuspend(user?.token) // IO 线程请求数据
tvMsgCount.text = unReadMsgCount.toString() // UI 线程更新界面
}
suspend fun getUserInfoSuspend(): User? {
return withContext(Dispatchers.IO) {
// 模拟网络请求耗时操作
delay(10)
User("asd123", "userName", "nickName")
}
}
suspend fun getUnReadMsgCountSuspend(token: String?): Int {
return withContext(Dispatchers.IO) {
// 模拟网络请求耗时操作
delay(10)
10
}
}
在协程实现中,告别了回调函数,避免了回调地狱的问题。协程最大的优势在于可以让我们用同步的代码写出异步的效果,也就是将异步代码同步去写。同时,协程还能让程序员更方便地处理异步业务,更便捷地切换线程,保证主线程的安全。
六、并发处理:并行与串行的选择
1. 并行执行:async
+ await
// 并发获取数据(耗时1s)
val time = measureTimeMillis {
val one = async { doOne() } // 耗时1s
val two = async { doTwo() } // 耗时1s
println(one.await() + two.await()) // 30
}
println("耗时:$time ms") // 约1000ms
2. 串行执行:withContext
或 无async
// 串行执行(耗时2s)
val time = measureTimeMillis {
val one = doOne() // 耗时1s
val two = doTwo() // 耗时1s
println(one + two) // 30
}
println("耗时:$time ms") // 约2000ms
扩展追问:
概述:
进程是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位;线程是进程中的一个执行单元,是 CPU 调度和分派的基本单位;而协程是一种比线程更加轻量级的并发编程方式,它可以在一个线程中实现多个任务的并发执行。
进程、线程、协程对比表
维度 | 进程(Process) | 线程(Thread) | 协程(Coroutine) |
---|---|---|---|
定义 | 程序在操作系统中的一次执行实例,是资源分配的基本单位。 | 进程内的执行单元,是 CPU 调度的基本单位。 | 用户态轻量级 “线程”,由协程库管理,可在同一线程内协作式调度。 |
调度单位 | 操作系统内核调度 | 操作系统内核调度 | 协程库(用户态调度,无需内核参与) |
上下文切换 | 内核态切换,开销极大(涉及内存地址空间、文件描述符等)。 | 内核态切换,开销较大(涉及寄存器、栈指针等)。 | 用户态切换,开销极小(仅保存协程状态到堆,无需内核干预)。 |
内存占用 | 独立地址空间(通常数 MB 到 GB 级)。 | 共享进程内存空间,每个线程栈默认约 1 MB。 | 共享线程内存,每个协程仅需几个 KB(无独立栈,共享调用栈)。 |
并发性 | 进程间并发,由操作系统控制。 | 线程间并发,由操作系统控制(抢占式多任务)。 | 协程间并发,由协程库控制(协作式多任务,需主动挂起)。 |
创建开销 | 高(需分配独立内存、文件句柄等资源)。 | 中(需分配线程栈、寄存器上下文)。 | 极低(仅创建协程对象,复用线程资源)。 |
切换开销 | 最高(涉及内核态上下文和地址空间切换)。 | 较高(内核态线程上下文切换)。 | 最低(用户态协程状态保存 / 恢复,无内核参与)。 |
资源隔离 | 完全隔离(地址空间、文件描述符等)。 | 部分隔离(共享进程内存,独立栈、寄存器)。 | 不隔离(共享线程内存,通过协程作用域管理生命周期)。 |
执行控制权 | 操作系统完全控制(不可预测抢占)。 | 操作系统完全控制(不可预测抢占)。 | 协程主动控制(通过 suspend 挂起,协作式恢复)。 |
典型用途 | 独立程序运行(如浏览器、IDE 等独立进程)。 | 多任务处理(如网络请求、文件读写等异步操作)。 | 高并发轻量任务(如海量 I/O 操作、事件驱动逻辑)。 |
代表技术 | Linux 进程、Windows 进程。 | Java 线程、POSIX 线程(pthread)。 | Kotlin 协程、Go Goroutine、Python asyncio 协程。 |
生命周期 | 由操作系统管理(创建 / 销毁开销大)。 | 由操作系统管理(依赖进程生命周期)。 | 由协程库管理(绑定作用域,如 Android 的 lifecycleScope )。 |
上下文保存位置 | 硬盘或内存(进程切换时保存完整状态)。 | 内存(线程栈和寄存器状态)。 | 堆(协程状态封装为 Continuation 对象)。 |
阻塞影响 | 进程阻塞不影响其他进程。 | 线程阻塞会占用 CPU 时间片,影响同进程内其他线程。 | 协程阻塞不阻塞线程,可释放线程执行其他协程。 |
核心差异总结
调度层级:
- 进程和线程由 操作系统内核 调度,属于 内核态并发;
- 协程由 协程库 调度,属于 用户态并发,依赖于线程但更轻量。
资源开销:
- 进程:资源隔离性最强,但创建和切换开销最大;
- 线程:共享进程资源,开销中等;
- 协程:几乎无额外资源开销,可在单线程内运行数万个协程。
控制方式:
- 进程 / 线程:由操作系统强制抢占,不可预测;
- 协程:通过
suspend
主动挂起,协作式恢复,适合细粒度异步控制。
适用场景:
- 进程:适合完全隔离的独立任务;
- 线程:适合 CPU 密集型或需要系统级并发的任务;
- 协程:适合 I/O 密集型、高并发轻量任务(如网络请求、UI 异步更新)。
总结
Kotlin 协程之所以被称为 “轻量级线程”,主要是因为它具有以下优点:
- 内存占用更少:协程不需要独立的栈内存,而是共享调用栈,大大减少了内存开销。
- 低切换开销:协程切换在用户态完成,无需与操作系统交互,切换代价小。
- 高并发模型:在同一线程上可以高效地运行大量协程,不受传统线程创建和管理的限制。
通过使用协程,开发者可以更高效地处理并发任务,提高程序的性能和可维护性。