mcpField
K
kotlin-coroutines-structured-concurrency

kotlin-coroutines-structured-concurrency

github.com/chrisbanes开发1.1k1.0k 次安装1 天前更新
代码仓库

关于 kotlin-coroutines-structured-concurrency

面向 Kotlin 协程代码编写与评审的技能,主张让调用方管理协程作用域,并用挂起 API 保持生命周期、取消和错误处理可见。

主要功能

  1. 01识别类持有作用域等反模式
  2. 02建议由调用方管理协程生命周期
  3. 03区分 UI 状态持有者与业务层职责
  4. 04检查取消异常是否被吞掉
  5. 05指导后台任务的显式启动方式

适用场景

  1. 01评审仓库或用例中的协程作用域
  2. 02将 fire-and-forget API 改为挂起 API
  3. 03排查作用域取消导致的静默失败
  4. 04审查初始化器或单例中的后台启动

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/chrisbanes/skills --skill kotlin-coroutines-structured-concurrency

安装后在 Claude Code 中输入/kotlin-coroutines-structured-concurrency确认已加载。

详细介绍

Kotlin 协程:结构化并发

核心原则

结构良好的协程是一个自包含的异步工作单元——只有一个入口和一个出口,其作用域与调用点已知的生命周期绑定。

作用域通常应与调用方的生命周期绑定,而不是作为被调用方的属性存储。 存储的 CoroutineScope 是一个需要重点审查的信号:类必须证明它负责取消、错误报告、重启行为和生命周期。大多数存储库、管理器、用例和数据源无法证明这一点,因此应改为提供 suspend API。

解决方法几乎总是相同的:将 API 改为 suspend,让调用方拥有作用域。

何时使用此技能

编写或审查 Kotlin 代码时,如果看到以下任一情况:

  • 类中有 private val scope: CoroutineScope(构造函数参数被存储为属性)
  • 有 init { scope.launch { ... } } 代码块
  • 一个非挂起的公共函数,其函数体是 scope.launch { ... }
  • 在支持挂起的应用代码中使用 runBlocking { ... },或者在应使用 runTest 的测试中使用它
  • 在挂起调用周围使用 runCatching { suspendCall() },或使用 catch 捕获 Exception / Throwable,却没有重新抛出 CancellationException
  • 在挂起操作周围使用 catch (e: CancellationException)(或等效写法),却没有重新抛出异常

静默取消错误

无主的 CoroutineScope 属性之所以如此危险,原因在于:作用域一旦被取消,此后在其上执行的每个 launch 都会静默地以取消状态完成——没有异常、没有日志,什么都没有。 工作就这样没有执行。这是最难诊断的协程错误之一,常见于类持有自身并不拥有的生命周期的长期引用时。

如果 API 是 suspend,就不会发生这种情况:调用方的作用域要么仍然存活(工作会运行),要么调用点取消了调用(调用方会知道)。

反模式与修复方法

1. 将 CoroutineScope 存储为属性
// ❌ BAD
@Inject
class UserRepository(
    private val scope: CoroutineScope,
    private val api: UserApi,
) {
    fun refresh() {
        scope.launch { _state.value = api.fetchUser() }
    }
}

// ✅ GOOD
@Inject
class UserRepository(
    private val api: UserApi,
) {
    suspend fun refresh(): User = api.fetchUser()
}

存储库不再需要了解协程。调用方(ViewModel、用例)可以决定使用哪个作用域、如何处理错误以及采用何种取消语义。

2. 在 init 代码块中启动协程
// ❌ BAD: construction-time side effect, unbounded work
class UserSession(private val scope: CoroutineScope, private val api: Api) {
    init { scope.launch { _user.value = api.load() } }
}

构造函数会立即返回。调用方无法 await 加载、无法查看错误,也无法取消。这个类虽然“存活”,但其状态未定义。

// ✅ GOOD: explicit bootstrap, caller owns the suspension
class UserSession(private val api: Api) {
    private var _user: User? = null
    val user: User get() = checkNotNull(_user) { "Call init() first" }

    suspend fun init() { _user = api.load() }
}
3. 在非 UI 类中启动即忘协程

非挂起的公共函数位于非 UI 类(存储库、管理器、用例、数据源)中,并在类拥有的作用域内启动协程。调用方无法获得结果、错误或取消能力,也无法保证工作确实运行过。

