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
}
}
}
结构化并发的核心原则
- 协程有层级关系 — 父协程取消时,所有子协程也会取消
- 父协程等待子协程 — 父协程会等所有子协程完成才完成
- 异常向上传播 — 子协程的异常会传播到父协程
- 必须在 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 函数的挂起点时:
- 当前状态保存到
Continuation对象中 - 方法返回特殊标记
COROUTINE_SUSPENDED - 当前线程被释放,可以执行其他任务
- 异步操作完成后,调度器将
Continuation恢复到线程上继续执行
关键:挂起不等于阻塞。delay(1000) 只是设置了一个定时器,线程在等待期间可以执行其他协程。而 Thread.sleep(1000) 会真正阻塞线程。
Q2: launch 和 async 的区别?
答案:
| 特性 | launch | async |
|---|---|---|
| 返回值 | 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 的问题:
- 无生命周期绑定 — 协程会一直运行到完成,即使 Activity 已销毁
- 内存泄漏 — 持有 Activity/Fragment 引用会导致泄漏
- 难以测试 — 无法控制协程的取消和等待
Q4: withContext 和 async 的区别?
答案:
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: coroutineScope 和 supervisorScope 的区别?
答案:
coroutineScope:任何子协程异常 → 取消所有兄弟协程 → 向上传播异常supervisorScope:子协程异常 → 只影响自身 → 兄弟协程不受影响
// coroutineScope —— 一个失败全部取消
coroutineScope {
launch { throw Exception() } // 失败
launch { delay(1000) } // 也被取消!
}
// supervisorScope —— 互不影响
supervisorScope {
launch { throw Exception() } // 失败
launch { delay(1000) } // 正常完成
}
supervisorScope 适用于独立任务(如同时加载多个图片,一个失败不影响其他)。