Kotlin后端别再用GlobalScope

Kotlin 后端别再用 GlobalScope:结构化并发与可取消 Scope

在 Kotlin 后端里,GlobalScope.launch 看起来最省事:不用声明 Scope、不用想生命周期,协程就能飞起来。官方 API 文档却把它标成 Delicate :没有 Job,取消不了整批,也等不齐全部完成。JetBrains 博客上 2025 年 12 月的一篇客座文章(问答体,作者是 JetBrains 认证 Kotlin 讲师)也提到:把协程当成「语法更漂亮的线程」是一种反模式,典型症状正是 GlobalScope.launch、生产路径乱 runBlocking、忽略结构化并发。下面只围绕这三件事落地:为什么危险、怎样换成有边界的 Scope、请求内并发怎么写才可取消。

一、GlobalScope 到底缺了什么

GlobalScope API 文档写得很直白:它是一个 没有 Job 的 CoroutineScope。后果有两条:

  1. 无法取消在该 Scope 里启动的全部协程;
  2. 无法等待它们全部结束。

于是生命周期对不上号:组件已经销毁、服务已经下线,后台协程还在拉数、写 socket,轻则资源泄漏,重则更新已不存在的状态。文档用 UI 场景举例:用户离开页面后还在 fetchLastRecordFromServer 并试图刷新界面。后端的对等场景是「实例已摘流,仍在消费队列 / 打下游」。再说资源占用:一个永远收不到取消、也永远等不到下一条请求(Channel 里再没人写入)的协程,会一直挂着、占着 socket,finally 里的 socket.close() 不会被执行。官方文档给过这个例子。

文档还提醒:GlobalScope 默认没有 CoroutineExceptionHandler,launch 里抛出的异常会落到平台「最后手段」行为。文档给出的情形是:Android、Kotlin/Native、JS 上可能直接崩溃,非 Android 的 JVM 上则是往日志里刷一堆未必有用的信息,没法干净地交给调用方。结构化并发下,异常通常能沿父协程传播;逃到全局后,这条链就断了。

另一个常被忽略的坑:不要 用 CoroutineScope().launch { ... } 去「替换」GlobalScope.launch。裸构造出来的 Scope 同样缺少你以为存在的边界,文档明确说它会踩同一类坑。看起来「我新建了 Scope」,实际上仍然没有人在 shutdown 时对它 cancel(),效果和全局漂着差不多。

合法场景极少:需要在整个应用生命周期都活着的顶层后台(例如周期性打统计)。即便如此,也必须显式 Opt-In,并自己装异常处理:

kotlin 复制代码
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

// 仅演示「应用级后台」逃生舱写法;多数业务代码不应走这条路
@OptIn(DelicateCoroutinesApi::class)
val globalScopeReporter = GlobalScope.launch(
    CoroutineExceptionHandler { _, e ->
        println("fatal in global reporter: $e")
    }
) {
    while (true) {
        delay(1000)
        // logStatistics()
    }
}

@OptIn(DelicateCoroutinesApi::class) 别当仪式看,它是在提醒你:正在走逃生舱。代码评审里看到裸 GlobalScope.launch 且没有 Opt-In、没有 Handler、没有「为何必须活过整个进程」的注释,基本可以直接打回。

二、结构化并发:父取消子、父等子

Coroutine context and dispatchers 把父子关系讲清楚了:

  • 在另一个协程的 CoroutineScope 里 launch,新协程的 Job 成为父 Job 的子 Job;
  • 父取消 → 子递归取消;
  • 父会等待全部子完成 ,不必自己 join 每一个。

脱离结构的两种典型写法:

  1. 显式换 Scope,例如 GlobalScope.launch,不继承父 Job;
  2. 给子协程塞一个全新的 Job(),覆盖父 Scope 的 Job,独立执行。

文档里的演示很直观:请求协程被 cancel 后,继承父上下文的子协程停掉,而带着独立 Job() 的那条仍继续跑。后端里「网关已超时返回、业务协程还在写库」往往就是同类结构问题:取消信号到了外层,内层却已经「声明独立」。

JetBrains 博客上的一篇客座文章(问答体,作者是 JetBrains 认证 Kotlin 讲师:How Backend Development Teams Use Kotlin in 2025)给出的修法,方向与上面的官方文档一致:停止在 GlobalScope 里起协程;每个协程绑到真实 Scope/生命周期;runBlocking 留给 main() 或测试;并发组合用 coroutineScope / supervisorScope,别逃到全局。

kotlin 复制代码
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.supervisorScope