// ❌ BAD — repository with stored scope and fire-and-forget public API
class AnalyticsClient(private val scope: CoroutineScope, private val api: Api) {
    fun track(event: Event) {
        scope.launch { api.send(event) }      // caller has no idea what happens
    }
    fun signOut() {
        scope.launch { api.signOut() }        // silent failure if scope cancelled
    }
}
// ✅ GOOD
class AnalyticsClient(private val api: Api) {
    suspend fun track(event: Event) = api.send(event)
    suspend fun signOut() = api.signOut()
}
例外:UI ↔ 状态持有者边界

UI 框架不支持挂起。Composable 的 onClick、Fragment 的 onKeyEvent、Activity 的 onNewIntent——都不能 suspend。状态持有者(ViewModel、Decompose Component、功能模型等——任何负责接收 UI 事件并持有 UI 状态的对象)就是将一次性 UI 事件转换为与 UI 生命周期绑定的异步工作的边界。这正是它的职责。

// ✅ GOOD — state holder absorbs a non-suspending UI event onto its scope
class FavouritesViewModel(private val repo: FavouritesRepository) : ViewModel() {
    fun onToggleFavourite(item: Item) {
        viewModelScope.launch { repo.toggleFavourite(item) }
    }
}

// in Compose:
ListItem(onClick = { viewModel.onToggleFavourite(item) })

这不是“发起后不管”的反模式。必须同时满足以下三个条件:

  1. 面向某个 UI 界面的状态持有者——ViewModel、Decompose Component、功能模型或同等的 UI 状态持有者。不能是仓库、管理器、用例或数据源。
  2. 与生命周期绑定的作用域——viewModelScope、销毁时会取消的 Component 的 coroutineScope、Composable 的 rememberCoroutineScope()。不能是 AppScope、注入的长生命周期作用域,也不能是临时创建的 CoroutineScope(...)。
  3. 调用方确实是 UI 事件——Composable 回调、按键处理程序、生命周期钩子。不能是其他业务逻辑类通过状态持有者发起调用。

下层的仓库/用例/数据源仍然应公开 suspend API。状态持有者是唯一适合进行从非挂起到挂起转换的层。

“感觉像状态持有者”还不够。关键问题是:“UI 是否直接绑定到它?”如果不是,则不适用此例外。

4. 未注入的已存储作用域

同一种反模式,只是没有注入作用域:

// ❌ BAD — same problem, scope is constructed in-class instead of injected
class FooManager {
    private val scope = MainScope()
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}

此时没有任何对象拥有其生命周期,它会永远存在。应改用 suspend API。

如果实例化嵌套在函数体内,情况也一样——fun foo() { CoroutineScope(...).launch { … } } 只是多绕了几步创建并存储作用域。每次调用都会泄漏一个无法取消的新作用域;将它封装到 by lazy 属性中也无法解决根本问题(这个作用域根本不应该存在)。

5. 由 DI 绑定的单例/启动器发起协程

有一种不易察觉的特定模式:由 DI 绑定的类(@SingleIn(AppScope)、@Singleton、Initializer.initialize())从构造函数/init 块/initialize() 中发起协程。这样启动的工作会:

  • 在不确定的时间开始——绑定关系何时被解析,就会在何时启动。冷启动顺序不可见。
  • **没有可观察的生命周期。**代码库中没有其他对象能知道它是否正在运行或是否已崩溃。
  • **没有 stop()/重启路径。**如果上游进入异常状态,循环就无法取消。
  • **没有可供搜索的调用代码。**读者无法查明“谁在何时启动了它”。

§1 指出,作用域应与调用方的生命周期绑定。由 DI 绑定的变体间接违反了这一点:作用域可能是注入的,但 launch 被隐藏在构造过程中——效果相同,却更难发现。

// ❌ BAD — singleton boots work as a side effect of being constructed
@SingleIn(AppScope::class)
@Inject
class TokenRefresher(
    @ForScope(AppScope::class) private val scope: CoroutineScope,
    private val auth: AuthService,
) {
    init {
        scope.launch {
            while (isActive) {
                delay(5.minutes)
                auth.refreshIfNeeded()
            }
        }
    }
}

// ❌ ALSO BAD — Initializer.initialize() that *launches*, not just registers
class TokenInvalidatorInitializer @Inject constructor(
    @ForScope(AppScope::class) private val scope: CoroutineScope,
    private val store: AuthStore,
    private val invalidator: TokenInvalidator,
) : Initializer {
    override fun initialize() {
        scope.launch { store.tokenChanges.collect { invalidator.invalidate() } }
    }
}

两种情况看起来都像“应用级单例”,但不适用场景中的例外并不意味着可以从 init/initialize() 中发起协程。它只是允许单例在 API 为挂起函数时拥有作用域。

