协程是什么?
协程并不是一个新的概念,它并不是 Kotlin 发明的。它们已经存在了几十年,并且在 Go 等其他一些编程语言中很受欢迎。
协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务的子程序,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务、异常处理、事件循环、迭代器、无限列表和管道。
根据高德纳的说法, 马尔文·康威于1958年发明了术语“coroutine”并用于构建汇编程序.
本文主要讲协程在 Kotlin 中实现的方式。事实上,在 Kotlin 中除了 suspend 关键字,没有任何其他关键字被添加到语言中。
这也是与其他语言的不同之处,例如 C# 将 async 以及 await 作为语法的一部分。而在 Kotlin 中,他们都只是库函数。
Kotlin 编写异步代码: suspend 函数
Kotlin 编写异步代码的方式是使用协程,这是一种计算可被挂起的想法。即一种函数可以在某个时刻暂停执行并稍后恢复的想法。
协程的一个好处是,当涉及到开发人员时,编写非阻塞代码与编写阻塞代码基本相同。编程模型本身并没有真正改变。
以下面的代码为例
fun postItem(item: Item) {
launch {
val token = preparePost()
val post = submitPost(token, item)
processPost(post)
}
}
suspend fun preparePost(): Token {
// 发起请求并挂起该协程
return suspendCoroutine { /* ... */ }
}
此代码将启动长时间运行的操作,而不会阻塞主线程。preparePost 就是所谓的 可挂起的函数,因此它含有 suspend 前缀。这意味着如上所述,该函数将被执行、暂停执行以及在某个时间点恢复。
该函数的签名保持完全相同。唯一的不同是它被添加了 suspend 修饰符。但是返回类型依然是我们想要的类型。
编写这段代码代码就好像我们正在编写同步代码,自上而下,不需要任何特殊语法,除了使用一个名为 launch 的函数,它实质上启动了该协程(在其他教程中介绍)。
编程模型和 API 保持不变。我们可以继续使用循环,异常处理等,而且不需要学习一整套新的 API。
它与平台无关。无论我们是面向 JVM,JavaScript 还是其他任何平台,我们编写的代码都是相同的。编译器负责将其适应每个平台。
Kotlin 中协程的实现原理
Kotlin 协程本质上是依托线程执行的。具体地说,当一个 Kotlin 协程被创建,底层会创建相应的 Task (Runnable 实例),然后将 Task 添加到任务队列(LockFreeTaskQueue),并发放一个许可证,唤醒一个 Worker。Kotlin 后台维护了共享线程池 CoroutineScheduler,里面有若干个 Worker 守护线程 (Thread 实例),Worker 会从任务队列获取 Task 并执行。
整个流程抽象如下:
线程 VS.协程
线程是内核态线程,协程是用户态线程。线程可直接调度 CPU 资源,而协程是运行在线程之上的:
1)相比线程,协程没有执行性能上的优势。
协程(用户态线程)底层是依托于线程(内核态线程)执行的。线程是 CPU 调度的基本单位,可直接参与 CPU 资源调度获得计算资源。协程是用户态实现,不具备直接参与 CPU 资源调度的能力,只能依托线程获得计算资源。本质上,协程是运行在线程之上的一段代码块,因此没有执行性能方面的优势。我做过一个简单的实验:分别创建 1000 个线程和 1000 个协程,并执行相同的操作。结果表明,两者执行时间基本相同,甚至线程会更快一些。
2)协程上下文切换和创建销毁开销比线程低,是一种更轻量的并发编程方式
从上下文切换角度,线程切换需要在用户态和内核态之间完成,而协程切换只需在用户态即可完成。用户态陷入内核态属于系统调用,是一个比较重的操作。因此,相比线程,协程的上下文切换开销更低。
从创建销毁角度,线程或协程都需要占用一定的内存资源。一个程序(进程)能创建多少个线程或协程,和 CPU 核数无关,与内存有关。粗略地,一个协程占用内存小于 10 KB,而一个线程占用内存接近甚至超过 1 MB(受平台或配置影响)。同样,我做过一个简单的实验:分别创建 10W 个线程和 10W 个协程。结果表明,创建 10W 个线程很快会导致 OOM,而 10W 个协程不会有任何问题。可见,协程和线程的内存占用是不同的,协程创建销毁开销比线程低。
3)协程是一种更简洁的异步编程方式。
多任务编程除了解决多任务并发处理问题,还可以解决异步问题。从异步编程角度,线程和协程最终产生的效果相似,但协程实现要简单得多。协程可像编写同步程序那样编写异步程序
实现异步编程的不同方式
几十年以来,作为开发人员,我们面临着需要解决的问题——如何防止我们的应用程序被阻塞。 当我们正在开发桌面应用,移动应用,甚至服务端应用程序时,我们希望避免让用户等待或导致更糟糕的原因成为阻碍应用程序扩展的瓶颈。
我们有很多途径来解决这种问题,包括:
- 线程
- 回调
- Futures,Promises 等
- 响应式扩展
- 协程
在解释协程的含义之前,让我们简要回顾一些其他解决方案。
线程
到目前为止,线程可能是最常见的避免应用程序阻塞的方法。
fun postItem(item: Item) {
val token = preparePost()
val post = submitPost(token, item)
processPost(post)
}
fun preparePost(): Token {
// 发起请求并因此阻塞了主线程
return token
}
让我们假设在上面的代码中,preparePost 是一个长时间运行的进程,因此会阻塞用户界面。我们可以做的是在一个单独的线程中启动它。这样就可以允许我们避免阻塞 UI。这是一种非常常见的技术,但有一系列缺点:
1.线程并非廉价的。线程需要昂贵的上下文切换。
2.线程不是无限的。可被启动的线程数受底层操作系统的限制。在服务器端应用程序中,这可能会导致严重的瓶颈。
3.线程并不总是可用。在一些平台中,比如 JavaScript 甚至不支持线程。
4.线程不容易使用。线程的 Debug,避免竞争条件是我们在多线程编程中遇到的常见问题。
回调
使用回调,我们的想法是将一个函数作为参数传递给另一个函数,并在处理完成后调用此函数。
fun postItem(item: Item) {
preparePostAsync { token ->
submitPostAsync(token, item) { post ->
processPost(post)
}
}
}
fun preparePostAsync(callback: (Token) -> Unit) {
// 发起请求并立即返回
// 设置稍后调用的回调
}
原则上这感觉就像一个更优雅的解决方案,但又有几个问题:
1.回调嵌套的难度。通常被用作回调的函数,经常最终需要自己的回调。这导致了一系列回调嵌套并导致出现难以理解的代码。该模式通常被称为标题圣诞树(大括号代表树的分支)。
2.错误处理很复杂。嵌套模型使错误处理和传播变得更加复杂。
回调在诸如 JavaScript 之类的事件循环体系结构中非常常见,但即使在那里,通常人们已经转而使用其他方法,例如 promises 或响应式扩展。
Futures,Promises 等
futures 或 promises 背后的想法(这也可能会根据语言/平台而有不同的术语),是当我们发起调用的时候,我们承诺在某些时候它将返回一个名为 Promise 的可被操作的对象。
fun postItem(item: Item) {
preparePostAsync()
.thenCompose { token ->
submitPostAsync(token, item)
}
.thenAccept { post ->
processPost(post)
}
}
fun preparePostAsync(): Promise<Token> {
// 发起请求并当稍后的请求完成时返回一个 promise
return promise
}
1.这种方法需要对我们的编程方式进行一系列更改,尤其是不同的编程模型。与回调类似,编程模型从自上而下的命令式方法转变为具有链式调用的组合模型。传统的编程结构例如循环,异常处理,等等。通常在此模型中不再有效。
2.不同的 API。通常这需要学习完整的新 API 诸如 thenCompose 或 thenAccept,这也可能因平台而异。
3.具体的返回值类型。返回类型远离我们需要的实际数据,而是返回一个必须被内省的新类型“Promise”。
4.异常处理会很复杂。错误的传播和链接并不总是直截了当的。
响应式扩展
Erik Meijer 介绍了 C# 中的响应式扩展(Rx)。虽然它在 .NET 平台上是毫无疑义的, 但是在 Netflix 将它移植到 Java 并取名为 RxJava 之前绝对不是主流。从那时起,响应式被移植到各种平台,包括 JavaScript(RxJS)。
Rx 背后的想法是走向所谓的“可观察流”,我们现在将数据视为流(无限量的数据),并且可以观察到这些流。 实际上,Rx 很简单, Observer Pattern 带有一系列扩展,允许我们对数据进行操作。
在方法上它与 Futures 非常相似,但是人们可以将 Future 视为一个离散元素,而 Rx 返回一个流。然而,与前面类似,它还介绍了一种全新的思考我们的编程模型的方式,著名的表述是:
“一切都是流,并且它是可被观察的”.
这意味着处理问题的方式不同,并且在编写同步代码时从我们使用的方式发生了相当大的转变。与 Futures 相反的一个好处是,它被移植到这么多平台,通常我们可以找到一致的 API 体验,无论我们使用 C#、Java、JavaScript,还是 Rx 可用的任何其他语言。
此外,Rx 确实引入了一种更好的错误处理方法。
总结
多任务编程对于开发者来说早已是家常便饭。在这个背景下,越来越多的新兴语言给出了自己对于多任务编程的解决方案,比如 Go 语言的 Goroutine,Kotlin 语言的 Coroutine 等。Java 虽然没有语言层面的协程实现,但并不意味着 Java 在多任务编程场景表现不出色,如 JUC、NIO 等。Java 语言本身也能解决协程要解决的问题,只是没把它做成简洁的语法糖而已。当然,协程内存占用小、切换开销低的好处是 Java 仅借助多线程做不到的。
要不要在项目中引入协程没有确定的答案,需要考虑项目本身是否需要协程的特性。对于 Java 项目来说,使用协程的一种方式是引入第三方库,另一种方式是引入 Kotlin,利用 Kotlin 可与 Java 混写的特性,在 Java 项目中使用协程。
参考资料
https://proandroiddev.com/kotlin-coroutines-channels-csp-android-db441400965fhttps://www.kotlincn.net/docs/tutorials/coroutines/async-programming.html
Kotlin 开发者社区
国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。
越是喧嚣的世界,越需要宁静的思考。