// supervisorScope:子协程失败不会自动取消兄弟;但 await() 会把异常抛出来,块以异常结束时整个 Scope 仍会被取消
suspend fun loadPair(): Pair<Result<String>, Result<String>> = supervisorScope {
    val a = async { "A" }
    val b = async { "B" }
    runCatching { a.await() } to runCatching { b.await() }
}

// 一个失败应取消兄弟任务时用 coroutineScope
suspend fun loadTogether(): String = coroutineScope {
    val first = async { "config" }
    val second = async { "data" }
    first.await() + "/" + second.await()
}

对调用方而言,结构化并发还有一个常被低估的好处:函数返回即表示内部并发收尾 。coroutineScope 块结束前,子协程都得了断;你不必在业务代码里维护一份「子 Job 列表」再手动 join。相反,GlobalScope 把「何时结束」抛回给全人类,也就是基本上没人负责。后端接口超时、线程中断、框架取消信号,只有进得了 Job 树,才谈得上协作式取消。

关键是:并发发生在 suspend 作用域内部 ,取消与等待跟着调用栈走。coroutineScope 适合「任一失败就整单失败」;supervisorScope 适合「汇总多个结果、单路失败不立刻拖死兄弟」;但子协程的异常仍会在 await() 处抛出,要容忍部分失败就得自己 runCatching。另外文档提醒,supervisorScope 不会替你装 CoroutineExceptionHandler,launch 子协程失败时异常可能无人处理。选型按业务失败语义,不要为了「看起来高级」默认 supervisor。

官方替代路径还有更朴素的一条:很多时候根本不需要新协程。能串行的 suspend 直接调即可;只有真正要重叠执行时,再在 coroutineScope 里 launch/async。GlobalScope 文档指出,它常被看不到其他入口的初学者拿来创建协程,属于 antipattern,应当避免。

三、后端怎么绑生命周期 Scope

官方文档用 Android Activity 做示例:创建 CoroutineScope(Dispatchers.Default),在 destroy() 里 cancel()。后端没有 Activity,但有 服务组件 / 客户端 / Worker 的启停,同一模式直接搬:启动时建 Scope,关闭时 cancel(),中间只在这个 Scope 上 launch。

把文档里的 Activity.destroy() 念成 DisposableBean.destroy()、close()、shutdown() 或 Ktor 3 里 monitor.subscribe(ApplicationStopped) { ... } 一类钩子即可:名字不同,契约相同:生命周期结束必须 cancel 。若框架已提供现成 Scope,优先用框架的,不要再平行造一个 GlobalScope「备份通道」。kotlinx 文档举的例子是 Android 的 ViewModel;服务端的 Ktor 里,Application 本身就是 CoroutineScope,应用停止时会被取消。

kotlin 复制代码
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class BackgroundWorker {
    // 与文档 Activity 示例同模式:有边界的 Scope;CoroutineScope 文档建议成员 Scope 配 SupervisorJob 和 CoroutineExceptionHandler
    private val scope = CoroutineScope(
        SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, e ->
            println("worker coroutine failed: $e")
        }
    )

    fun startPolling() {
        scope.launch {
            while (true) {
                delay(500)
                // pollOnce()
            }
        }
    }

    fun shutdown() {
        scope.cancel() // 父取消 → 子递归取消
    }
}

fun main() {
    val worker = BackgroundWorker()
    worker.startPolling()
    // ...服务运行...
    worker.shutdown()
}

落地清单可以压成四条:

  1. 不必起协程就别起 :能串行的 suspend 函数直接调,别为了「看起来异步」就 launch。
  2. 请求内要并发 :在 suspend 里用 coroutineScope { launch / async },让并发生命周期等于这次调用。
  3. 顶层非 suspend :用有边界的 CoroutineScope(如上),在 destroy/shutdown 时 cancel();不要 GlobalScope,也不要随手写 CoroutineScope().launch { ... }(没有人持有这个 Scope,也就没人 cancel())。
  4. 生产路径慎用 runBlocking :官方 API 文档把 runBlocking 定位成阻塞代码与挂起代码之间的桥,用于 main 函数、测试和非 suspend 回调,并明确说在 suspend 函数里调用它是多余的、会堵线程;上面那篇 JetBrains 博客文章也建议只在 main() 或测试里用。服务里若在事件循环线程上到处 runBlocking,等于把异步模型拧回阻塞,取消链也更难跟。