首先要问:这个后台循环类真的有必要存在吗?

大多数后台循环类之所以存在,只是因为没人反过来审视这个问题。以下有三种方案,按优先级排序:

模式 1——将逻辑移入消费者。 这个类会一直观察状态,并在状态变化时作出反应。但总有人会修改状态——例如登出流程、配置切换或标志更新处理程序。修改状态的位置已经处于协程上下文中,也是直接执行相关工作的自然位置。

// ✅ GOOD — no background loop, no scope, no class. The mutation site does the work.
class Authenticator(
    private val authStore: AuthStore,
    private val tokenInvalidator: TokenInvalidator,
) {
    suspend fun signOut() {
        authStore.clearTokens()
        tokenInvalidator.invalidate()   // direct call at the mutation site
    }
}

后台循环类会被删除。状态变化时,就在原处执行相关工作。

适用场景:状态的消费者有明确的生命周期(例如用例、Authenticator 或服务处理程序),并且可以在调用处直接执行响应逻辑。

模式 2——计划任务。 适用于真正定期执行或延迟执行的任务。使用 WorkManager / BGTaskScheduler。入队操作只执行一次;将其设为挂起函数,并从已在启动时运行的协调器中调用一次。

模式 3——明确命名的启动位置。 有时,消费者是没有可观察生命周期的同步 API(例如 OpenTelemetry 的 Sampler.shouldSample(...)、AIDL 存根扇出或广播接收器桥接)。观察逻辑必须放在某个支持协程的位置,但必须放在明确命名的调用位置,而不是类自身的 init 中。

// ✅ GOOD — work is named; an explicit call site owns the launch
@SingleIn(AppScope::class)
class OtelConfigurableSampler(...) : Sampler {
    @Volatile private var delegate: Sampler = ...

    suspend fun observeRate(featureFlags: FeatureFlags) {
        featureFlags.observe(OTEL_SAMPLING_RATE).collect { rate ->
            delegate = Sampler.traceIdRatioBased(rate.coerceIn(0.0, 1.0))
        }
    }

    override fun shouldSample(...) = delegate.shouldSample(...)
}

// wired explicitly at the OTel SDK init module:
applicationScope.launch { otelSampler.observeRate(featureFlags) }

适用场景:消费者是一个没有可观察生命周期、会调用你所提供的接口的同步 API。启动操作无法移入消费者,但仍必须位于明确命名的调用位置。

判断适用模式

“我能观察到消费者的生命周期吗?”

  • 能,而且消费者已经处于协程上下文中 → 模式 1。将订阅逻辑移入消费者;删除后台循环类。
  • 任务需要定期执行或延迟执行 → 模式 2。通过挂起函数入队,并且只调用一次。
  • 不能,消费者是没有可观察生命周期的同步 API → 模式 3。使用明确的启动位置,而不是 init。

如果看起来有第四种答案——例如“我想要一个 Bootable 接口来替我启动所有任务”——那只是多加了一层抽象的同一种反模式。重点在于让启动操作清晰可见;通过接口自动发现会违背这一点。

初始化器仍然可以使用——前提是它们只负责注册

当 initialize() 注册监听器或钩子时,Initializer 模式是正确的。问题在于 initialize() 启动了协程。

// ✅ GOOD Initializer — registers a contributor, doesn't launch
class FavouritesContributorInitializer @Inject constructor(
    private val registry: ContributorRegistry,
    private val favouritesContributor: FavouritesContributor,
) : Initializer {
    override fun initialize() {
        registry.register(favouritesContributor)
    }
}

Initializer.initialize() 不得 launch 协程。 如果你的实现会这样做,就需要考虑模式 1、2 或 3。

代码审查诊断
  • 启动时机在哪里定义?如果答案是“DI 实例化我的时候”,那就有问题。
  • 谁能观察任务是否正在运行?如果答案是“没人”,那就有问题。
  • 谁能停止或重启任务?如果答案是“没人”,那就有问题。
  • 读者能否通过 grep 找到启动位置?如果不能,那就有问题。

如果答案分别是“消费者/协调器/明确命名的调用位置”,那就没问题。

6. 吞掉 CancellationException

如果围绕 suspend 调用的 catch 子句会匹配 CancellationException(直接匹配,或通过 Exception / Throwable 匹配),却没有重新抛出异常,通常就会把取消变成悄无声息的成功。父协程会认为子协程已完成;子协程却仍在运行(或其副作用仍在发生);取消契约因此遭到破坏。

