跳到主要内容

Kotlin 协程基础

问题

Kotlin 协程是什么?与线程有什么区别?suspend 函数的原理是什么?

答案

1. 协程概述

协程是 Kotlin 提供的轻量级并发方案,可以理解为"可挂起的计算"。协程让异步代码看起来像同步代码,避免了回调地狱。

// 回调地狱 ❌
fetchUser { user ->
fetchOrders(user.id) { orders ->
fetchDetails(orders[0].id) { detail ->
updateUI(detail)
}
}
}

// 协程写法 ✅(看起来像同步代码)
suspend fun loadData() {
val user = fetchUser() // 挂起,不阻塞线程
val orders = fetchOrders(user.id)
val detail = fetchDetails(orders[0].id)
updateUI(detail)
}

2. 协程 vs 线程

特性线程协程
开销约 1MB 栈内存几十字节对象
数量受 OS 限制(几千个)可创建上万个
调度OS 内核调度协程调度器(用户态)
切换成本内核态切换(昂贵)函数调用级别(廉价)
阻塞阻塞整个线程只挂起当前协程
// 创建 10 万个协程 —— 毫无压力
runBlocking {
repeat(100_000) {
launch { delay(1000) }
}
}
// 创建 10 万个线程 —— OOM 了

3. 协程构建器

// 1. launch —— 启动协程,不返回结果(Fire and Forget)
val job = scope.launch {
delay(1000)
println("Done")
}

// 2. async —— 启动协程,返回 Deferred(类似 Future)
val deferred = scope.async {
delay(1000)
"Result"
}
val result = deferred.await() // 挂起等待结果

// 3. runBlocking —— 阻塞当前线程直到协程完成(仅用于测试/main 函数)
fun main() = runBlocking {
launch { println("Hello") }
}

// 4. withContext —— 切换协程上下文(常用于切换线程)
suspend fun fetchData(): String = withContext(Dispatchers.IO) {
// 在 IO 线程执行网络请求
api.getData()
}

4. 调度器(Dispatcher)

Dispatchers.Main       // 主线程(UI 更新)
Dispatchers.IO // IO 线程池(网络、磁盘)—— 最多 64 个线程
Dispatchers.Default // CPU 密集型 —— 线程数 = CPU 核心数
Dispatchers.Unconfined // 不限制线程(不推荐常规使用)
// 典型用法:在 IO 线程获取数据,在主线程更新 UI
viewModelScope.launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
repository.fetchData()
}
// 自动回到主线程
textView.text = data
}

5. suspend 函数原理

suspend 函数是协程的核心,编译后会被转换为状态机

// 源代码
suspend fun loadUser(): User {
val token = getToken() // 挂起点 1
val user = getUser(token) // 挂起点 2
return user
}

// 编译后伪代码(CPS 变换 + 状态机)
fun loadUser(continuation: Continuation<User>): Any? {
val sm = continuation as? LoadUserSM ?: LoadUserSM(continuation)

when (sm.state) {
0 -> {
sm.state = 1
val result = getToken(sm)
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
1 -> {
val token = sm.result as String
sm.state = 2
val result = getUser(token, sm)
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
2 -> {
return sm.result as User
}
}
}
CPS(Continuation Passing Style)变换

编译器将每个 suspend 函数多加一个 Continuation 参数。Continuation 就像一个回调,包含了恢复执行所需的状态。挂起时保存状态到 Continuation,恢复时从状态机的下一个状态继续执行。

6. CoroutineScope 与结构化并发

// ❌ 错误:全局协程,无法管理生命周期
GlobalScope.launch { /* ... */ }

// ✅ 正确:使用结构化并发
class MyViewModel : ViewModel() {
fun loadData() {
// viewModelScope 自动绑定 ViewModel 生命周期
viewModelScope.launch {
val data = repository.fetchData()
_uiState.value = data
}
}
}

class MyFragment : Fragment() {
fun loadData() {
// lifecycleScope 自动绑定 Fragment 生命周期
viewLifecycleOwner.lifecycleScope.launch {
val data = viewModel.getData()
binding.textView.text = data
}
}
}
结构化并发的核心原则
  1. 协程有层级关系 — 父协程取消时,所有子协程也会取消
  2. 父协程等待子协程 — 父协程会等所有子协程完成才完成
  3. 异常向上传播 — 子协程的异常会传播到父协程
  4. 必须在 Scope 中启动 — 确保协程有明确的生命周期归属

7. 协程异常处理

// supervisorScope —— 子协程的异常不会影响兄弟协程
supervisorScope {
launch {
throw Exception("Failed") // 只影响这个协程
}
launch {
delay(1000)
println("I'm still running!") // 正常执行
}
}

// CoroutineExceptionHandler —— 全局异常处理
val handler = CoroutineExceptionHandler { _, exception ->
Log.e("TAG", "Caught: $exception")
}

scope.launch(handler) {
throw RuntimeException("Oops")
}

常见面试问题

Q1: 协程是如何实现"挂起"的?它真的不阻塞线程吗?

答案

当协程执行到 suspend 函数的挂起点时:

  1. 当前状态保存到 Continuation 对象中
  2. 方法返回特殊标记 COROUTINE_SUSPENDED
  3. 当前线程被释放,可以执行其他任务
  4. 异步操作完成后,调度器将 Continuation 恢复到线程上继续执行

关键:挂起不等于阻塞delay(1000) 只是设置了一个定时器,线程在等待期间可以执行其他协程。而 Thread.sleep(1000) 会真正阻塞线程。

Q2: launchasync 的区别?

答案

特性launchasync
返回值Job(无结果)Deferred<T>(有结果)
异常处理立即传播await() 时抛出
用途副作用操作需要返回结果
// 并行执行两个请求
coroutineScope {
val user = async { fetchUser() }
val orders = async { fetchOrders() }
updateUI(user.await(), orders.await()) // 并行完成后合并
}

Q3: viewModelScope 是什么?为什么不用 GlobalScope

答案

viewModelScope 是 ViewModel 提供的 CoroutineScope,绑定 ViewModel 的生命周期。当 ViewModel 被清除时,scope 自动取消所有协程。

GlobalScope 的问题:

  1. 无生命周期绑定 — 协程会一直运行到完成,即使 Activity 已销毁
  2. 内存泄漏 — 持有 Activity/Fragment 引用会导致泄漏
  3. 难以测试 — 无法控制协程的取消和等待

Q4: withContextasync 的区别?

答案

  • withContext串行切换上下文,挂起当前协程,在指定 Dispatcher 执行,然后返回结果
  • async并行启动新协程,不挂起当前协程,通过 await() 获取结果
// withContext —— 串行(总耗时 2s)
val a = withContext(Dispatchers.IO) { fetchA() } // 1s
val b = withContext(Dispatchers.IO) { fetchB() } // 1s

// async —— 并行(总耗时 1s)
val a = async(Dispatchers.IO) { fetchA() }
val b = async(Dispatchers.IO) { fetchB() }
a.await() + b.await()

Q5: coroutineScopesupervisorScope 的区别?

答案

  • coroutineScope:任何子协程异常 → 取消所有兄弟协程 → 向上传播异常
  • supervisorScope:子协程异常 → 只影响自身 → 兄弟协程不受影响
// coroutineScope —— 一个失败全部取消
coroutineScope {
launch { throw Exception() } // 失败
launch { delay(1000) } // 也被取消!
}

// supervisorScope —— 互不影响
supervisorScope {
launch { throw Exception() } // 失败
launch { delay(1000) } // 正常完成
}

supervisorScope 适用于独立任务(如同时加载多个图片,一个失败不影响其他)。

相关链接