一句话:协程是编译器把异步代码变换成状态机 + 运行时用线程池调度执行的组合,用同步的写法实现了非阻塞的异步效果。
分三个层次:
编译器层 — suspend 函数的 CPS 变换
- suspend 函数编译后会被加一个额外参数 Continuation<T>,本质是一个回调
- 函数被拆成多个状态机片段,每次遇到挂起点,保存当前状态(局部变量 + label)到 Continuation 对象
- 返回 COROUTINE_SUSPENDED 标志,等异步结果回来后通过 resumeWith 恢复执行
运行时层 — 调度器
- 协程本身不绑定线程,执行由 CoroutineDispatcher 决定
- Dispatchers.Main → post 到主线程 Handler
- Dispatchers.IO → 共享线程池,适合阻塞 IO
- Dispatchers.Default → CPU 密集线程池,大小默认等于 CPU 核数
- 挂起的协程不占线程,这是它"轻量"的核心原因
结构化并发层 — CoroutineScope + Job
- 每个协程关联一个 Job,以树状结构组织
- 父 Job 取消会传播到所有子 Job,避免泄漏
一句话:CoroutineScope 不是线程池,也不是协程本身,它本质是一个协程上下文容器,负责约束协程启动时的上下文和生命周期。
可以把它理解成“协程运行的作用域”:
- 规定这批协程挂在哪个父 Job 下
- 规定默认运行在哪个 Dispatcher
- 规定取消时机和异常传播边界
它的接口非常简单:
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
所以 CoroutineScope 的本质不是“做事情”,而是持有 coroutineContext,然后让 launch/async 等构建器基于这个 context 去创建新协程。
这是 CoroutineScope 最核心的价值。
如果没有 scope,协程就容易变成“到处 launch 的后台任务”,生命周期没人管,页面退出了还在跑,就会出现:
- 内存泄漏
- 页面销毁后回调 UI 崩溃
- 请求结果回来了,但宿主对象已经没了
而有了 CoroutineScope,协程就会和宿主生命周期绑定:
- viewModelScope 绑定 ViewModel
- lifecycleScope 绑定 LifecycleOwner
- coroutineScope {} 绑定当前 suspend 调用链
宿主结束时,scope 对应的父 Job 会被取消,子协程也会一起取消。
launch/async 并不是凭空创建协程,而是:
1. 读取当前 CoroutineScope 的 coroutineContext
2. 从里面拿到父 Job、Dispatcher、异常处理器等元素
3. 创建一个新的子 Job
4. 把新协程挂到父 Job 下面
所以 scope 决定了这批协程是不是同一个“家族”。
协程启动时默认会继承 scope 里的上下文:
- Job:决定取消关系
- CoroutineDispatcher:决定运行线程
- CoroutineName:便于调试
- CoroutineExceptionHandler:决定顶层异常处理方式
比如:
viewModelScope.launch {
// 默认继承 Main dispatcher + ViewModel 的 Job
}
如果启动时额外传了 context,本质是和 scope 里的 context 做 + 合并,相同 key 后者覆盖前者。
CoroutineScope 可以用 + 组合多个配置吗可以,但要注意:不是多个 CoroutineScope 相加,而是多个 CoroutineContext.Element 用 + 合并后,再构造一个 CoroutineScope。
例如:
val scope = CoroutineScope(
SupervisorJob() +
Dispatchers.Main +
CoroutineName("MainScope")
)
这里合并进去的本质是:
- Job
- Dispatcher
- CoroutineName
最终一起组成这个 scope 的 coroutineContext。
+ 的规则本质是合并 CoroutineContext:
- 不同 key 的元素可以共存
- 相同 key 的元素,后者覆盖前者
比如:
val context = Dispatchers.IO + Dispatchers.Main
最终保留的是 Dispatchers.Main,因为它们都属于 CoroutineDispatcher,key 相同。
再比如:
val context = CoroutineName("A") + CoroutineName("B")
最终保留的是 CoroutineName("B")。
Job 也一样,不能两个一起生效:
val context = Job() + SupervisorJob()
最终只会保留后面的 SupervisorJob(),不会同时存在两个父 Job。
所以面试里可以直接说:
CoroutineScope 常见写法就是 CoroutineScope(Job() + Dispatchers.Main + CoroutineName(...))。+ 本质是合并 CoroutineContext,不是把多个 scope 拼起来;相同 key 的元素以后者为准。
一句话:CoroutineContext 是协程运行时携带的一组配置集合,本质上是一个按 Key 存储的元素集合。
它常见包含这些元素:
- Job:管理取消和父子关系
- CoroutineDispatcher:决定线程调度
- CoroutineName:方便日志和调试
- CoroutineExceptionHandler:处理顶层异常
所以一个协程跑起来时,真正随身带着的“运行环境”其实就是 CoroutineContext。
例如:
val context =
SupervisorJob() +
Dispatchers.IO +
CoroutineName("UploadTask")
这个 context 表达的含义就是:
- 这是一个可监督的任务
- 默认在 IO 线程池运行
- 调试名字叫 UploadTask
它为什么能用 + 合并?
因为 CoroutineContext 内部不是 List,也不是 Map 的普通实现,而是一个支持按 Key 查找和覆盖的上下文结构。
你可以把它理解成:
- 每个元素都有自己的 Key
- 合并时按 Key 去重
- key 不同就共存,key 相同就覆盖
常见合并规则:
Dispatcher + Dispatcherval context = Dispatchers.IO + Dispatchers.Main
最终是 Dispatchers.Main,因为同一个 key,后者覆盖前者。
Name + Nameval context = CoroutineName("A") + CoroutineName("B")
最终是 CoroutineName("B")。
Job + Jobval context = Job() + SupervisorJob()
最终只保留后面的 SupervisorJob()。
Dispatcher + Name + Jobval context = Dispatchers.IO + CoroutineName("Sync") + Job()
这三个 key 不同,所以可以同时存在。
在 launch 里也是一样的规则:
viewModelScope.launch(Dispatchers.IO + CoroutineName("load")) {
// 会覆盖 scope 默认的 dispatcher
// 但仍然继承 scope 的父 Job
}
这里可以这样理解:
- Dispatcher 被新的 Dispatchers.IO 覆盖
- CoroutineName 新增进去
- 如果没显式传 Job,父 Job 继续沿用 scope 里的
所以 launch(context) 不是完全替换原 scope,而是先继承 scope,再和新 context 做一次合并。
面试里常见一句话总结:
CoroutineContext 是协程的运行上下文,里面常见有 Job、Dispatcher、CoroutineName、异常处理器;多个 context 用 + 合并时,本质是按 key 合并,同类元素后者覆盖前者,不同元素可以共存。
一句话:协程异常不是“谁抛谁处理”这么简单,它还和协程构建器类型、父子关系、是否 await、是否结构化并发有关。**
最核心的几条规则:
- launch 里的异常默认会立即向父协程传播
- async 里的异常会先存起来,等 await() 时再抛给调用方
- 子协程抛异常,默认会取消父协程,再由父协程取消其他子协程
- CancellationException 属于取消信号,通常不当成业务失败处理
launch 和 async 的异常区别launch:异常是“直接冒泡型”
viewModelScope.launch {
throw RuntimeException("error")
}
launch 返回的是 Job,没有结果可取,所以异常不会等你“取值”时再抛,而是会立刻沿父子关系向上传播。
async:异常是“延迟暴露型”
val deferred = viewModelScope.async {
throw RuntimeException("error")
}
deferred.await()
async 返回的是 Deferred<T>,异常会先被封装在里面,等到 await() 时才真正抛出来。
所以面试里常见表述可以说:
- launch 更像 fire-and-forget,异常立即传播
- async 更像 future/promise,异常在 await() 时暴露
Job、SupervisorJob、supervisorScope 的区别这三个经常一起考,核心区别就是:子协程失败后,会不会把父协程和兄弟协程一起带挂。
普通 Job
- 默认父子关系
- 任一子协程失败,会向上取消父协程
- 父协程再取消其他兄弟子协程
- 特点是“一损俱损”
例如:
coroutineScope {
launch { task1() }
launch { error("task2 failed") }
}
这里 task2 失败后,整个 coroutineScope 会失败,task1 也会被取消。
SupervisorJob
- 是一种“监督型父 Job”
- 子协程失败,不会自动把父 scope 取消
- 其他兄弟协程可以继续执行
- 适合多个并行任务彼此独立的场景
例如:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch { error("task1 failed") }
scope.launch { loadBanner() } // 仍可继续
这里第一个子协程失败,不会影响第二个子协程。
supervisorScope
- 是一个 suspend 作用域构建器
- 作用和 SupervisorJob 类似,都是隔离子协程失败
- 但它更适合在一次 suspend 调用链内部临时创建“监督作用域”
例如:
suspend fun loadPage() = supervisorScope {
launch { loadCardA() }
launch { loadCardB() }
}
这里 loadCardA() 失败,不会自动取消 loadCardB()。
它们的使用场景区别:
- Job() / 普通 coroutineScope {}:希望失败能整体收敛,适合强一致任务
- SupervisorJob():适合构建长期存在的监督型 scope,比如 ViewModel 内部某些独立任务组
- supervisorScope {}:适合在某个 suspend 函数里临时做“局部失败隔离”
一个表格记忆:
普通 Job / coroutineScope |
SupervisorJob / supervisorScope |
|
|---|---|---|
| 子失败是否取消父 | 会 | 不会 |
| 子失败是否影响兄弟 | 会 | 不会自动影响 |
| 父取消是否影响子 | 会 | 会 |
| 适合场景 | 强依赖、整体成败一致 | 并行模块相互独立 |
可以这样理解:
- 普通 Job:团队里一个人出问题,整个小组停工
- SupervisorJob:组长知道有人失败了,但不会让其他人一起停工
面试里一句话总结:
普通 Job 是默认父子失败联动;SupervisorJob 和 supervisorScope 会隔离子协程失败,不让一个子任务把整个作用域和其他兄弟任务一起拖垮,但父作用域如果主动取消,子协程仍然都会被取消。
supervisorScope 里子协程异常后,外层函数会不会失败结论先说:supervisorScope 只负责“失败隔离”,不负责“自动吞异常”。
也就是说:
- 子协程失败,不会自动取消其他兄弟协程
- 但这个异常如果最终没人处理,外层函数仍然可能失败
所以 supervisorScope 解决的是“不要连坐”,不是“不要报错”。
情况一:launch 子协程抛异常
suspend fun load() = supervisorScope {
launch {
error("taskA failed")
}
launch {
loadB()
}
}
这里 taskA 失败后:
- loadB() 不会被自动取消
- 但 taskA 的异常本身依然存在
- 如果没有处理,它仍然会进入异常处理链
情况二:async 子协程抛异常
suspend fun load() = supervisorScope {
val a = async { error("taskA failed") }
val b = async { loadB() }
b.await()
a.await()
}
这里 a 的失败不会自动取消 b,但当执行 a.await() 时,异常还是会抛出来,外层函数最终仍然会失败。
所以 supervisorScope 的本质是:
- 保住兄弟协程继续执行
- 但不替你消费异常
try-catch当你的业务目标是“允许局部失败,但整体流程继续”时,就需要手动处理这个子任务的异常。
典型场景:
- 首页多个卡片并行加载,一个卡片失败不影响整页
- 聚合多个接口,一个接口失败时降级展示默认值
- 某个埋点、预加载、推荐流失败,只记日志,不中断主流程
例如在子协程内部处理:
suspend fun load() = supervisorScope {
launch {
try {
loadCardA()
} catch (e: Exception) {
log(e)
}
}
launch {
loadCardB()
}
}
或者对 async 在 await() 时处理:
suspend fun load() = supervisorScope {
val a = async { loadCardA() }
val b = async { loadCardB() }
val resultA = try {
a.await()
} catch (e: Exception) {
null
}
val resultB = b.await()
}
如果你不想让外层函数失败,就要把这个异常:
- 在子协程内部 try-catch
- 或者在 await() 时显式处理
- 或者转成 Result / null / 默认值
可以直接这样答:
supervisorScope 只能隔离子协程失败,不会让一个子任务失败把其他兄弟协程一起取消,但异常本身并不会自动消失。如果这个异常最终没有被消费,外层函数还是可能失败。所以当业务上允许局部失败、整体继续时,需要在子协程内部手动 try-catch,或者在 await 时把异常转成可控结果。
try-catch 在协程里能不能用能用,而且是处理业务异常最直接的方式。
例如:
viewModelScope.launch {
try {
val data = repository.loadData()
render(data)
} catch (e: Exception) {
showError(e)
}
}
这里的 try-catch 和普通 Kotlin 代码本质一样,只要异常是在当前协程这条执行链里抛出来的,就能接住。
比如:
- suspend 函数内部抛出的异常
- withContext 块里的异常
- await() 时抛出的异常
try-catch 的区别try-catch 是局部、同步语义上的异常捕获工具,而协程异常处理还多了两层:
- 异步边界:异常可能在另一个协程里发生
- 结构化并发边界:异常会沿 Job 树传播和取消
这就是为什么下面这种写法经常接不住:
try {
viewModelScope.launch {
throw RuntimeException("error")
}
} catch (e: Exception) {
// 接不到
}
原因是:
- 外层 try-catch 只包住了“启动协程”这一步
- 真正抛异常发生在新协程里
- 它已经不在当前同步调用栈中了
所以这个例子和普通代码最大的区别就是:协程启动本身是同步的,但协程体执行是异步的。
try-catch 的位置原则是:谁的协程里可能抛异常,就在谁的协程执行体内部捕获。
正确示例:
viewModelScope.launch {
try {
request()
} catch (e: Exception) {
handleError(e)
}
}
如果是 async:
viewModelScope.launch {
try {
val result = async { request() }.await()
render(result)
} catch (e: Exception) {
handleError(e)
}
}
CoroutineExceptionHandler 是干什么的CoroutineExceptionHandler 更像兜底的顶层异常处理器,不是替代 try-catch 的业务异常处理工具。
例如:
val handler = CoroutineExceptionHandler { _, throwable ->
logError(throwable)
}
scope.launch(handler) {
throw RuntimeException("error")
}
它更适合做:
- 统一日志上报
- 崩溃监控
- 顶层兜底
不适合做:
- 普通业务分支处理
- 替代页面状态更新
- 替代局部 try-catch
因为它拿到异常时,往往已经到了“这个协程失败了”的阶段。
try-catch 和 CoroutineExceptionHandler 怎么对比try-catch |
CoroutineExceptionHandler |
|
|---|---|---|
| 处理粒度 | 局部代码块 | 顶层协程兜底 |
| 适合场景 | 业务异常处理、状态回传 | 日志、监控、统一上报 |
| 是否能恢复流程 | 可以,捕获后继续走分支 | 通常不用于恢复业务流程 |
| 是否能跨协程接异常 | 不能 | 只处理命中它的顶层未捕获异常 |
一句话理解:
- try-catch 是“我知道这里可能失败,我要自己处理”
- CoroutineExceptionHandler 是“这类未捕获异常最后统一交给我收口”
CoroutineExceptionHandler 不生效这是面试高频坑点。
对 async 来说,CoroutineExceptionHandler 往往不是你第一反应看到异常的地方。
因为 async 会把异常存进 Deferred,等 await() 时再抛。
也就是说,async 的异常通常应由:
- await() 外层的 try-catch 处理
而不是单纯依赖 CoroutineExceptionHandler。
协程取消时抛的是 CancellationException,它的语义不是“任务失败”,而是“任务被正常取消”。
所以一般要注意两件事:
- 不要把它当普通错误提示给用户
- 不要在宽泛的 catch (Exception) 里把它吞掉
推荐写法:
try {
doWork()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError(e)
}
如果把取消异常吞掉,可能会导致:
- 协程无法及时结束
- 结构化并发的取消链条失效
- 资源释放时机混乱
可以直接这么答:
try-catch 当然还能用,而且是处理协程内部业务异常最常用的方式。但协程异常比普通代码多了一层异步边界和父子协程传播关系。比如 launch 的异常会立刻向父协程传播,async 的异常会在 await() 时抛出;外层 try-catch 不能捕获另一个新协程里的异常。CoroutineExceptionHandler 更适合做顶层兜底和日志上报,不是替代业务里的 try-catch。另外 CancellationException 要单独处理,不能随便吞掉。
CoroutineScope 本体几乎没什么复杂逻辑,它只是一个带 coroutineContext 的接口。
真正关键的是它里面装的几个上下文元素,尤其是:
- Job
- CoroutineDispatcher
所以面试里可以直接说:CoroutineScope 本身不负责调度,它只是把协程组织起来;真正决定线程切换的是 Dispatcher,真正决定生命周期的是 Job。
以 launch 为例,流程可以理解为:
scope.coroutineContext 取出上下文StandaloneCoroutineJobJob 会挂到父 Job 下,形成树状结构Dispatcher 决定是立即执行还是投递到对应线程池也就是说,CoroutineScope 提供的是“组织关系”,不是“执行能力”。
结构化并发要求:子任务不能脱离父任务独立失控地存在。
而 CoroutineScope 正是这个约束的入口,因为所有正规创建的协程都应该从某个 scope 发起:
suspend fun load() = coroutineScope {
launch { task1() }
launch { task2() }
}
这里两个子协程都属于 coroutineScope 创建出来的作用域:
- 任一子协程失败,整个 scope 感知并取消其他子协程
- scope 不会提前结束,会等所有子协程结束
所以 scope 的底层意义是:把并发任务收口到一个明确的生命周期边界里。
viewModelScope
- 适合界面数据请求、状态流转
- ViewModel.onCleared() 时自动取消
- 避免页面重建后旧请求继续持有 ViewModel
lifecycleScope
- 适合和 Activity/Fragment 生命周期直接绑定的任务
- 页面销毁时自动取消
rememberCoroutineScope(Compose)
- 适合由组合节点触发的事件协程,比如点击事件、动画、Snackbar
- 组合离开时自动取消
GlobalScope
- 不推荐业务里直接使用
- 生命周期几乎等于进程级,容易造成任务失控、异常难收敛、资源泄漏
面试里如果被问“为什么不推荐 GlobalScope”,可以直接答:
因为它破坏了结构化并发,协程脱离了页面、ViewModel、调用链的生命周期管理。
Android 里优先选这些现成 scope:
- UI 状态和数据请求用 viewModelScope
- 页面级任务用 lifecycleScope
- Compose 事件协程用 rememberCoroutineScope
这样做的好处是生命周期天然对齐,取消时机明确,不需要自己维护 Job。
比如下面这种写法通常不推荐:
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { fetchData() }
问题在于:
- 没有明确宿主,生命周期不好管理
- 容易忘记取消
- 异常边界不清晰
如果确实需要自定义 scope,至少要显式带上 SupervisorJob() 或 Job(),并且明确谁负责 cancel():
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun clear() {
scope.cancel()
}
coroutineScope,不要额外起野生协程如果当前已经在 suspend 函数里,优先这样写:
suspend fun load() = coroutineScope {
val user = async { api.loadUser() }
val profile = async { api.loadProfile() }
merge(user.await(), profile.await())
}
这样所有子任务都收敛在当前调用链里:
- 调用方取消,内部子任务自动取消
- 子任务异常会回传给调用方
- 不会出现函数返回了但内部任务还在偷偷跑
最佳实践不是把所有代码都写在 viewModelScope.launch 里,而是:
- UI 层负责触发
- Repository / UseCase 提供 suspend API
- 线程切换在合适的位置通过 withContext 完成
例如:
viewModelScope.launch {
val data = repository.loadData()
_uiState.value = Success(data)
}
而不是把网络、磁盘、转换逻辑全部堆在 ViewModel 里。
withContext 负责切线程,launch 负责开任务,不要混用职责常见原则:
- 需要结果,用 withContext
- 只想并发启动任务,用 launch / async
不要为了切线程额外 launch(Dispatchers.IO),否则容易:
- 丢失结构化结果返回
- 异常处理分散
- 调用方无法自然等待完成
更推荐:
suspend fun query() = withContext(Dispatchers.IO) {
dao.query()
}
GlobalScopeGlobalScope 最大的问题不是“能不能跑”,而是“跑出去以后谁负责收尾”。
它通常会带来:
- 页面退出后任务还在执行
- 异常难集中处理
- 测试时行为不可控
只有极少数和进程同生命周期的任务才可能考虑它,比如非常底层的全局守护逻辑,但业务层一般都不应该直接使用。
默认情况下,父子协程是一损俱损:
- 一个子协程失败,父 scope 会取消其他子协程
如果场景是“局部失败不影响整体”,要显式使用:
- SupervisorJob()
- supervisorScope {}
比如并行加载多个卡片模块时,一个模块失败不应该让整个页面全部失败,这时就适合监督关系。
协程取消本质上依赖 CancellationException 传播,所以要注意:
- 不要随便 catch (Exception) 后把取消也吞掉
- 长任务中要检查 isActive
- 确实要兜底时,CancellationException 要继续抛出
例如:
try {
doWork()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(e)
}
一个 scope 最好服务于一个明确生命周期边界。
比如:
- viewModelScope 管页面状态
- 应用级 scope 管进程级任务
不要把页面请求、全局上报、长期轮询全部塞进同一个 scope,否则取消策略和异常策略会互相污染。
coroutineScope,不要乱开脱离调用方的协程withContext,并发任务用 launch/asyncSupervisorJobGlobalScope,要让取消、异常、回收路径都清晰本质:suspend 函数返回 COROUTINE_SUSPENDED,线程退出;外部事件调用 continuation.resumeWith(),状态机从上次的 label 继续执行。
以 delay(1000) 为例:
label 记录执行位置delay 时传入当前 Continuationdelay 把 Continuation 包装成 Runnable 扔给延迟队列,返回 COROUTINE_SUSPENDEDreturn,线程释放continuation.resumeWith(),状态机从 label=1 继续挂起不阻塞线程,阻塞才阻塞线程,这是协程和 Thread.sleep 的本质区别。
本质:挂起当前协程 + 把任务 dispatch 到新线程池 + 完成后 dispatch 回原调度器恢复。
三步流程:
1. withContext 是 suspend 函数,调用后挂起当前协程,当前线程释放
2. 创建子协程,调用新 Dispatcher.dispatch() 把任务 post 到目标线程池执行
3. 子协程执行完毕,用原来的 Dispatcher dispatch 恢复操作,切回原线程继续
Dispatcher 切线程的底层:
- Dispatchers.Main → Handler(Looper.getMainLooper()).post(block)
- Dispatchers.IO/Default → executor.execute(block)
"切线程"本质就是把 Continuation 的恢复操作作为 Runnable post 到目标线程。
与 launch 的区别:
withContext |
launch |
|
|---|---|---|
| 返回值 | 有,可以拿结果 | 无(返回 Job) |
| 执行方式 | 串行,等待完成 | 并行,新建子协程 |
| 执行完后 | 自动切回原调度器 | 不切回 |
协程不等于线程安全,并发问题依然存在。
什么情况下有问题: 多个协程在 Dispatchers.IO/Default 上并发修改共享可变状态时。
var count = 0
repeat(1000) {
launch(Dispatchers.Default) { count++ } // 非原子,结果不一定是 1000
}
StateFlow 的坑: .value 单次读写线程安全,但"读-改-写"组合非原子,多协程下仍需 Mutex。
一、原子操作 — 适合简单计数/标志位,无锁性能最好
val count = AtomicInteger(0)
count.incrementAndGet()
二、锁
- synchronized / ReentrantLock — 阻塞线程,协程里慎用
- Mutex — 协程专用,挂起等待,不阻塞线程(推荐)
- ReadWriteLock — 读多写少场景
三、限制单线程访问 — 从根本上消除竞态
val single = newSingleThreadContext("state")
launch(single) { count++ }
四、Channel — 通过通信共享数据,消费者单协程串行处理
五、不可变数据 — data class + copy(),配合 StateFlow 使用
六、线程安全集合 — ConcurrentHashMap、CopyOnWriteArrayList 等
选型总结:
| 场景 | 推荐方案 |
|---|---|
| 简单计数/标志位 | AtomicXxx |
| 通用共享状态(协程) | Mutex |
| 读多写少 | ReadWriteLock |
| 状态集中管理 | 单线程 / 主线程 |
| 生产消费解耦 | Channel |
| 复杂共享集合 | ConcurrentHashMap 等 |
| 机制 | 能用吗 | 问题 |
|---|---|---|
synchronized |
能 | 阻塞线程;块内不能调用 suspend 函数(编译报错) |
ReentrantLock |
能 | 阻塞线程 |
volatile |
能 | 只保证可见性,不保证原子性 |
AtomicXxx |
推荐 | 适合简单原子操作 |
Mutex |
推荐 | 挂起等锁,协程友好 |
关键坑: synchronized 块内不能调用 suspend 函数,因为协程挂起后可能在另一个线程恢复,而 synchronized 锁是线程绑定的,编译器直接禁止。
核心数据结构: 内部维护一个原子状态值 _state(UNLOCKED / LOCKED)+ 等待队列(链表)
加锁过程:
1. CAS 原子尝试把 UNLOCKED 换成 LOCKED,成功直接返回
2. 失败则把当前协程的 Continuation 加入等待队列,挂起协程,线程释放
解锁过程:
1. 队列为空 → CAS 改回 UNLOCKED
2. 队列不为空 → 取出队头 Continuation,直接 resume(锁的所有权直接移交,FIFO 公平)
与 synchronized 的本质区别:
synchronized |
Mutex |
|
|---|---|---|
| 等待方式 | 阻塞线程(OS 级别) | 挂起协程(用户态) |
| 等待期间 | 线程被占用 | 线程释放,可跑其他协程 |
| 底层 | JVM monitorenter/monitorexit | CAS + 协程等待队列 |
| suspend 块内 | 不能用 | 可以用 |
基本模式:async + awaitAll
suspend fun fetchAll() = coroutineScope {
val t1 = async { fetchFromNetwork() }
val t2 = async { fetchFromDisk() }
awaitAll(t1, t2)
}
总耗时取决于最慢的任务,不是相加。
任务量不固定:
ids.map { async { fetchItem(it) } }.awaitAll()
限制并发数:Semaphore
val semaphore = Semaphore(5)
ids.map { async { semaphore.withPermit { fetchItem(it) } } }.awaitAll()
流式处理:Flow + flatMapMerge
ids.asFlow()
.flatMapMerge(concurrency = 5) { flow { emit(fetchItem(it)) } }
.collect { process(it) }
核心约束: 协程必须在一个 scope 内启动,生命周期不能超过这个 scope。
三个关键保证:
1. 父取消,子全取消 — 不会有游离协程
2. 子失败,父感知 — 异常不会静默丢失,自动向上传播
3. 父等所有子完成才结束 — coroutineScope {} 块结束时所有子任务已完成
Android 里的体现:
- viewModelScope — 绑定 ViewModel 生命周期,ViewModel clear 时自动取消
- lifecycleScope — 绑定 Activity/Fragment 生命周期
与 RxJava 对比:
| RxJava | 协程结构化并发 | |
|---|---|---|
| 生命周期管理 | 手动 CompositeDisposable |
自动,scope 对齐 |
| 异常处理 | 需每个流单独处理 | 自动向上传播 |
| 泄漏风险 | 忘记 dispose 就泄漏 | scope 结束自动取消 |
| 方法 | 返回值 | 异常时机 | 适合场景 |
|---|---|---|---|
launch |
Job |
立即传播到父 | 触发任务,不关心结果 |
async |
Deferred<T> |
await() 时抛出 |
并行任务,需要结果 |
runBlocking |
阻塞线程 | 立即抛出 | 测试 / main 函数 |
补充:scope 构建器
- coroutineScope — 任一子协程失败,全部取消
- supervisorScope — 子协程失败互不影响
一句话:有返回值用 async,没返回值用 launch,测试用 runBlocking。
可以用这套表达:
第一层,定义:
CoroutineScope 是协程的作用域,本质是持有 CoroutineContext 的容器,不负责执行任务,本身也不是线程池。
第二层,作用:
它主要解决三个问题:
- 统一协程上下文
- 建立父子 Job 关系
- 管理生命周期,保证结构化并发
第三层,底层原理:
launch/async 启动协程时,会从 scope 里拿到父 Job 和 Dispatcher,创建子协程并挂到父 Job 下,所以取消、异常传播、线程调度都能统一管理。
第四层,Android 实战:
在 Android 里通常用 viewModelScope、lifecycleScope,避免协程跑飞;一般不建议直接用 GlobalScope,因为它脱离生命周期,容易泄漏和失控。