Edit Page

异步编程(Asynchronous Programming)技术

最终更新: 2024/03/21

过去几十年来, 作为开发者, 我们始终面对一个问题需要解决 - 怎么样才能让我们的应用程序不要发生阻塞. 无论我们在开发桌面应用程序, 移动应用程序, 甚至服务器端的应用程序, 我们都希望避免让用户等待甚至更糟的情况, 因为会导致瓶颈, 使得应用程序无法扩展到更大规模.

现在已经有了很多种方案来解决这个问题, 包括:

在解释协程之前, 先让我们简单回顾一下其他解决方案.

线程(Thread)

要避免程序阻塞, 线程(Thread)可能是大家最熟悉的解决方案.

fun postItem(item: Item) {
    val token = preparePost()
    val post = submitPost(token, item)
    processPost(post)
}

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

我们假设上面的代码中的 preparePost 是一个长时间执行的处理, 因此会阻塞 UI. 我们可以在一个独立的线程中启动它. 这样我们就可以避免 UI 阻塞. 这是非常常见的技术, 但有很多缺点:

  • 线程代价高昂. 线程需要 context 切换, 这个代价很高.
  • 线程不是无限的. 底层的操作系统限制了能够启动的线程数量. 在服务器端应用程序中, 这个限制可能导致显著的瓶颈.
  • 线程并不总是可用的. 在某些平台, 比如 JavaScript, 甚至根本不支持线程.
  • 线程使用困难. 调试线程, 以及避免竞争条件, 都是我们在多线程编程中遭遇的常见问题.

回调(Callback)

使用回调, 基本想法是将回调函数作为参数传给另一个函数, 然后在处理结束后调用这个回调函数.

fun postItem(item: Item) {
    preparePostAsync { token ->
        submitPostAsync(token, item) { post ->
            processPost(post)
        }
    }
}

fun preparePostAsync(callback: (Token) -> Unit) {
    // 发起请求, 并立即返回
    // 调度回调函数, 在之后的时刻调用它
}

这个原则感觉好像是更加优雅的解决方案, 但仍然存在一系列的问题:

  • 回调的嵌套很困难. 通常来说, 被用作回调的函数, 经常会需要它自己的回调. 因此导致一系列的嵌套回调, 代码极难理解. 这种模式经常被称为 "圣诞树" (代码中的括号相当于圣诞树的树枝).
  • 错误处理很复杂. 嵌套模型导致错误的处理和传播变得更加复杂.

在事件循环架构中, 比如 JavaScript, 回调是非常常见的, 但即使在这种场景, 通常人们也会改为使用其他方案, 比如 Promise 或 Reactive Extension.

Future, Promise, 以及其他

Future 或 Promise (其他语言和平台上也可能会使用别的名称), 背后的理念是, 当我们发起一个调用, 它向我们承诺, 在某个时间点它会返回一个对象, 称为 Promise, 然后我们可以对它进行操作.

fun postItem(item: Item) {
    preparePostAsync()
        .thenCompose { token ->
            submitPostAsync(token, item)
        }
        .thenAccept { post ->
            processPost(post)
        }

}

fun preparePostAsync(): Promise<Token> {
    // 发起请求, 并返回一个 promise, 它会在之后的时刻完成
    return promise
}

这个方案要求我们的编程方式发生很多变化, 具体来说是:

  • 不同的编程模型. 与回调类似, 编程模型不再是自顶向下的命令模式(top-down imperative approach), 而是变为一种由链式调用构成的组合模式(compositional model). 传统的编程结构比如循环, 异常处理, 等等, 在这种模式中通常不再可用了.
  • 不同的 API. 通常需要学习完全不同的新 API, 比如 thenComposethenAccept, 而且这些函数在不同的平台上也可能存在差异.
  • 特殊的返回类型. 返回类型不再是我们需要的实际数据, 而是一个新的类型 Promise, 我们需要从它得到数据.
  • 错误处理很复杂. 错误的传播和链条通常很不直观.

Reactive Extension

Erik Meijer 将Reactive Extension (Rx) 引入到了 C# 中. 尽管它在 .NET 平台得到了大量应用, 但并没有被主流开发者采用, 直到 Netflix 将它移植到 Java, 命名为 RxJava. 在那之后, 对很多平台有了大量的移植, 包括 JavaScript (RxJS).

Rx 背后的理念是所谓 可观察的流(observable stream), 我们将数据看作流(stream)(包含无限数量的数据), 而且可以观察这些流. 从实践层面来讲, Rx 只不过是 观察者模式(Observer Pattern), 并带有一系列的扩展, 使得我们可以对数据进行操作.

这个方案与 Future 很类似, 但 Future 可以被看作是返回一个单独的元素, Rx 则返回一个流. 但是, 与前面的方案类似, Rx 也带来了编程模型的全新的理念, 如同下面这句名言所说:

"任何东西都是流, 而且可以观察"

这表示要用不同的方式来解决问题, 与我们以前编写同步代码相比, 编程方式发生显著的变化. 有一个优点是, 与 Future 不同, 由于 Rx 被移植到了很多平台, 因此不论编程语言是 C#, Java, JavaScript, 还是可以使用 Rx 的任何其他语言, 通常我们可以得到一致的 API 体验.

此外, Rx 还引入了比较好的错误处理方案.

协程(Coroutine)

Kotlin 的异步编程解决方案是使用协程(Coroutine), 它的理念是可被挂起的一段计算, 也就是说, 一个函数的执行在某个时刻可以被挂起, 并在之后的某个时刻恢复运行.

协程的优点之一是, 对于开发者来说, 非阻塞代码的编写方式与编写阻塞代码基本上是一样的. 编程模型本身并没有发生变化.

以下面的代码为例:

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 关键字, Kotlin 语言没有添加其他关键字. 这一点与其他语言不同, 比如 C# 的语法添加了 asyncawait. 而在 Kotlin 中, 这些只是库函数.

更多详情, 请参见 协程参考文档.