Android 并发考古:newThread 与 RxJava 共存,以及 GlobalScope 的三宗罪
几代异步方案并存不是问题没有所有者才是问题前言打开一个活了五年的 Android 工程经常能在同一个文件里看到Handler、TimerTask、线程池、RxJava、协程。第一反应通常是这代码太脏了然后想找个时间统一技术栈。但脏不是问题的本质。真正让人吃亏的是这些方案各自管各自的取消、各自管各自的异常Handler用removeCallbacks线程池用shutdownNowRxJava 用Disposable协程用Job.cancel。只要漏掉一条就会出现服务已经停了任务还在跑的状态。这跟用了几代技术无关跟谁负责收尾有关。我维护的这套测绘采集工程里一个 400 行的后台同步 Service 同时躺着五代异步方案。这篇分两半前半讲共存本身——它是怎么来的、代价是什么、不重写怎么治理后半讲其中危险的一处GlobalScope。两部分共用一条主线几代方案共存完全没问题问题是资源的所有者必须唯一。一、一个文件里的五世同堂1.1 现场这个工程里有一个后台同步服务弱网环境下上传测量数据单文件 400 行里面同时出现了代际出现形式作用第一代Thread/HandlerHandler(Looper.getMainLooper())主线程消息、弹窗第二代ExecutorTimerTaskExecutors.newScheduledThreadPool(1)scheduleWithFixedDelay60 秒轮询第三代回调式网络库RetrofitCall.enqueue(Callback)网络请求第四代RxJava 2ObservableDisposable散落在各业务模块数据流订阅第五代协程GlobalScope.launch(Dispatchers.IO)async/await批量上传编排// 同一个 Service 里的片段示意已脱敏classSyncService:Service(){privatevarselectServiceExecutors.newScheduledThreadPool(1)// 第二代privatevaltimerTaskobject:TimerTask(){// 第二代overridefunrun(){if(isNetworkConnected())selectResult()if(System.currentTimeMillis()-startTime4*3600*1000){stopRecord()}}}funstart(){selectService?.scheduleWithFixedDelay(timerTask,1000,60_000,TimeUnit.MILLISECONDS)}privatefunupload(list:ListItem){jobGlobalScope.launch(Dispatchers.IO){// 第五代valonlineasync{isNetOnline()}if(online.await()){for(iteminlist){valrasync{httpManager.upload(item)}// 混用第三代回调if(r.await()){/* ... */}}}withContext(Dispatchers.Main){showSuccessDialog()// 第一代语义}}}}这不是段子是大多数活过五年的 Android 工程的真实现状。1.2 为什么会变成这样每一代技术的引入都有当时的合理性时期引入原因遗留原因早期ThreadHandler只有这个能用能跑没人敢动AsyncTask官方推荐当年已被废弃但老代码还在Executor/TimerTask需要定时、需要线程复用定时器逻辑稳定改了要重测RxJava需要复杂的数据流编排、线程切换优雅学习曲线陡新人不会改只会抄协程官方主推结构化并发只有新代码用老代码没人迁移关键洞察遗留代码的迁移成本几乎总是由业务价值来排优先级。异步框架改造没有直接业务价值所以永远排最后。这句话解释了为什么统一技术栈这个目标在真实项目里几乎不可能达成。它不是一个技术决策而是一个排期决策而排期永远轮不到它。接受这一点之后治理思路才会从重写转向共存 约束。二、共存的四个代价先把代价说清楚。四代方案真正的差别其实不在 API 长什么样而在谁负责结束代际谁负责结束漏掉的后果Thread自己跑完或手动interrupt线程无法停止持有外部引用HandlerremoveCallbacks消息还在队列里持有 targetExecutorshutdown/shutdownNow线程常驻任务继续跑RxJavaDisposable.dispose()/clear()订阅泄漏回调继续打协程父 scope 的取消传播取决于这个 scope 有没有 owner这张表就是后面所有问题的索引每一种方案都需要一个所有者来触发结束而混用时所有者有五个。2.1 代价一取消语义不统一停止服务停不干净Service 停止时需要取消所有异步任务五种技术有五种取消方式overridefunonDestroy(){super.onDestroy()timerTask.cancel()// TimerTaskcancelselectService?.shutdownNow()// 线程池shutdownNowjob?.cancel()// 协程 JobcancelcompositeDisposable.clear()// RxJavaclearhandler.removeCallbacksAndMessages(null)// Handler移除消息// 还有 Retrofit 的 Callcall.cancel()}漏掉任何一个都会留下服务已死但任务还在跑的状态。而这类问题的表现是用户退出后还在上传流量偷偷跑了Service 被杀后任务写数据库抛IllegalStateException定时任务持有 Activity 引用虽然用的是 Application Context但回调里有 UI 操作。第一条只是费流量第二条和第三条才是真麻烦它们发生在别人的生命周期里。组件已经销毁任务还在往里写结果要么是崩溃要么是写进一个没人再读的地方。2.2 代价二异常处理路径各不相同技术未捕获异常去哪了ThreadUncaughtExceptionHandlerAsyncTaskonCancelled/ 直接崩线程池submit被吞进Future不get()就永远看不到线程池executeUncaughtExceptionHandlerRxJavaRxJavaPlugins.setErrorHandler否则崩溃协程launchCoroutineExceptionHandler否则走全局 handler协程async封装进Deferred不 await 就静默丢失这意味着“为什么这个后台任务失败了却没日志”要按技术栈逐一套答案。排查一个人要同时知道七种异常路径这本身就是成本。注意表里有两行是静默的submit的Future和async的Deferred。它们的设计假设是调用方一定会来取结果但在业务代码里submit之后忘了get、async之后忘了await是极常见的事。一旦忘了异常就存在对象里随着对象被 GC 一起消失。2.3 代价三线程切换的心智负担同一个业务流程里可能经历RxJava subscribeOn(IO) → observeOn(mainThread) → 回调进 Retrofit CallbackIO 线程 → 手动切 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 第二步统一取消入口给每个持有异步任务的组件定义一个统一的销毁契约interfaceDisposableScope{funregister(disposable:Disposable)// RxJavafunregister(job:Job)// 协程funregister(future:Future*)// 线程池fundisposeAll()}或者更实际的在组件Activity / Fragment / 自定义生命周期类里维护一个统一的清理列表classTaskBag{privatevaljobsmutableListOfJob()privatevaldisposablesCompositeDisposable()privatevalfuturesmutableListOfFuture*()funadd(job:Job)jobs.add(job)funadd(d:Disposable)disposables.add(d)funadd(f:Future*)futures.add(f)funclear(){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轮询换成lifecycleScopedelay循环可读性提升大P1纯 IO 型 RxJava单次请求可直接替换为suspend函数P2复杂 RxJava 数据流组合、防抖、切换迁移风险高建议最后做或保留P3稳定运行多年的Executor定时任务不动除非有明确 Bug两个 P0 的共同点它们的改动是机械的不需要重新设计业务。new Thread换成scope.launchGlobalScope换成有 owner 的 scope改完行为不变、生命周期变清楚。这种改动最适合先做因为它能快速建立团队对迁移是安全的的信心。四、最危险的一处GlobalScope4.1 现场一个 Service 里的三处// 后台同步服务示意已脱敏classSyncService:Service(){privatevarjob:Job?nullprivatefunuploadAll(list:ListItem){jobGlobalScope.launch(Dispatchers.IO){// ①valonlineasync{isNetOnline()}if(online.await()){for(iteminlist){valrasync{httpManager.upload(item)}if(r.await()){withContext(Dispatchers.Main){updateProgress()}}}}}}privatefundealFile(path:String){GlobalScope.launch(Dispatchers.IO){// ②valresultasync{updateDatabase(path)}if(result.await()){withContext(Dispatchers.Main){showSuccessDialog()}}}}privatefunrefresh(filePath:String,taskId:String){GlobalScope.launch(Dispatchers.IO){// ③updateItem(filePath,taskId)}}}三处GlobalScope.launch其中只有第一处保存了Job引用——而这个job从来没有被cancel()过搜索全类job只有赋值没有取消。这个细节很典型写代码的人其实知道要保存 Job但保存之后没有下一步。保存 Job 只是获得了取消的能力不等于取消了。4.2 GlobalScope 是什么publicobjectGlobalScope:CoroutineScope{overridevalcoroutineContext:CoroutineContextget()EmptyCoroutineContext// 空的没有 Job没有 Dispatcher没有异常处理器}三个特征决定了它的危险性没有Job→ 无法整体取消只能逐个Job.cancel()生命周期 整个应用进程→ 启动它的组件销毁了协程还在跑没有CoroutineExceptionHandler→ 异常走全局UncaughtExceptionHandler如果全局 handler 又被自定义实现覆盖且没有委托异常就此蒸发。官方注释里写得很直白Application code usually should use an application-definedCoroutineScope. Usingasyncorlaunchon the instance ofGlobalScopeis highly discouraged.往机制里再走一步CoroutineScope.launch在创建协程时会用newCoroutineContext(context)计算上下文其中如果找不到Job就新建一个独立的Job()。所以每一次GlobalScope.launch都是一个没有父节点的根协程——它不参与任何树取消不会传播给它它失败也不会通知任何人。结构化并发只有三条规则父取消则子取消、子失败则父取消SupervisorJob例外、父等待所有子结束。GlobalScope把三条全绕过了。五、三宗罪5.1 第一宗生命周期失控泄漏Service.onDestroy()执行了但GlobalScope启动的协程继续持有thisSyncService隐式外部引用继续执行网络请求、数据库写入继续withContext(Dispatchers.Main)尝试更新 UI此时 Activity 可能已经销毁。泄漏链GlobalScope 协程进程级 → 持有 SyncService 实例 → 持有 Context / 数据库连接 / 监听器 → 全部无法释放每次启动服务跑一轮就多一份引用。反复启停内存缓慢上涨最终 OOM——而且LeakCanary 未必报得出来它的检测逻辑针对的是已销毁但被引用的组件而这里协程还在运行、引用看起来有主很容易被当成正常的后台任务放过。5.2 第二宗无法取消资源浪费 状态错乱job?.cancel()从来没被调用。后果场景表现用户停止同步上传继续跑流量继续消耗用户退出登录上传继续用旧账号凭证服务端返回 401服务重启上一轮的协程和新一轮并发写同一批数据数据错乱进程即将被杀协程在后台写数据库写一半进程没了尤其是并发写同一批数据——这属于数据一致性问题比崩溃更难查。因为两次写入单独看都是成功的只是最终结果不对。即便调用了cancel()也要知道协程的取消是协作式的。GlobalScope.launch(Dispatchers.IO){for(iteminlist){upload(item)// 如果是阻塞式调用cancel 不会中断它}}必须主动检查for(iteminlist){ensureActive()// 取消时抛 CancellationExceptionupload(item)}或者用isActive判断。ensureActive()只是检查并抛异常它不会中断正在执行的upload(item)。如果upload是一个阻塞的 socket 写取消信号要等到这一帧写完才有机会生效。真正要及时中断得让阻塞调用本身可中断——关闭 socket、设置读超时或者用withTimeout配合可取消的底层 API。5.3 第三宗异常静默GlobalScope.launch(Dispatchers.IO){updateItem(filePath,taskId)// 这里抛异常会怎样}launch里的未捕获异常会交给CoroutineExceptionHandler。GlobalScope没设置所以走全局 → 如果全局 handler 被覆盖且没委托异常彻底消失。加一个 handler 至少能看见valscopeCoroutineScope(SupervisorJob()Dispatchers.IOCoroutineExceptionHandler{_,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里取消classSyncService:Service(){// 自建作用域SupervisorJob任务互不影响 IO 调度 异常处理privatevalscopeCoroutineScope(SupervisorJob()Dispatchers.IOCoroutineExceptionHandler{_,e-Log.e(TAG,sync failed,e)CrashReport.record(e)})overridefunonStartCommand(intent:Intent?,flags:Int,startId:Int):Int{scope.launch{uploadAll()}returnSTART_STICKY}overridefunonDestroy(){super.onDestroy()scope.cancel()// 一次性取消所有子协程连带取消嵌套的 async}}scope.cancel()会取消所有子协程结构化并发比逐个job.cancel()可靠得多。区别在于前者取消的是树你不需要知道树上有几个节点后者要求你把每一个节点都记下来。6.2 场景二Activity / Fragment不要自建 scope用现成的// ActivitylifecycleScope.launch{/* 随 Activity 销毁自动取消 */}// Fragment推荐用 viewLifecycleOwner随 View 销毁取消viewLifecycleOwner.lifecycleScope.launch{/* ... */}Fragment 这里有个常见错误用lifecycleScope而不是viewLifecycleOwner.lifecycleScope。前者跟 Fragment 同寿后者跟 View 同寿。在replace场景下Fragment 还在回退栈里View 已经销毁了此时用lifecycleScope启动的协程仍然会去碰binding于是NullPointerException或者更隐蔽的更新到了已经不在屏幕上的 View。6.3 场景三ViewModelviewModelScope.launch{repository.load()}viewModelScope在ViewModel.onCleared()时自动取消。6.4 场景四普通类让类自己实现LifecycleOwner然后用lifecycleScopeclassMyWorkMode:LifecycleOwner{/* ... */}// 使用myWorkMode.lifecycleScope.launch{doWork()}或者实现CoroutineScope并手动管理记得close()classRepository:CoroutineScopebyCoroutineScope(SupervisorJob()Dispatchers.IO){funload(){launch{/* ... */}}funclose(){cancel()}// 必须有人调用}第二种写法有个陷阱委托出来的close()没人调用就等于GlobalScope。它的安全性完全取决于是否有人记得调close而这正是GlobalScope的问题。所以更推荐第一种或者干脆由外部注入 scope。6.5 场景五需要比页面活得更久的任务这是唯一需要考虑GlobalScope语义的场景比如用户退出页面后仍要完成的上传。但仍然不该用GlobalScopeAndroid 上用WorkManager可约束网络、可重试、进程被杀后仍会执行或者自建一个应用级 scope并明确它的生命周期objectAppScope:CoroutineScope{overridevalcoroutineContextSupervisorJob()Dispatchers.IOCoroutineExceptionHandler{_,e-Log.e(AppScope,error,e)}}AppScope和GlobalScope的差别只有一点它有一个明确的、写在代码里的 owner而且这个 owner 的生命周期是应用进程与任务的需求匹配。GlobalScope的生命周期也是进程但它是隐式的——没人能回答这个协程该活多久因为它压根没有声明。七、怎么把 GlobalScope 找干净7.1 搜索与清理GlobalScope每一个命中都要能回答三个问题谁取消它异常去哪了它持有谁答不上来就改。这三个问题对应三宗罪也对应三种修法加 scope、加 handler、缩小持有范围。7.2 Detekt / Lint 规则# detekt.ymlForbiddenMethodCall:active:truemethods:-reason:使用 lifecycleScope / viewModelScope / 自建 scope 替代 GlobalScopevalue:kotlinx.coroutines.GlobalScope.launch-reason:同上value:kotlinx.coroutines.GlobalScope.async7.3 运行时监控给所有自建 scope 加一个统一的命名与注册Debug 包里能看到当前存活的协程// 用 CoroutineName 标记CoroutineScope(SupervisorJob()Dispatchers.IOCoroutineName(SyncService))再配合-Dkotlinx.coroutines.debug开启调试模式堆栈里会带上协程名排查泄漏时能直接看到哪个 scope 还在跑。八、五种写法的对比写法生命周期可取消异常处理结论GlobalScope.launch进程级需手动逐个取消全局 handler禁用CoroutineScope(SupervisorJob() ...)手动管理scope.cancel()一次性可配 handler适用非生命周期组件lifecycleScope随 Lifecycle自动可配首选Activity/Fragment/自定义 LifecycleOwnerviewModelScope随 ViewModel自动可配ViewModel 内首选WorkManager系统托管系统管理系统重试需要比页面活得久时用选择顺序可以记成一句话能用现成的就用现成的不能用现成的就自己建一个并写清谁取消两样都做不到才轮到GlobalScope——而这时候正确的答案是WorkManager。结论多代异步方案共存是常态不是耻辱——耻辱的是没有治理规则。四代方案的真正差别不是 API而是谁负责结束Thread靠自己跑完、Handler靠队列清理、Executor靠shutdown、RxJava 靠dispose、协程靠 scope 传播。四个真实代价取消语义不统一、异常路径各异、线程切换心智负担、代码风格分裂。治理三步禁止新增 → 统一取消入口TaskBag→ 按文件整体迁移不在一个文件里混两套。迁移优先级先new Thread和GlobalScope改动机械、收益明确复杂 RxJava 数据流留到最后。GlobalScope的三宗罪生命周期失控泄漏、无法整体取消、异常静默——本质是没有 owner。取消是协作式的耗时循环里要ensureActive()阻塞调用要靠关闭资源或超时来响应取消。自建 scope 用三件套SupervisorJob() Dispatcher CoroutineExceptionHandlerActivity/Fragment 用lifecycleScopeViewModel 用viewModelScope要活久一点用WorkManager。CI 加规则禁止GlobalScope每个搜索结果都要回答谁取消它、异常去哪了、它持有谁。这套东西并不高级甚至有点笨承认老代码不会消失然后给每一代技术划一条边界。但工程里真正有效的治理往往就是这种笨办法。如果你也在维护一个五世同堂的工程欢迎在评论区聊聊你们是怎么给这些方案划边界的——尤其是那些最后决定不动的部分理由通常比改法更有参考价值。关键词标签#Android#Kotlin#协程#GlobalScope#RxJava#内存泄漏#并发编程