------ 从 Job、Deferred 到结构化并发,彻底讲透 Kotlin 协程三大启动方式的设计思想
前面三篇
Kotlin 协程设计思想(一):CoroutineContext 到底是什么?为什么 Job 和 Dispatcher 可以直接相加?-CSDN博客
Kotlin 协程设计思想(二):Job 到底是什么?为什么协程能被取消?-CSDN博客
Kotlin 协程设计思想(三):Dispatchers 到底是什么?切线程真的只是切线程吗?-CSDN博客
我们已经讲了:
CoroutineContext:协程运行环境
Job:协程生命周期管理器
Dispatcher:协程调度策略
到这里,我们终于可以回头看一个高频问题:
launch、async、withContext 到底有什么区别?
很多教程会告诉你:
launch 没有返回值
async 有返回值
withContext 用来切线程
这当然没错。但这只是最表层的理解。
真正要理解它们,必须回到协程的设计思想:
launch 代表启动一个任务
async 代表启动一个带结果的任务
withContext 代表在当前协程中切换运行环境
这一篇,我们就从 Job、Deferred、结构化并发三个角度,彻底讲透它们。
一、先看最常见的 launch
Kotlin
viewModelScope.launch {
login()
}
launch 的作用是:启动一个新的协程任务
它返回的是:Job
例如:
Kotlin
val job = viewModelScope.launch {
delay(3000)
println("任务完成")
}
你可以:job.cancel()
也可以:job.join() //(等待这个协程执行完成)
所以:
launch 的核心不是返回结果
而是管理任务生命周期
一句话:
launch 适合"只关心执行,不关心结果"的任务
例如:
提交日志
发送埋点
刷新页面
启动一个监听
二、async 是什么?
再看:
Kotlin
val deferred = viewModelScope.async {
getUserInfo()
}
async 也会启动一个新的协程。
但它返回的不是普通 Job,
而是:
Deferred<T>
例如:
Kotlin
val userDeferred = async {
getUserInfo()
}
val user = userDeferred.await()
await() 可以拿到结果。
所以:
Deferred = 带结果的 Job
它既能取消:
deferred.cancel()
也能等待结果:
val result = deferred.await()
所以:
async 适合"并发执行,并且需要结果"的任务
三、Job 和 Deferred 的关系
可以这样理解:
Job
只表示一个任务
Deferred
表示一个有结果的任务
关系类似:
Job -> 任务
Deferred<T> -> 任务 + 结果
所以:
launch { }
返回:
Job
而:
async { }
返回:
Deferred<T>
这就是它们最核心的区别。
四、async 最经典的场景:并发请求
例如:
Kotlin
viewModelScope.launch {
val userDeferred = async {
api.getUser()
}
val orderDeferred = async {
api.getOrders()
}
val user = userDeferred.await()
val orders = orderDeferred.await()
updateUI(user, orders)
}
这里两个请求是:
并发执行
而不是:
一个执行完再执行另一个
如果每个请求 1 秒,
串行需要:
2 秒
并发大约:
1 秒
这就是 async 的价值。
五、那 withContext 呢?
很多人这样写:
Kotlin
val user = withContext(Dispatchers.IO) {
api.getUser()
}
然后理解成:
切到 IO 线程
没错,
但不完整。
withContext 的本质是:
在当前协程中切换 CoroutineContext
它不会像 launch、async 那样开启一个并列的新任务。
它是:
挂起当前协程
↓
切换 Context 执行代码块
↓
执行完恢复回来
所以:
viewModelScope.launch {
val user = withContext(Dispatchers.IO) {
api.getUser()
}
updateUI(user)
}
执行顺序是:
进入 launch
↓
切到 IO 执行 getUser
↓
拿到 user
↓
回到原协程继续 updateUI
一句话:
withContext 适合"当前流程中某一段代码需要切换运行环境"
六、launch 和 withContext 最大区别
看起来都能切线程:
launch(Dispatchers.IO) {
api.getUser()
}
withContext(Dispatchers.IO) {
api.getUser()
}
但它们完全不同。
launch
launch(Dispatchers.IO) {
api.getUser()
}
含义:
启动一个新的协程
调用后:
不会等待它执行完
除非你手动:
job.join()
withContext
withContext(Dispatchers.IO) {
api.getUser()
}
含义:
切换当前协程的运行环境
调用后:
会等待代码块执行完
然后继续往下走。
所以可以记:
launch:开新任务
withContext:切换当前任务的执行环境
七、async 和 withContext 都能返回结果,区别是什么?
例如:
Kotlin
val user = withContext(Dispatchers.IO) {
api.getUser()
}
也能返回结果。
Kotlin
val user = async {
api.getUser()
}.await()
也能返回结果。
那区别是什么?
关键在于:
async 是并发模型
withContext 是顺序模型
withContext
val user = withContext(Dispatchers.IO) {
api.getUser()
}
val orders = withContext(Dispatchers.IO) {
api.getOrders()
}
执行顺序:
先 getUser
再 getOrders
这是串行。
async
val userDeferred = async {
api.getUser()
}
val ordersDeferred = async {
api.getOrders()
}
val user = userDeferred.await()
val orders = ordersDeferred.await()
执行顺序:
getUser 和 getOrders 同时开始
这是并发。
所以:
withContext:我要切线程并等待结果
async:我要并发执行并等待结果
八、为什么 async 不建议单独使用?
很多人会写:
val result = async {
api.getUser()
}.await()
这通常没有意义。
因为:
启动 async
立刻 await
等价于:
并没有形成并发
这时候更推荐:
val result = withContext(Dispatchers.IO) {
api.getUser()
}
所以:
async 的价值在于并发
不是单纯返回结果
九、异常处理有什么不同?
这是很多人踩坑的地方。
launch 的异常
viewModelScope.launch {
throw RuntimeException("error")
}
launch 中未捕获异常会直接抛给父协程。
如果父协程没有处理,可能导致整个作用域取消。
async 的异常
val deferred = async {
throw RuntimeException("error")
}
async 的异常会先保存在 Deferred 里。
直到你调用:
deferred.await()
异常才会重新抛出来。
例如:
Kotlin
try {
val result = deferred.await()
} catch (e: Exception) {
e.printStackTrace()
}
所以:
async 的异常通常在 await 时暴露
十、结构化并发下的 async
很多人以为:async
就是随便开一个后台任务。
不是。
在结构化并发里:
viewModelScope.launch {
val user = async {
api.getUser()
}
val orders = async {
api.getOrders()
}
updateUI(user.await(), orders.await())
}
这两个 async 都是:launch 的子协程
结构:
Parent launch
│
├── async user
└── async orders
如果 parent 被取消,两个 async 也会被取消。
这就是:
结构化并发
十一、coroutineScope 和 async 的经典组合
如果你在 suspend 函数里想并发请求,通常可以这样写:
suspend fun loadHomeData(): HomeData = coroutineScope {
val userDeferred = async {
api.getUser()
}
val bannerDeferred = async {
api.getBanner()
}
val user = userDeferred.await()
val banner = bannerDeferred.await()
HomeData(user, banner)
}
为什么要用:
coroutineScope
因为它提供一个父作用域。
确保:
所有子协程完成后
整个函数才返回
如果其中一个失败:
其它子协程也会被取消
这就是结构化并发。
十二、supervisorScope 又是什么?
如果你希望:
一个请求失败,不影响其它请求
可以用:
Kotlin
supervisorScope {
val userDeferred = async {
api.getUser()
}
val bannerDeferred = async {
api.getBanner()
}
val user = runCatching {
userDeferred.await()
}.getOrNull()
val banner = runCatching {
bannerDeferred.await()
}.getOrNull()
HomeData(user, banner)
}
区别是:
coroutineScope:一个子协程失败,整个作用域失败
supervisorScope:一个子协程失败,不影响其它子协程
这就和前面讲的:
Job
SupervisorJob
完全串起来了。
十三、三者怎么选?
可以这样记。
launch
用于:
我只想启动一个任务
不需要返回结果
例如:
viewModelScope.launch {
refresh()
}
async
用于:
我想并发执行多个任务
并且需要它们的结果
例如:
val user = async { api.getUser() }
val orders = async { api.getOrders() }
combine(user.await(), orders.await())
withContext
用于:
我在当前流程中
需要切换 Dispatcher
并拿到结果
例如:
val user = withContext(Dispatchers.IO) {
api.getUser()
}
十四、最终总结
如果让我一句话解释:
launch
我会说:
启动一个不关心返回值的协程任务。
如果让我解释:
async
我会说:
启动一个带返回值、适合并发组合的协程任务。
如果让我解释:
withContext
我会说:
在当前协程中切换运行环境,并等待结果返回。
它们背后的关系是:
launch -> Job
async -> Deferred<T>
withContext -> 切换 CoroutineContext
所以:
launch 解决任务启动
async 解决并发结果
withContext 解决上下文切换
真正理解这三者,就不是背 API 区别, 而是理解 Kotlin 协程的任务模型。
下篇预告
到这里,我们已经讲完了:
CoroutineContext
Job
Dispatcher
launch / async / withContext
但协程里还有一个最容易让人崩溃的问题:
异常处理。
为什么:
try-catch
有时候能捕获,有时候不能?
为什么:
CoroutineExceptionHandler
有时候生效,有时候不生效?
为什么:
async
的异常非要等到:
await()
才暴露?
下一篇我们继续:
《Kotlin 协程设计思想(五):协程异常为什么这么难理解?》
从 launch、async、SupervisorJob 到 CoroutineExceptionHandler,
彻底讲透 Kotlin 协程异常传播机制。