很多 Android 开发者刚接触 Kotlin 协程时,会有这样的认知:
协程是不是就是一个更轻量的线程?
于是很多文章会强调:
- 协程比线程轻量;
- 一个线程可以运行很多协程;
- 协程切换成本低。 之前写过一篇
如果你没看过,可以先看下。
下面通过一个朋友圈发布场景来看:
一个典型业务:发布朋友圈
假设用户选择多张图片:
流程:
markdown
选择图片
↓
并发压缩图片
↓
并发上传服务器
↓
提交朋友圈数据
↓
发布成功
这是一个非常典型的异步任务链。
如果使用传统线程池:
你需要自己管理:
- 任务提交;
- 任务完成计数;
- 阶段切换;
- 线程安全;
- 生命周期;
- 异常处理。
如果使用协程:
业务流程可以直接表达出来。
一、传统 ThreadPool
压缩10张图片
↓
全部完成
↓
上传10张图片
↓
全部完成
↓
提交朋友圈
问题就出现了。
kotlin
[UI 线程] postMoment(uris)
│
├─> Executor.execute (分发 9 个压缩任务)
│ │
│ ├─ [线程 A] 压缩图片1 -> synchronized { 存入 List } -> count++ -> count==9? (No)
│ ├─ [线程 B] 压缩图片2 -> synchronized { 存入 List } -> count++ -> count==9? (No)
│ └─ [线程 N] 压缩图片9 -> synchronized { 存入 List } -> count++ -> count==9? (Yes! 触发回调)
│ │
│ ┌────────────────────────────────── onCompressionFinished() <────┘
│ │
│ ├─> Executor.execute (分发 N 个上传任务)
│ │ ├─ [线程 X] 上传图片1 -> synchronized { 存入 URL } -> count++ -> count==all? (No)
│ │ └─ [线程 Z] 上传图片N -> synchronized { 存入 URL } -> count++ -> count==all? (Yes! 触发)
│ │ │
│ └─> ┌────────────────────────────── onUploadFinished() <───────────────┘
│ │
│ └─> Executor.execute { repository.finalizePostSync() }
│
[UI 线程] 更新成功 UI 状态 (需通过 Handler 或 Flow.update)
代码的表达不是线性的,而是撕碎的
1. 需要自己记录任务完成数量
例如:
kotlin
val count = AtomicInteger(0)
val current = count.incrementAndGet()
if(current == total){
startUpload()
}
因为线程不知道:
什么时候所有任务完成。
所以开发者必须JUC 同步(这里可以不用AtomicInteger,但是JUC 同步肯定少不了)。
2. 需要处理共享数据
例如:
多个线程同时添加压缩结果:
kotlin
val images = mutableListOf<ByteArray>()
必须:
kotlin
synchronized(images){
images.add(data)
}
否则:
可能发生:
csharp
线程1 add
线程2 add
数据竞争
3. 生命周期管理复杂
Android 最大的问题:
页面退出了。
但是线程还在:
markdown
Activity destroy
↓
Thread继续上传
↓
内存泄漏
所以需要:
kotlin
executor.shutdown()
甚至:
kotlin
shutdownNow()
再配合:
interrupt检查
二、Coroutine:把异步重新写回同步代码
协程版本:
kotlin
[UI 线程] postMoment(uris)
│
├─> launch (开启协程)
│ │
│ ├─> performCompression (并发压缩) ──┐
│ │ ├─ async (图片1) -> 挂起等待 │
│ │ ├─ async (图片2) -> 挂起等待 ├─> Dispatchers.Default (并发执行)
│ │ └─ awaitAll() <─── 全部完成回调 ──┘
│ │
│ ├─> performUpload (并发上传) ──────┐
│ │ ├─ async (图片1) -> 挂起等待 │
│ │ ├─ async (图片2) -> 挂起等待 ├─> Dispatchers.IO (并发执行)
│ │ └─ awaitAll() <─── 全部完成汇总 ──┘
│ │
│ └─> finalizePost (最终发布) ───────> Dispatchers.IO (单次执行)
│
[UI 线程] 更新成功 UI 状态
代码阅读方式:
压缩
↓
上传
↓
提交
和业务流程完全一致。
但是内部:
依然是异步执行。
三、async + awaitAll:替代计数器等待
以前:
等待10张图片:
需要:
kotlin
AtomicInteger
+
if(count==total)
现在:
kotlin
val result =
images.map {
async {
compress(it)
}
}.awaitAll()
含义:
创建多个任务:
Task1
Task2
Task3
Task4
然后:
scss
awaitAll()
等待全部完成
开发者不需要知道:
哪个线程完成。
也不需要维护:
完成数量。
四、Structured Concurrency:协程最大的杀手锏
协程最重要的概念:
不是轻量。
而是:
结构化并发。
什么叫结构化并发?
任务拥有父子关系。
例如:
lua
ViewModel
|
launch
|
+--压缩任务1
+--压缩任务2
+--上传任务1
+--上传任务2
当 ViewModel 销毁:
kotlin
viewModelScope.cancel()
结果:
父任务取消
↓
所有子任务取消
不会留下后台任务。
传统线程:
markdown
ViewModel
|
ThreadPool
|
Thread
线程之间没有天然关系。
生命周期:
需要人工维护。
五、协程并没有让任务执行更快
这里容易产生误解。
比如:
压缩图片:
800ms
上传:
1200ms
协程不会:
800ms → 400ms
因为:
真正耗时的是:
- CPU计算;
- 网络IO。
协程提升的是:
开发效率和系统可控性。
它减少的是:
arduino
AtomicInteger
synchronized
Callback
Future
CountDownLatch
线程生命周期管理
六、协程真正替代的是什么?
很多人说:
协程替代线程。
其实不准确。
线程依然存在。
协程真正替代的是:
过去为了组织异步流程而产生的大量复杂机制:
arduino
Callback
↓
Future
↓
CountDownLatch
↓
AtomicInteger
↓
synchronized
↓
状态机代码
最后
线程池解决的问题:
任务在哪里执行?
例如:
哪个线程执行上传?
协程解决的问题:
多个异步任务如何组合?
例如:
压缩完成后上传
上传完成后发布
页面退出自动取消
所以:
线程是执行资源,协程是异步流程的组织模型。
Kotlin 协程真正改变 Android 开发的地方,不是让线程消失,而是让开发者不用再JUC 同步状态。
以前:
diff
Callback
+
线程池
+
锁
+
计数器
+
生命周期管理
现在:
diff
suspend
+
async
+
await
+
Structured Concurrency
这才是协程带来的架构级变化。
源码: