5.12.1 异步编程技术

原文链接: https://kotlinlang.org/docs/async-programming.html

5.12.1 异步编程技术

几十年来,作为开发者,我们一直要面对一个问题:如何避免应用阻塞。无论我们开发的是桌面、移动还是后端应用,都不希望让用户等待,更不希望制造阻碍应用扩展的瓶颈。

解决这个问题有许多方式,包括:

在解释什么是协程之前,我们先简要回顾其他几种解决方案。

线程

线程大概是避免应用阻塞最为人熟知的方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun postItem(item: Item) {
    val token = preparePost()
    val post = submitPost(token, item)
    processPost(post)
}

fun preparePost(): Token {
    // 发起请求,因此会阻塞主线程
    return token
}

假设上面的代码中 preparePost 是一个长时间运行的过程,因此会阻塞用户界面。我们可以把它放到单独的线程中启动,这样就避免了 UI 阻塞。这是一项非常常见的技术,但它有一系列缺点:

  • 线程并不廉价。线程需要上下文切换,而这是有代价的。
  • 线程不是无限的。可启动的线程数量受底层操作系统限制。在后端应用中,这可能造成严重的瓶颈。
  • 线程并非总是可用。有些平台(例如 JavaScript)甚至不支持线程。
  • 线程并不简单。调试线程、避免竞态条件,是我们在多线程编程中经常遇到的难题。

回调

回调的思路是把一个函数作为参数传给另一个函数,并在处理完成后调用它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun postItem(item: Item) {
    preparePostAsync { token ->
        submitPostAsync(token, item) { post ->
            processPost(post)
        }
    }
}

fun preparePostAsync(callback: (Token) -> Unit) {
    // 发起请求并立即返回
    // 安排回调稍后被调用
}

这在原理上似乎是一种更优雅的解决方案,但同样存在几个问题:

  • 嵌套回调难以处理。通常,作为回调使用的函数往往又需要自己的回调。这会形成一连串嵌套回调,导致代码难以理解。这种模式常被称为回调地狱,或者厄运金字塔,因为这种深层嵌套回调产生的缩进会形成三角形。
  • 错误处理很复杂。嵌套模型让错误处理和传播变得有些复杂。

回调在 JavaScript 这类事件循环架构中相当常见,但即便在那里,人们也普遍转向了 promises 或响应式扩展等其他方式。

Futures、promises 及其他

futures 或 promises(不同语言或平台可能使用其他术语)背后的想法是:当我们发起调用时,就被承诺在某个时刻该调用会返回一个 Promise 对象,然后我们可以对它进行操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
fun postItem(item: Item) {
    preparePostAsync()
        .thenCompose { token ->
            submitPostAsync(token, item)
        }
        .thenAccept { post ->
            processPost(post)
        }

}

fun preparePostAsync(): Promise<Token> {
    // 发起请求并返回一个稍后完成的 promise
    return promise
}

这种方式要求我们在编程方式上做出一系列改变,特别是:

  • 不同的编程模型。与回调类似,编程模型从自上而下的命令式方式转向带有链式调用的组合式模型。循环、异常处理等传统程序结构在这种模型中通常不再适用。
  • 不同的 API。通常需要学习全新的 API,例如 thenCompose 或 thenAccept,而且这些 API 在不同平台上也可能不同。
  • 特定的返回类型。返回类型不再是我们真正需要的数据,而是一个需要进一步检视的新类型 Promise。
  • 错误处理可能很复杂。错误的传播和链式处理并不总是那么直接。

响应式扩展

响应式扩展(Rx)由 Erik Meijer 引入 C#。虽然它确实在 .NET 平台上被使用,但真正进入主流是在 Netflix 把它移植到 Java 并命名为 RxJava 之后。此后,各种平台都出现了大量移植版本,包括 JavaScript(RxJS)。

Rx 背后的想法是走向所谓的 observable streams(可观察流):我们把数据视为流(无限量的数据),而这些流可以被观察。就实际应用而言,Rx 其实就是观察者模式加上一系列允许我们对数据进行操作的扩展。

在思路上它与 Futures 颇为相似,不过可以把 Future 看作返回一个离散元素,而 Rx 返回的是流。然而与前面几种方式一样,它也引入了一套全新的编程模型思维方式,其著名表述是:

“一切皆是流,并且可被观察”

这意味着要用不同的方式看待问题,与我们在编写同步代码时的习惯有相当大的转变。与 Futures 相比的一个好处是,由于它被移植到众多平台,无论你使用 C#、Java、JavaScript 还是任何可用 Rx 的语言,通常都能获得一致的 API 体验。

此外,Rx 确实在错误处理方面提供了相对更好的方式。

协程

Kotlin 处理异步代码的方式是使用协程,即"可挂起的计算"这一理念,也就是说函数可以在某个时刻挂起其执行,稍后再恢复。

不过,协程的好处之一是:对开发者而言,编写非阻塞代码与编写阻塞代码基本上没有区别。编程模型本身并不会真正改变。

例如下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun postItem(item: Item) {
    launch {
        val token = preparePost()
        val post = submitPost(token, item)
        processPost(post)
    }
}

suspend fun preparePost(): Token {
    // 发起请求并挂起协程
    return suspendCoroutine { /* ... */ }
}

这段代码会启动一个长时间运行的操作,而不会阻塞主线程。preparePost 就是所谓的 suspendable function(可挂起函数),因此前缀了 suspend 关键字。如上所述,这意味着该函数会执行、暂停执行,并在某个时刻恢复。

  • 函数签名保持完全不变,唯一的区别是加上了 suspend。不过返回类型仍然是我们希望返回的类型。
  • 代码仍然像编写同步代码那样自上而下书写,除了使用一个名为 launch 的函数(用于启动协程,详见其他教程)之外,不需要任何特殊语法。
  • 编程模型和 API 保持不变。我们可以继续使用循环、异常处理等,无需学习一整套新 API。
  • 它与平台无关。无论目标平台是 JVM、JavaScript 还是其他平台,我们写的代码都一样。在底层,编译器负责把它适配到各个平台。

协程并不是新概念,更不是 Kotlin 发明的。它已经存在了几十年,并在 Go 等其他一些编程语言中很流行。不过需要强调的是,在 Kotlin 中的实现方式下,大部分功能都委托给了库。事实上,除了 suspend 关键字之外,语言没有添加其他关键字。这与 C# 等把 async 和 await 作为语法一部分的语言有所不同。在 Kotlin 中,它们只是库函数。

更多信息请参阅协程参考。