几代异步方案并存不是问题,没有所有者才是问题
前言
打开一个活了五年的 Android 工程,经常能在同一个文件里看到 Handler、TimerTask、线程池、RxJava、协程。第一反应通常是"这代码太脏了",然后想找个时间统一技术栈。
但脏不是问题的本质。真正让人吃亏的,是这些方案各自管各自的取消、各自管各自的异常 :Handler 用 removeCallbacks,线程池用 shutdownNow,RxJava 用 Disposable,协程用 Job.cancel。只要漏掉一条,就会出现"服务已经停了,任务还在跑"的状态。这跟用了几代技术无关,跟"谁负责收尾"有关。
我维护的这套测绘采集工程里,一个 400 行的后台同步 Service 同时躺着五代异步方案。这篇分两半:前半讲共存本身------它是怎么来的、代价是什么、不重写怎么治理;后半讲其中危险的一处,GlobalScope。两部分共用一条主线:几代方案共存完全没问题,问题是资源的所有者必须唯一。
一、一个文件里的"五世同堂"
1.1 现场
这个工程里有一个后台同步服务(弱网环境下上传测量数据),单文件 400 行,里面同时出现了:
| 代际 | 出现形式 | 作用 |
|---|---|---|
第一代 Thread / Handler |
Handler(Looper.getMainLooper()) |
主线程消息、弹窗 |
第二代 Executor + TimerTask |
Executors.newScheduledThreadPool(1) + scheduleWithFixedDelay |
60 秒轮询 |
| 第三代 回调式网络库 | Retrofit Call.enqueue(Callback) |
网络请求 |
| 第四代 RxJava 2 | Observable + Disposable(散落在各业务模块) |
数据流订阅 |
| 第五代 协程 | GlobalScope.launch(Dispatchers.IO) + async/await |
批量上传编排 |
kotlin
// 同一个 Service 里的片段(示意,已脱敏)
class SyncService : Service() {
private var selectService = Executors.newScheduledThreadPool(1) // 第二代
private val timerTask = object : TimerTask() { // 第二代
override fun run() {
if (isNetworkConnected()) selectResult()
if (System.currentTimeMillis() - startTime >= 4 * 3600 * 1000) {
stopRecord()
}
}
}
fun start() {
selectService?.scheduleWithFixedDelay(timerTask, 1000, 60_000, TimeUnit.MILLISECONDS)
}
private fun upload(list: List<Item>) {
job = GlobalScope.launch(Dispatchers.IO) { // 第五代
val online = async { isNetOnline() }
if (online.await()) {
for (item in list) {
val r = async { httpManager.upload(item) } // 混用第三代回调
if (r.await()) { /* ... */ }
}
}
withContext(Dispatchers.Main) {
showSuccessDialog() // 第一代语义
}
}
}
}
这不是段子,是大多数活过五年的 Android 工程的真实现状。
1.2 为什么会变成这样
每一代技术的引入都有当时的合理性:
| 时期 | 引入原因 | 遗留原因 |
|---|---|---|
早期 Thread + Handler |
只有这个能用 | 能跑,没人敢动 |
AsyncTask |
官方推荐(当年) | 已被废弃,但老代码还在 |
Executor / TimerTask |
需要定时、需要线程复用 | 定时器逻辑稳定,改了要重测 |
| RxJava | 需要复杂的数据流编排、线程切换优雅 | 学习曲线陡,新人不会改只会抄 |
| 协程 | 官方主推,结构化并发 | 只有新代码用,老代码没人迁移 |
关键洞察:遗留代码的迁移成本,几乎总是由"业务价值"来排优先级。异步框架改造没有直接业务价值,所以永远排最后。
这句话解释了为什么"统一技术栈"这个目标在真实项目里几乎不可能达成。它不是一个技术决策,而是一个排期决策;而排期永远轮不到它。接受这一点之后,治理思路才会从"重写"转向"共存 + 约束"。
二、共存的四个代价
先把"代价"说清楚。四代方案真正的差别,其实不在 API 长什么样,而在谁负责结束:
| 代际 | 谁负责结束 | 漏掉的后果 |
|---|---|---|
Thread |
自己跑完,或手动 interrupt |
线程无法停止,持有外部引用 |
Handler |
removeCallbacks |
消息还在队列里,持有 target |
Executor |
shutdown / shutdownNow |
线程常驻,任务继续跑 |
| RxJava | Disposable.dispose() / clear() |
订阅泄漏,回调继续打 |
| 协程 | 父 scope 的取消传播 | 取决于这个 scope 有没有 owner |
这张表就是后面所有问题的索引:每一种方案都需要一个"所有者"来触发结束,而混用时所有者有五个。
2.1 代价一:取消语义不统一,"停止服务"停不干净
Service 停止时需要取消所有异步任务,五种技术有五种取消方式:
kotlin
override fun onDestroy() {
super.onDestroy()
timerTask.cancel() // TimerTask:cancel
selectService?.shutdownNow() // 线程池:shutdownNow
job?.cancel() // 协程 Job:cancel
compositeDisposable.clear() // RxJava:clear
handler.removeCallbacksAndMessages(null) // Handler:移除消息
// 还有 Retrofit 的 Call:call.cancel()
}
漏掉任何一个,都会留下"服务已死但任务还在跑"的状态。 而这类问题的表现是:
- 用户退出后还在上传,流量偷偷跑了;
- Service 被杀后任务写数据库,抛
IllegalStateException; - 定时任务持有 Activity 引用(虽然用的是 Application Context,但回调里有 UI 操作)。
第一条只是费流量,第二条和第三条才是真麻烦:它们发生在别人的生命周期里。组件已经销毁,任务还在往里写,结果要么是崩溃,要么是写进一个没人再读的地方。
2.2 代价二:异常处理路径各不相同
| 技术 | 未捕获异常去哪了 |
|---|---|
Thread |
UncaughtExceptionHandler |
AsyncTask |
onCancelled / 直接崩 |
线程池 submit |
被吞进 Future,不 get() 就永远看不到 |
线程池 execute |
UncaughtExceptionHandler |
| RxJava | RxJavaPlugins.setErrorHandler,否则崩溃 |
协程 launch |
CoroutineExceptionHandler,否则走全局 handler |
协程 async |
封装进 Deferred,不 await 就静默丢失 |
这意味着:"为什么这个后台任务失败了却没日志",要按技术栈逐一套答案。 排查一个人要同时知道七种异常路径,这本身就是成本。
注意表里有两行是"静默"的:submit 的 Future 和 async 的 Deferred。它们的设计假设是"调用方一定会来取结果",但在业务代码里,submit 之后忘了 get、async 之后忘了 await,是极常见的事。一旦忘了,异常就存在对象里,随着对象被 GC 一起消失。
2.3 代价三:线程切换的心智负担
同一个业务流程里,可能经历:
RxJava subscribeOn(IO) → observeOn(mainThread)
→ 回调进 Retrofit Callback(IO 线程)
→ 手动切 Handler 到主线程
→ 主线程里 launch 协程
→ withContext(IO)
每一跳都是"读代码时要在脑子里跑一遍线程模型"。而线程切换的地方,恰恰是最容易出并发 Bug 的地方。
更麻烦的是,这段代码里没有任何一处是"错"的。每一跳都符合它所在技术的用法,但连起来之后,没有人能一眼说出"这段代码最终在哪个线程上更新 UI"。
2.4 代价四:新人只会抄最新看到的那一种
结果是同一个模块内,A 文件用 RxJava、B 文件用协程、C 文件用 Thread。代码风格不一致时,Code Review 也失去了统一标准------你没法说"这里应该用协程",因为隔壁文件就是 RxJava,而且跑得很好。
风格分裂的深层影响是:团队失去了判断"什么是好代码"的共同基准。这比多几行样板代码严重得多。
三、不重写,先让现状可控
完全重写不现实(风险与收益不成正比)。可操作的策略是"止血 → 隔离 → 渐进替换"三步。
3.1 第一步:给每一代技术定"禁止新增"的边界
| 技术 | 状态 |
|---|---|
AsyncTask |
禁止新增,且已废弃(API 30+),列入清理计划 |
new Thread() |
禁止新增,一律用协程 |
Handler |
仅允许用于"主线程消息",不允许 postDelayed 做定时轮询 |
Executors |
仅允许在"非 Android 生命周期组件"中使用(如长驻服务) |
| RxJava | 冻结:老代码可维护,新代码一律协程 |
| 协程 | 唯一推荐 |
这条规则要写进 Code Review 清单,并用 CI 做粗粒度检查(比如禁止 new Thread( 出现在 src/main)。
"冻结"这个词很重要。它不是"禁止",而是"不再新增,存量保留"。前者会引发无谓的争论,后者是一个可执行的状态描述。
3.2 第二步:统一"取消"入口
给每个持有异步任务的组件定义一个统一的销毁契约:
kotlin
interface DisposableScope {
fun register(disposable: Disposable) // RxJava
fun register(job: Job) // 协程
fun register(future: Future<*>) // 线程池
fun disposeAll()
}
或者更实际的:在组件(Activity / Fragment / 自定义生命周期类)里维护一个统一的清理列表:
kotlin
class TaskBag {
private val jobs = mutableListOf<Job>()
private val disposables = CompositeDisposable()
private val futures = mutableListOf<Future<*>>()
fun add(job: Job) = jobs.add(job)
fun add(d: Disposable) = disposables.add(d)
fun add(f: Future<*>) = futures.add(f)
fun clear() {
jobs.forEach { it.cancel() }
jobs.clear()
disposables.clear()
futures.forEach { it.cancel(true) }
futures.clear()
}
}
收益 :onDestroy 里只需要调一次 taskBag.clear(),不会漏。
这个模式的价值不在代码本身,而在它把"取消"从一个分散的约定 变成了一个集中的入口。以前是"每个人记得取消自己创建的东西",现在是"所有东西都必须登记,销毁时统一清"。前者依赖自觉,后者依赖编译器和 review。
3.3 第三步:边界隔离------老代码保持原样,新代码用新方案
不要在一个文件里混用两套。规则:
改造一个文件时,把它整体迁移到协程;不改造的文件,保持原技术栈不动。
避免"文件内一半 RxJava 一半协程"这种最难维护的状态。因为一旦混用,你就同时需要两套取消逻辑、两套异常处理、两套线程模型------而且它们还在同一个函数里交替出现。
3.4 一个可执行的迁移优先级
按"风险低 + 收益高"排序:
| 优先级 | 目标 | 理由 |
|---|---|---|
| P0 | 所有 new Thread() + runOnUiThread |
改动机械、风险低、收益(可取消 + 异常可控)明显 |
| P0 | 所有 GlobalScope |
泄漏风险最高(后文详述) |
| P1 | 所有 postDelayed 轮询 |
换成 lifecycleScope + delay 循环,可读性提升大 |
| P1 | 纯 IO 型 RxJava(单次请求) | 可直接替换为 suspend 函数 |
| P2 | 复杂 RxJava 数据流(组合、防抖、切换) | 迁移风险高,建议最后做,或保留 |
| P3 | 稳定运行多年的 Executor 定时任务 |
不动,除非有明确 Bug |
两个 P0 的共同点:它们的改动是机械的,不需要重新设计业务 。new Thread 换成 scope.launch,GlobalScope 换成有 owner 的 scope,改完行为不变、生命周期变清楚。这种改动最适合先做,因为它能快速建立团队对"迁移是安全的"的信心。
四、最危险的一处:GlobalScope
4.1 现场:一个 Service 里的三处
kotlin
// 后台同步服务(示意,已脱敏)
class SyncService : Service() {
private var job: Job? = null
private fun uploadAll(list: List<Item>) {
job = GlobalScope.launch(Dispatchers.IO) { // ①
val online = async { isNetOnline() }
if (online.await()) {
for (item in list) {
val r = async { httpManager.upload(item) }
if (r.await()) {
withContext(Dispatchers.Main) { updateProgress() }
}
}
}
}
}
private fun dealFile(path: String) {
GlobalScope.launch(Dispatchers.IO) { // ②
val result = async { updateDatabase(path) }
if (result.await()) {
withContext(Dispatchers.Main) { showSuccessDialog() }
}
}
}
private fun refresh(filePath: String, taskId: String) {
GlobalScope.launch(Dispatchers.IO) { // ③
updateItem(filePath, taskId)
}
}
}
三处 GlobalScope.launch,其中只有第一处保存了 Job 引用------而这个 job 从来没有被 cancel() 过 (搜索全类,job 只有赋值,没有取消)。
这个细节很典型:写代码的人其实"知道要保存 Job",但保存之后没有下一步。保存 Job 只是获得了取消的能力,不等于取消了。
4.2 GlobalScope 是什么
kotlin
public object GlobalScope : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext // 空的:没有 Job,没有 Dispatcher,没有异常处理器
}
三个特征决定了它的危险性:
- 没有
Job→ 无法整体取消,只能逐个Job.cancel(); - 生命周期 = 整个应用进程 → 启动它的组件销毁了,协程还在跑;
- 没有
CoroutineExceptionHandler→ 异常走全局UncaughtExceptionHandler(如果全局 handler 又被自定义实现覆盖且没有委托,异常就此蒸发)。
官方注释里写得很直白:
Application code usually should use an application-defined
CoroutineScope. Usingasyncorlaunchon the instance ofGlobalScopeis highly discouraged.
往机制里再走一步:CoroutineScope.launch 在创建协程时会用 newCoroutineContext(context) 计算上下文,其中如果找不到 Job,就新建一个独立的 Job()。所以每一次 GlobalScope.launch 都是一个没有父节点的根协程------它不参与任何树,取消不会传播给它,它失败也不会通知任何人。
结构化并发只有三条规则:父取消则子取消、子失败则父取消(SupervisorJob 例外)、父等待所有子结束。GlobalScope 把三条全绕过了。
五、三宗罪
5.1 第一宗:生命周期失控(泄漏)
Service.onDestroy() 执行了,但 GlobalScope 启动的协程:
- 继续持有
this@SyncService(隐式外部引用); - 继续执行网络请求、数据库写入;
- 继续
withContext(Dispatchers.Main)尝试更新 UI(此时 Activity 可能已经销毁)。
泄漏链:
GlobalScope 协程(进程级)
→ 持有 SyncService 实例
→ 持有 Context / 数据库连接 / 监听器
→ 全部无法释放
每次启动服务跑一轮,就多一份引用。反复启停,内存缓慢上涨,最终 OOM------而且 LeakCanary 未必报得出来:它的检测逻辑针对的是"已销毁但被引用的组件",而这里协程还在运行、引用看起来"有主",很容易被当成正常的后台任务放过。
5.2 第二宗:无法取消(资源浪费 + 状态错乱)
job?.cancel() 从来没被调用。后果:
| 场景 | 表现 |
|---|---|
| 用户停止同步 | 上传继续跑,流量继续消耗 |
| 用户退出登录 | 上传继续用旧账号凭证,服务端返回 401 |
| 服务重启 | 上一轮的协程和新一轮并发写同一批数据,数据错乱 |
| 进程即将被杀 | 协程在后台写数据库,写一半进程没了 |
尤其是"并发写同一批数据"------这属于数据一致性问题,比崩溃更难查。因为两次写入单独看都是成功的,只是最终结果不对。
即便调用了 cancel(),也要知道:协程的取消是协作式的。
kotlin
GlobalScope.launch(Dispatchers.IO) {
for (item in list) {
upload(item) // 如果是阻塞式调用,cancel 不会中断它
}
}
必须主动检查:
kotlin
for (item in list) {
ensureActive() // 取消时抛 CancellationException
upload(item)
}
或者用 isActive 判断。
ensureActive() 只是"检查并抛异常",它不会中断正在执行的 upload(item)。如果 upload 是一个阻塞的 socket 写,取消信号要等到这一帧写完才有机会生效。真正要及时中断,得让阻塞调用本身可中断------关闭 socket、设置读超时,或者用 withTimeout 配合可取消的底层 API。
5.3 第三宗:异常静默
kotlin
GlobalScope.launch(Dispatchers.IO) {
updateItem(filePath, taskId) // 这里抛异常会怎样?
}
launch 里的未捕获异常会交给 CoroutineExceptionHandler。GlobalScope 没设置,所以走全局 → 如果全局 handler 被覆盖且没委托,异常彻底消失。
加一个 handler 至少能看见:
kotlin
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
Log.e(TAG, "coroutine failed", e)
CrashReport.record(e)
})
注意这里用的是 SupervisorJob() 而不是 Job():
Job():一个子协程失败 → 整个 scope 全部取消;SupervisorJob():子协程失败互不影响,适合"多个独立任务"的场景。
还有一处比 launch 更静默:async。async 的异常不会交给 CoroutineExceptionHandler,而是存进 Deferred,只有 await() 时才会抛出来。上面那段代码里 async { httpManager.upload(item) } 如果失败了,而某条分支提前 return,这个异常就永远躺在 Deferred 里。
六、正确的写法
6.1 场景一:Service 里
Service 本身不是 CoroutineScope,需要自己建一个,并在 onDestroy 里取消:
kotlin
class SyncService : Service() {
// 自建作用域:SupervisorJob(任务互不影响)+ IO 调度 + 异常处理
private val scope = CoroutineScope(
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
Log.e(TAG, "sync failed", e)
CrashReport.record(e)
}
)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch { uploadAll() }
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
scope.cancel() // 一次性取消所有子协程,连带取消嵌套的 async
}
}
scope.cancel() 会取消所有子协程(结构化并发),比逐个 job.cancel() 可靠得多。区别在于:前者取消的是"树",你不需要知道树上有几个节点;后者要求你把每一个节点都记下来。
6.2 场景二:Activity / Fragment
不要自建 scope,用现成的:
kotlin
// Activity
lifecycleScope.launch { /* 随 Activity 销毁自动取消 */ }
// Fragment(推荐用 viewLifecycleOwner,随 View 销毁取消)
viewLifecycleOwner.lifecycleScope.launch { /* ... */ }
Fragment 这里有个常见错误:用 lifecycleScope 而不是 viewLifecycleOwner.lifecycleScope。前者跟 Fragment 同寿,后者跟 View 同寿。在 replace 场景下,Fragment 还在回退栈里,View 已经销毁了,此时用 lifecycleScope 启动的协程仍然会去碰 binding,于是 NullPointerException 或者更隐蔽的"更新到了已经不在屏幕上的 View"。
6.3 场景三:ViewModel
kotlin
viewModelScope.launch {
repository.load()
}
viewModelScope 在 ViewModel.onCleared() 时自动取消。
6.4 场景四:普通类
让类自己实现 LifecycleOwner,然后用 lifecycleScope:
kotlin
class MyWorkMode : LifecycleOwner { /* ... */ }
// 使用
myWorkMode.lifecycleScope.launch {
doWork()
}
或者实现 CoroutineScope 并手动管理(记得 close()):
kotlin
class Repository : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.IO) {
fun load() { launch { /* ... */ } }
fun close() { cancel() } // 必须有人调用
}
第二种写法有个陷阱:委托出来的 close() 没人调用,就等于 GlobalScope 。它的安全性完全取决于"是否有人记得调 close",而这正是 GlobalScope 的问题。所以更推荐第一种,或者干脆由外部注入 scope。
6.5 场景五:需要"比页面活得更久"的任务
这是唯一需要考虑 GlobalScope 语义的场景(比如"用户退出页面后仍要完成的上传")。但仍然不该用 GlobalScope:
- Android 上用
WorkManager(可约束网络、可重试、进程被杀后仍会执行); - 或者自建一个应用级 scope,并明确它的生命周期:
kotlin
object AppScope : CoroutineScope {
override val coroutineContext =
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
Log.e("AppScope", "error", e)
}
}
AppScope 和 GlobalScope 的差别只有一点:它有一个明确的、写在代码里的 owner,而且这个 owner 的生命周期是"应用进程",与任务的需求匹配 。GlobalScope 的生命周期也是进程,但它是隐式的------没人能回答"这个协程该活多久",因为它压根没有声明。
七、怎么把 GlobalScope 找干净
7.1 搜索与清理
GlobalScope
每一个命中都要能回答三个问题:谁取消它?异常去哪了?它持有谁? 答不上来就改。
这三个问题对应三宗罪,也对应三种修法:加 scope、加 handler、缩小持有范围。
7.2 Detekt / Lint 规则
yaml
# detekt.yml
ForbiddenMethodCall:
active: true
methods:
- reason: "使用 lifecycleScope / viewModelScope / 自建 scope 替代 GlobalScope"
value: "kotlinx.coroutines.GlobalScope.launch"
- reason: "同上"
value: "kotlinx.coroutines.GlobalScope.async"
7.3 运行时监控
给所有自建 scope 加一个统一的命名与注册,Debug 包里能看到"当前存活的协程":
kotlin
// 用 CoroutineName 标记
CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("SyncService"))
再配合 -Dkotlinx.coroutines.debug 开启调试模式,堆栈里会带上协程名,排查泄漏时能直接看到"哪个 scope 还在跑"。
八、五种写法的对比
| 写法 | 生命周期 | 可取消 | 异常处理 | 结论 |
|---|---|---|---|---|
GlobalScope.launch |
进程级 | 需手动逐个取消 | 全局 handler | 禁用 |
CoroutineScope(SupervisorJob() + ...) |
手动管理 | scope.cancel() 一次性 |
可配 handler | 适用非生命周期组件 |
lifecycleScope |
随 Lifecycle | 自动 | 可配 | 首选(Activity/Fragment/自定义 LifecycleOwner) |
viewModelScope |
随 ViewModel | 自动 | 可配 | ViewModel 内首选 |
WorkManager |
系统托管 | 系统管理 | 系统重试 | 需要"比页面活得久"时用 |
选择顺序可以记成一句话:能用现成的就用现成的,不能用现成的就自己建一个并写清谁取消;两样都做不到,才轮到 GlobalScope------而这时候正确的答案是 WorkManager。
结论
- 多代异步方案共存是常态,不是耻辱------耻辱的是没有治理规则。
- 四代方案的真正差别不是 API,而是"谁负责结束" :
Thread靠自己跑完、Handler靠队列清理、Executor靠shutdown、RxJava 靠dispose、协程靠 scope 传播。 - 四个真实代价:取消语义不统一、异常路径各异、线程切换心智负担、代码风格分裂。
- 治理三步 :禁止新增 → 统一取消入口(
TaskBag)→ 按文件整体迁移,不在一个文件里混两套。 - 迁移优先级 :先
new Thread和GlobalScope(改动机械、收益明确),复杂 RxJava 数据流留到最后。 GlobalScope的三宗罪:生命周期失控(泄漏)、无法整体取消、异常静默------本质是"没有 owner"。- 取消是协作式的 :耗时循环里要
ensureActive(),阻塞调用要靠关闭资源或超时来响应取消。 - 自建 scope 用三件套 :
SupervisorJob() + Dispatcher + CoroutineExceptionHandler;Activity/Fragment 用lifecycleScope,ViewModel 用viewModelScope,"要活久一点"用WorkManager。 - CI 加规则禁止
GlobalScope,每个搜索结果都要回答"谁取消它、异常去哪了、它持有谁"。
这套东西并不高级,甚至有点笨:承认老代码不会消失,然后给每一代技术划一条边界。但工程里真正有效的治理,往往就是这种笨办法。如果你也在维护一个"五世同堂"的工程,欢迎在评论区聊聊你们是怎么给这些方案划边界的------尤其是那些最后决定"不动"的部分,理由通常比改法更有参考价值。
关键词标签 :#Android #Kotlin #协程 #GlobalScope #RxJava #内存泄漏 #并发编程