在Kotlin中创建一个子协同程序作用域



短篇故事

我想知道是否有或多或少的标准方法来创建协同程序上下文/范围,比如:

  • 它是当前用于结构化并发的协同程序的子级
  • 它可以存储在一些属性等中,然后用于运行异步任务,例如launch()

coroutineScope()正是我所需要的,它创建了一个子作用域,但它并不是简单地将其返回给调用方——我们需要传递一个lambda,并且coroutine生存期仅限于此lambda的执行。另一方面,CoroutineScope()工厂创建了一个长期运行的作用域,我可以将其存储起来以备将来使用,但它与当前的协同程序无关。

我能够手动创建这样一个范围:

suspend fun createChildCoroutineScope(): CoroutineScope {
val ctx = coroutineContext
return CoroutineScope(ctx + Job(ctx.job))
}

乍一看,它似乎正是我所需要的。它是否等同于coroutineScope()所做的,或者我的解决方案在某种程度上不完整,我应该执行一些额外的任务?我试着阅读coroutineScope()的源代码,但它相当复杂。有没有一种更简单或更标准的方法来创建子作用域?

此外,这被认为是一种不良做法还是反模式?我只是担心,如果还没有这样一个简单的函数,那么可能是有原因的,我真的不应该这样使用协程。

用例(较长的故事)

通常,当我实现某种可以异步调度后台操作的长时间运行的服务时,我会看到需要这样做:

class MyService {
fun scheduleSomeTask() {
// start task in the background
// return immediately
}
}

有几种可能性可以通过协同程序做到这一点:

  1. GlobalScope,但它是坏的。

  2. 使scheduleSomeTask()可挂起,并使用当前协同程序运行后台任务。在许多情况下,我认为这种方法实际上并不合适:

    • 后台任务是";拥有";由调用者而不是由服务本身。例如,如果我们停止服务,后台任务仍将运行
    • 它要求调度功能是可挂起的。我认为这是错误的,因为我真的看不出为什么一些Java代码或协同程序上下文之外的代码不应该被允许在我的服务中调度任务
  3. 给我的服务一个定义的生命周期,在停止/销毁时用CoroutineScope()cancel()创建作用域。这很好,但我认为我们仍然可以从协同程序的结构化并发中受益,所以对我来说,我的服务是分离的是一个缺点。

    例如,我们有一个文件下载服务,它由(拥有)其他服务组成,包括数据缓存服务。在start()/stop()服务的典型方法中,我们需要手动控制生命周期,并且很难正确处理故障。推论使它更容易:如果缓存服务崩溃,它会自动传播到下载服务;如果下载服务需要停止,它只需取消协同程序,就可以确保不会泄露任何子组件。因此,对我来说,在设计由几个小服务组成的应用程序时,协程的结构化并发性可能非常有用。

我目前的方法类似于:

class MyService {
private lateinit var coroutine : CoroutineScope
suspend fun start() {
coroutine = createChildCoroutineScope() + CoroutineName("MyService")
}
fun stop() {
coroutine.cancel()
}
fun scheduleSomeTask() {
coroutine.launch {
// do something
}
}
}

或者:

class MyService(
private val coroutine: CoroutineScope
) {
companion object {
suspend fun start() = MyService(createChildCoroutineScope())
}
}

通过这种方式;拦截;启动它并将其后台操作附加到它的协同程序。但正如我所说,我不确定这是否出于某种原因被认为是反模式。

此外,我知道我的createChildCoroutineScope()有潜在的危险。通过调用它,我们使当前的协同程序变得不复杂。这可能就是库中不存在这样一个函数的原因。另一方面,这与做一些类似的事情没有什么不同:

launch {
while (true) {
socket.accept() // assume it is suspendable, not blocking
// launch connection handler
}
} 

事实上,从技术角度来看,这两种方法非常相似。它们具有相似的并发结构,但我相信"我的";这种方法通常更干净、更强大。

我找到了一个非常好的答案和解释。Roman Elizarov在他的一篇文章中准确地讨论了我的问题:https://elizarov.medium.com/coroutine-context-and-scope-c8b255d59055

他解释说,虽然从技术上讲;捕获";一个挂起函数的当前上下文,并使用它来启动后台协同程序,强烈建议这样做:

不要这样做!它使得启动协同程序的范围不透明且隐含,捕获一些外部Job来启动新的协同程序,而不在函数签名中明确宣布它。协程是与代码的其余部分并发的一项工作,它的启动必须是明确的。

如果您需要在函数返回后启动一个持续运行的协程,那么将函数作为CoroutineScope的扩展,或者将scope: CoroutineScope作为参数传递,以在函数签名中明确您的意图。不要使这些功能暂停。

我知道我可以将CoroutineScope/CoroutineContext传递给函数,但我认为挂起函数将是一种更短、更优雅的方法。然而,上面的解释很有道理。如果我们的函数需要获取调用方的协同程序范围/上下文,请明确这一点——这再简单不过了。

这也与";热的"/"冷的";处决暂停函数的一个伟大之处在于,它们允许我们轻松地创建";冷的";长期运行任务的实现。虽然我认为协同程序文档中没有明确规定挂起函数应该是"挂起";冷";,满足这一要求通常是个好主意,因为我们的挂起函数的调用方可能会认为它是"挂起";冷";。捕获协同程序上下文使我们的函数";热";,因此应该通知呼叫者这一点。

IMHO如果您正在将协同程序与一些外部生命周期管理(例如Spring的bean处理方法)进行桥接,那么使用您的解决方案是非常好的。或者更好地创建一个范围工厂:

fun Any.childScopeOf(
parent: ApplicationScope,
context: CoroutineContext = EmptyCoroutineContext
) = parent.childScope(this, context) { Job(it) }
fun Any.supervisorChildScopeOf(
parent: ApplicationScope,
context: CoroutineContext = EmptyCoroutineContext
) = parent.childScope(this, context) { SupervisorJob(it) }
@Component
class ApplicationScope {
// supervisor to prevent failure in one subscope from failing everyting
private val rootJob = SupervisorJob()
internal fun childScope(
ref: Any,
context: CoroutineContext,
job: (Job) -> Job
): CoroutineScope {
val name = ref.javaClass.canonicalName
log("Creating child scope '$name'")
val job = job(rootJob)
job.invokeOnCompletion { error ->
when (error) {
null, is CancellationException -> log("Child scope '$name' stopped")
else -> logError(error, "Scope '$name' failed")
}
}
return CoroutineScope(context + job + CoroutineName(name))
}
@PreDestroy
internal fun stop() {
rootJob.cancel()
runBlocking {
rootJob.join()
}
}
}

用法示例:

@Component
MyComponent(appScope: ApplicationScope) {
private val scope = childScopeOf(appScope) // no need to explicitly dispose this scope - parent ApplicationScope will take care of it
}

最新更新