- 原文连接:Structured concurrency
- 原文做者:Roman Elizarov
今天 (2018/09/12) 是 kotlinx.coroutines 0.26.0
版本的发布日,同时在这里对 Kotlin 协程的「结构化并发」作一些介绍。它不只仅是一个功能改变——它标志着编程风格的巨大改变,我写这篇文章就是为了解释这一点。html
在 Kotlin 1.1 也就是 2017年初, 首次推出协程做为实验性质的特性开始,咱们一直在努力向程序员解释协程的概念,他们过去经常使用线程理解并发,因此咱们举的例子和标语是"协程是轻量级线程"。前端
此外,咱们的关键 api 被设计为相似于线程 api,以简化学习曲线。这种举例在小规模例子中很适用,可是它不能帮助解释协程编程风格的转变。git
当咱们学习使用线程编程时,咱们被告知线程是昂贵的资源,不该该处处建立它们。一个优雅的程序一般在启动时建立一个线程池而后使用它们搞些事情。有些环境(尤为是 iOS)甚至说"不同意使用线程"(即便全部的东西仍然在线程上运行)。它们提供了一个系统内的随时可用的线程池,其中包含可向其提交代码的相应队列。程序员
可是协程的状况不一样。它能够很是方便地建立不少你须要的协程,由于它们很是廉价。让咱们看一下协程的几个用例。github
假设你正在写一个前端 UI 应用(移动端、web 端或桌面端——对于这个例子并不重要),而且须要向后端发送一个请求,以获取一些数据并使用结果更新 UI 模型。咱们最初推荐这样写:web
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 { … }
,协程的"全局"含义变得直观了。api
我已经就 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
和结构化编程。
现代语言在刚开始时为咱们提供一种以彻底非结构化启动并发任务的方式,这玩意该结束了。