这与 §1 中存储作用域的错误有相同的失败模式,只是从另一端来看:§1 将工作从调用者的生命周期中隐藏起来;这里则是将取消从工作中隐藏起来。

// ❌ BAD — catches CancellationException, never rethrows
suspend fun fetch() {
    try {
        api.load()
    } catch (e: Exception) {           // matches CancellationException too
        logger.warn("load failed", e)
    }
}

// ❌ ALSO BAD — runCatching has the same problem
suspend fun fetch() {
    runCatching { api.load() }
        .onFailure { logger.warn("load failed", it) }
}

可接受的写法:

// ✅ Separate catch first
try { api.load() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { logger.warn("load failed", e) }

// ✅ Conditional rethrow inside the broad catch
try { api.load() }
catch (e: Exception) {
    if (e is CancellationException) throw e
    logger.warn("load failed", e)
}

// ✅ ensureActive() — good when the catch handles ordinary failures and you only need
// to rethrow if the current coroutine is cancelled
try { api.load() }
catch (e: Exception) {
    currentCoroutineContext().ensureActive()
    logger.warn("load failed", e)
}

// ✅ runCatching with explicit guard
runCatching { api.load() }
    .onFailure {
        if (it is CancellationException) throw it
        logger.warn("load failed", it)
    }

// ✅ runCatching terminated with getOrThrow (cancellation flows back out)
runCatching { api.load() }.getOrThrow()

触发条件是“try 中有挂起调用”,而不是“外围函数声明为 suspend”。这适用于任何可挂起的代码块中,包括 suspend fun、launch { … } lambda、Flow 的 collect { … } 等。

常见的例外情况是有意设置的局部超时:捕获自己调用的 withTimeout 所抛出的 TimeoutCancellationException,并将其转换为领域结果,可能是正确的。应将此 catch 限定在较小范围内,并放在超时逻辑附近。不要以此为由吞掉任意取消。

捕获非取消子类型(IOException、你自己的异常类型)没有问题——它们不是 CancellationException 的子类型。

7. runBlocking

runBlocking 会让当前线程停下来,直到 lambda 执行完毕。在支持挂起或受生命周期作用域约束的应用代码路径中,这种做法是错误的:原本应异步工作的线程现在被阻塞,结构化并发遭到破坏,上游的任何取消也都不会生效。这是最直接的“被调用方替调用方做结构性决定”反模式。

// ❌ BAD — bridging to suspend by blocking the calling thread
fun saveUser(user: User) {
    runBlocking { repository.save(user) }
}

根据使用场景,有三种修复方式:

支持挂起的应用代码——将函数改为 suspend:

// ✅ GOOD
suspend fun saveUser(user: User) = repository.save(user)

如果直接调用方也无法挂起(例如非挂起的 UI 回调或 BroadcastReceiver 钩子),则在边界处使用现有的、与生命周期绑定的作用域——参见 §3 中 UI ↔ 状态持有者的例外情况。修复应位于边界处,而不是 saveUser 内部。

确实存在合理的阻塞边界:CLI 工具中的 main、必须同步返回的 Java 互操作 API、没有挂起替代方案的框架回调,以及迁移垫片。将 runBlocking 放在最外层边界,保持其代码块简短,并立即调用挂起代码。

测试——使用 runTest:

// ❌ BAD — real time, slow tests, no virtual delay
@Test fun loadsUser() = runBlocking {
    assertThat(repository.load().name).isEqualTo("Alice")
}

// ✅ GOOD
@Test fun loadsUser() = runTest {
    assertThat(repository.load().name).isEqualTo("Alice")
}

runTest 提供虚拟时间(delay() 会立即返回)、TestDispatcher 集成,以及妥善的协程清理。在测试中使用实时的 runBlocking 会让测试变慢且不稳定。

ContentProvider 例外情况——从进程外部调用 Android 的 ContentProvider 方法(query、insert、update、delete、onCreate、call)时,这些方法都是同步的。它们无法挂起。在 ContentProvider 子类的成员函数中(直接或间接继承均可,不包括伴生对象),runBlocking 是不可避免的桥接方式。尽可能缩短代码块,并立即调用挂起代码:

// ✅ Acceptable in ContentProvider members only
class MyProvider : ContentProvider() {
    override fun query(...): Cursor? = runBlocking { dao.query(...) }
}

此例外情况仅适用于 android.content.ContentProvider 子类。“它和 ContentProvider 类似”并不适用;在 ContentProvider 的伴生对象中使用 runBlocking 仍然属于常规违规,因为该辅助函数不属于框架的同步接口。

快速参考

症状反模式修正方法
类中有 private val scope: CoroutineScope在被调用方中存储作用域删除。将公共 API 改为 suspend。
init { scope.launch { ... } }在构造时启动协程移至 suspend fun init() / login()
仓库/管理器/用例中的 fun foo() { scope.launch { ... } }在非 UI 类中启动后即不再关注改为 suspend fun foo(),让 UI 状态持有者选择作用域
状态持有者中的 fun onClick() { viewModelScope.launch { ... } },由 UI 调用UI ↔ 状态持有者边界——可以保持不变(见 §3 例外情况)
private val scope = MainScope()在内部创建并存储作用域同上——删除,将 API 改为 suspend
@SingleIn(AppScope) class X(scope) { init { scope.launch { … } } }绑定到 DI 的不透明协程启动(§5)暴露 suspend fun run(),由启动协调器启动
class Y : Initializer { override fun initialize() { scope.launch { … } } }启动协程而非注册任务的初始化器(§5)同上——改为 suspend fun run(),由协调器管理生命周期
try { suspendCall() } catch (e: Exception|Throwable|CancellationException) { … } 且未重新抛出异常吞掉取消信号(§6)优先使用 catch (e: CancellationException) { throw e };仅在符合预期时使用 ensureActive()
runCatching { suspendCall() }.onFailure { … } 且没有取消保护与上面相同的情况(§6)添加 if (it is CancellationException) throw it,或以 .getOrThrow() 结束
支持挂起的应用代码中出现 runBlocking { … }阻塞线程的桥接(§7)将调用方改为 suspend;或在边界处使用生命周期作用域
测试中出现 runBlocking { … }同上——实时桥接(§7)使用 runTest { … }
ContentProvider.query/insert/… 成员中出现 runBlocking { … }例外情况(§7)可以接受;保持方法体精简

重构指南

移除现有问题代码:

  1. 从叶子节点开始。 选择距离 UI 最远的类——通常是仓库或数据源。它的公共接口通常最容易转换。
  2. 逐个将公共函数改为 suspend。编译器会指出所有调用方。
  3. 在每个调用方处,审慎选择作用域: viewModelScope、lifecycleScope、coroutineScope { } 或显式的 job。这正是之前缺失的选择。
  4. 在没有任何代码再使用 CoroutineScope 构造函数参数后将其删除。 同时移除注入绑定。

不要试图在一个 MR 中修复所有类。移除反模式是一项渐进式工作。

不适用的情况

  • 吸收 UI 事件的 UI 状态持有者。 带有 fun onClick(...) { viewModelScope.launch { ... } } 的 ViewModel/Component/功能模型是正确的——这是框架所需的边界。参见 §3 例外情况。
  • 具有明确取消和错误策略的生命周期所有者。 Actor/服务、应用基础设施或应用级单例可以拥有作用域,前提是它们提供明确的 close/cancel/重启行为,或以其他方式直接对应应用生命周期。应显式注入 Application.applicationScope,而非临时创建作用域。这并不意味着可以从 init / initialize() 中启动协程——参见 §5。
  • 已经是挂起函数的 API 不需要进行这些改动。
  • 测试有时会有意将 TestScope 用作环境作用域——这是另一种模式,具有明确的虚拟时间控制。

审查中的危险信号

出现以下想法,说明反模式又出现了:

想法实际情况
“我只要给作用域添加一个 CoroutineExceptionHandler 就行了”问题不在于错误处理,而在于这个作用域本来就不该存在。
“我需要从 init 中启动协程,这样使用方到来时数据就准备好了”使用方读取尚未准备好的状态才是问题。使用分阶段处理。
“调用方不想处理 suspend”那就由调用方在自己的作用域中选择即发即弃。不要替他们做决定。
“这只是一个小小的即发即弃调用”取消可能被静默处理,因此每个即发即弃调用都可能静默失败。
“我们捕获并记录了异常,所以没问题”捕获异常时有没有重新抛出 CancellationException?如果没有,协程就会在未被取消的情况下静默继续。(§6)
“这只是一个 runBlocking,而且不在关键路径上”每个 runBlocking 都是在断言调用方没有异步选项。如果有,那它就不是合适的原语。(§7)
“用 runBlocking 编写测试更简单”测试会按实际时间运行,无法快进 delay,还会失去 TestDispatcher 的语义。请使用 runTest。(§7)

相关内容

评论

支持 Markdown