内存与背压方面,上面那篇 JetBrains 博客文章提到可用 Dispatchers.IO.limitedParallelism(...) 限制 IO 并行度(limitedParallelism 自 kotlinx.coroutines 1.9.0 起是稳定 API),用带上限的 flow.buffer(...) 避免无界堆积;文中示例是 onBufferOverflow = BufferOverflow.DROP_OLDEST,会丢旧数据,只适合允许丢弃的场景。这些是作者的个人实践建议,并非 kotlinx 文档里的「必须如此」,文中「每个线程约 1-2 MB」之类的数字也是作者的经验值,不是官方基准。这里的焦点仍是 Scope 与取消:先把 GlobalScope 挪掉,再谈限流旋钮。该文还提到,堆没涨而 RSS 在涨时,多半是线程栈或直接内存;那是下一层问题,和「协程有没有边界」是两件衣服,别混着改。

四、评审时可以对照的检查表

把协程相关改动丢进评审时,可以按表扫一遍,成本很低:

写法 问题 建议
GlobalScope.launch 无 Job,不能 cancel/await 全部 仅应用级后台 + @OptIn + CoroutineExceptionHandler
裸 CoroutineScope().launch 与 GlobalScope 同类坑 不要当「安全替代」
有边界 Scope + cancel() --- 绑服务生命周期
coroutineScope / supervisorScope --- 请求内并发的默认工具
生产路径 runBlocking 堵线程、难取消 留给 main()/测试
launch(Job()) 脱离父 Job 取消传不下去 确认是否真要独立;多数业务不要

补充两点实操经验:

  • 关闭顺序 (作者经验,非文档结论):先停接入(不再接新请求),再 scope.cancel(),再关线程池/客户端;倒过来容易在取消过程中又 launch 出新任务。
  • 日志与调试 :需要时用文档提到的 -Dkotlinx.coroutines.debug 让线程名带上协程名,比只看线程池工人号更容易对上「谁还活着」。这解决的是可见性,不能替代正确的 Scope 边界。

五、和「线程思维」掰扯清楚

后端同学从 Java 线程池迁过来时,容易带着三句口头禅:

  1. 「丢进全局线程池就行」→ 映射成 GlobalScope.launch;
  2. 「这里同步等等结果」→ 映射成到处 runBlocking;
  3. 「子任务独立跑,别影响我」→ 映射成随手 launch(Job()) 或另起 Scope。

结构化并发不禁止并发,只禁止说不清谁负责收尸 。请求级并发用 coroutineScope;组件级后台用成员 Scope + shutdown 时 cancel();进程级常驻才考虑带 Opt-In 的 GlobalScope。三者分层之后,Dispatcher 选 Default 还是 IO、要不要 limitedParallelism,才是下一层优化。内存排查类的建议,也要建立在「你没有无主协程在漏」的前提上。

若你维护的是长期运行的 Kotlin 服务,不妨在架构图上显式画两个框:请求 Scope (随调用创建/结束)和 组件 Scope(随 Bean/Worker 启停)。代码审查对照这两框,比争论「协程到底算不算轻量线程」更接近生产问题。GlobalScope 不属于其中任一框,所以默认就应该可疑。

小结:结构化并发要的是谁启动,谁负责取消与等待。GlobalScope 让人绕开这些「手续」,同时也丢掉了它的好处;后端要的恰恰是取消、等待、异常边界。先做到「可取消、可等待、可解释」,再谈 Dispatcher 调参与背压,顺序反了,调参只是在给无主协程续命。把手续补回来,标题里的「别再用」才站得住。

相关推荐
mmsx1 天前
Android 上的 AI 对话链路:流式响应、SSE 解析与三级降级
android·kotlin
YB13751 天前
Kotlin 引用操作符::
kotlin
维克兜率天2 天前
【维克】弹性策略:用“乖离率“捕捉超跌反弹
android·开发语言·python·深度学习·kotlin·量化
传奇开心果编程2 天前
【用案例学Material 3 Expressive】第1课:从一封邮件开始,感受安卓设计新语言的表现力
android·学习·ui·kotlin·android jetpack
mmsx2 天前
Android 状态保持:进程被杀、旋屏与后台任务的存活术
android·kotlin
EatFan2 天前
Spring Boot 4 落地观察:从 yudao-cloud、matecloud、JPower 看国产脚手架的升级路线与迁移清单
java·spring boot·后端·spring cloud·微服务·后端开发·jdk 21
传奇开心果编程3 天前
【Jetpack Compose进阶学与练】第14课:系列收尾复习总结;Compose项目常见坑点汇总;学习路线与后续学习方向
android·学习·ui·kotlin·android jetpack
花开路口3 天前
线程安全完全指南:从 Java 到 Kotlin,一文吃透并发编程
java·kotlin