[译] Kotlin 、协程、结构化并发

5,600 阅读6分钟

今天 (2018/09/12) 是 kotlinx.coroutines 0.26.0 版本的发布日,同时在这里对 Kotlin 协程的「结构化并发」做一些介绍。它不仅仅是一个功能改变——它标志着编程风格的巨大改变,我写这篇文章就是为了解释这一点。

在 Kotlin 1.1 也就是 2017年初, 首次推出协程作为实验性质的特性开始,我们一直在努力向程序员解释协程的概念,他们过去常常使用线程理解并发,所以我们举的例子和标语是"协程是轻量级线程"。

此外,我们的关键 api 被设计为类似于线程 api,以简化学习曲线。这种举例在小规模例子中很适用,但是它不能帮助解释协程编程风格的转变。

当我们学习使用线程编程时,我们被告知线程是昂贵的资源,不应该到处创建它们。一个优雅的程序通常在启动时创建一个线程池然后使用它们搞些事情。有些环境(尤其是 iOS)甚至说"不赞成使用线程"(即使所有的东西仍然在线程上运行)。它们提供了一个系统内的随时可用的线程池,其中包含可向其提交代码的相应队列。

但是协程的情况不同。它可以非常方便地创建很多你需要的协程,因为它们非常廉价。让我们看一下协程的几个用例。

异步操作(Asynchronous operations)

假设你正在写一个前端 UI 应用(移动端、web 端或桌面端——对于这个例子并不重要),并且需要向后端发送一个请求,以获取一些数据并使用结果更新 UI 模型。我们最初推荐这样写:

fun requestSomeData() {
    launch(UI) {
        updateUI(performRequest())
    }
}

这里,我们使用 launch(UI) 在 UI 上下文中启动一个新的协程,调用performRequest 挂起函数对后端执行异步调用,而不阻塞主 UI 线程,然后使用结果更新 UI。每个 requestSomeData 调用都创建自己的协程,这很好,不是吗?它和 C#、JS 和 GO 中的异步编程并没有太大的不同。

但是这里有个问题。如果网络或后端出现问题,这些异步操作可能需要很长时间才能完成。此外,这些操作通常在一些 UI 元素(比如窗口或页面)的范围内执行。如果一个操作花费的时间太长,通常用户会关闭相应的 UI 元素并执行其他操作,或者更糟糕的是,重新打开这个 UI 并一次又一次地尝试该操作。但是前面的操作仍然在后台运行,所以当用户关闭相应的 UI 元素时,我们需要某种机制来取消它。在 Kotlin 协程中,这导致我们推荐了一些非常棘手的设计模式,人们必须在代码中遵循这些模式,以确保正确处理这种取消。此外,你总是必须记住指定适当的上下文,否则 updateUI 可能会被错误的线程调用,从而破坏 UI。这是很容易出错的。一个简单的launch{ ... } 很轻松就写出来,但是你不应该写成这样。

在更哲学的层面上,我们很少像线程那样"全局"地启动协程。协程总是与应用程序中的某个局部作用域相关,这个局部作用域是一个生命周期有限的实体,比如 UI 元素。因此,对于结构化并发,我们现在要求在一个协程作用域中调用 launch,协程作用域是由你的生命周期有限的对象(如 UI 元素或它们相应的视图模型)实现的接口。你的 UI 元素实现一次协程作用域后, 你会发现,在你的 UI 类中就能轻松的使用的 launch{ … } ,然后你可以愉快的写很多次,并且不容易出错:

fun requestSomeData() {
    launch {
        updateUI(performRequest())
    }
}

注意,协程作用域的实现还为 UI 更新定义了适当的协程上下文。你可以在其文档页面上找到一个完整的协程作用域实现示例。对于一些比较少见的情况,你需要一个全局协程,它的生命周期受整个应用生命周期限制,我们现在提供了 GlobalScope (全局作用域)对象,因此以前全局协程的launch{ … } 变成了 GlobalScope.launch { … } ,协程的"全局"含义变得直观了。

并行分解(Parallel decomposition)

我已经就 Kotlin 协程进行了多次 讨论,,下面的示例代码展示了如何并行加载两个图片并在稍后将它们组合起来——这是一个使用 Kotlin 协程并行分解工作的惯用示例:

suspend fun loadAndCombine(name1: String, name2: String): Image { 
    val deferred1 = async { loadImage(name1) }
    val deferred2 = async { loadImage(name2) }
    return combineImages(deferred1.await(), deferred2.await())
}

不幸的是,这个例子在很多层面上都是错误的。挂起函数loadAndCombine 本身将从一个已经启动的执行更大操作的协程内部调用。如果这个操作被取消了呢?然后加载这两个图片仍然没有收到影响。这不是我们想从可靠代码中的得到的,特别是如果这些代码是许多客户端使用后端服务的一部分。

我们推荐的解决方案是写成这样async(conroutineContext){ … } ,以便在子协程中加载两个图片,当父协程被取消时,子协程将被取消。

它仍然不完美。如果加载第一个图片失败,那么 deferred1.await() 将抛出相应的异常,但是加载第二个图片的第二个 async 协程仍然在后台工作。解决这个问题就更复杂了。

我们在第二个用例中看到了同样的问题。一个简单的 async { … } 很容易写,但是你不应该写成这样。

使用结构化并发,async 协程构建器就像 luanch 一样,变成了协程作用域上的一个扩展。你不能再简单的编写 async{ … } ,你必须提供一个作用域。并行分解的一个恰当的例子是:

suspend fun loadAndCombine(name1: String, name2: String): Image =
    coroutineScope { 
        val deferred1 = async { loadImage(name1) }
        val deferred2 = async { loadImage(name2) }
        combineImages(deferred1.await(), deferred2.await())
    }

你必须将代码封装到 coroutineScope { ... } 块中,这个块为你的操作及其范围建立了边界。所有异步协程都成为这个范围的子协程,如果该作用域因为异常导致失败或被取消了,它所有的子协程也将被取消。

进一步的阅读

结构化并发的概念背后有更多的哲学。我强烈推荐阅读 “结构化并发的注意事项,或:Go 语句的危害” ,它很好的对比了经典的 goto-statement 和结构化编程。

现代语言在刚开始时为我们提供一种以完全非结构化启动并发任务的方式,这玩意该结束了。