Kotlin協程

一些 API 啓動長時間運行的操作(例如網絡 IO、文件 IO、CPU 或 GPU 密集型任務等),並要求調用者阻塞直到它們完成。協程提供了一種避免阻塞線程並用更廉價、更可控的操作替代線程阻塞的方法:協程掛起

在 Kotlin 1.1 中協程是實驗性的。詳見下文

協程通過將複雜性放入庫來簡化異步編程。程序的邏輯可以在協程中順序地表達,而底層庫會爲我們解決其異步性。該庫可以將用戶代碼的相關部分包裝爲回調、訂閱相關事件、在不同線程(甚至不同機器!)上調度執行,而代碼則保持如同順序執行一樣簡單。

許多在其他語言中可用的異步機制可以使用 Kotlin 協程實現爲庫。這包括源於 C# 和 ECMAScript 的 async/await、源於 Go 的 管道select 以及源於 C# 和 Python 生成器/yield。關於提供這些結構的庫請參見其下文描述。

阻塞 vs 掛起

基本上,協程計算可以被掛起而無需阻塞線程。線程阻塞的代價通常是昂貴的,尤其在高負載時,因爲只有相對少量線程實際可用,因此阻塞其中一個會導致一些重要的任務被延遲。

另一方面,協程掛起幾乎是無代價的。不需要上下文切換或者 OS 的任何其他干預。最重要的是,掛起可以在很大程度上由用戶庫控制:作爲庫的作者,我們可以決定掛起時發生什麼並根據需求優化/記日誌/截獲。

另一個區別是,協程不能在隨機的指令中掛起,而只能在所謂的掛起點掛起,這會調用特別標記的函數。

掛起函數

當我們調用標記有特殊修飾符 suspend 的函數時,會發生掛起:

suspend fun doSomething(foo: Foo): Bar {
    ……
}

這樣的函數稱爲掛起函數,因爲調用它們可能掛起協程(如果相關調用的結果已經可用,庫可以決定繼續進行而不掛起)。掛起函數能夠以與普通函數相同的方式獲取參數和返回值,但它們只能從協程和其他掛起函數中調用。事實上,要啓動協程,必須至少有一個掛起函數,它通常是匿名的(即它是一個掛起 lambda 表達式)。讓我們來看一個例子,一個簡化的 async() 函數(源自 kotlinx.coroutines 庫):

fun <T> async(block: suspend () -> T)

這裏的 async() 是一個普通函數(不是掛起函數),但是它的 block 參數具有一個帶 suspend 修飾符的函數類型: suspend () -> T。所以,當我們將一個 lambda 表達式傳給 async() 時,它會是掛起 lambda 表達式,於是我們可以從中調用掛起函數:

async {
    doSomething(foo)
    ……
}

繼續該類比,await() 可以是一個掛起函數(因此也可以在一個 async {} 塊中調用),該函數掛起一個協程,直到一些計算完成並返回其結果:

async {
    ……
    val result = computation.await()
    ……
}

更多關於 async/await 函數實際在 kotlinx.coroutines 中如何工作的信息可以在這裏找到。

請注意,掛起函數 await()doSomething() 不能在像 main() 這樣的普通函數中調用:

fun main(args: Array<String>) {
    doSomething() // 錯誤:掛起函數從非協程上下文調用
}

還要注意的是,掛起函數可以是虛擬的,當覆蓋它們時,必須指定 suspend 修飾符:

interface Base {
    suspend fun foo()
}

class Derived: Base {
    override suspend fun foo() { …… }
}

[@RestrictsSuspension](https://github.com/RestrictsSuspension "@RestrictsSuspension") 註解

擴展函數(和 lambda 表達式)也可以標記爲 suspend,就像普通的一樣。這允許創建 DSL 及其他用戶可擴展的 API。在某些情況下,庫作者需要阻止用戶添加新方式來掛起協程。

爲了實現這一點,可以使用 @RestrictsSuspension 註解。當接收者類/接口 R 用它標註時,所有掛起擴展都需要委託給 R 的成員或其它委託給它的擴展。由於擴展不能無限相互委託(程序不會終止),這保證所有掛起都通過調用 R 的成員發生,庫的作者就可以完全控制了。

這在少數情況是需要的,當每次掛起在庫中以特殊方式處理時。例如,當通過 buildSequence() 函數實現下文所述的生成器時,我們需要確保在協程中的任何掛起調用最終調用 yield()yieldAll() 而不是任何其他函數。這就是爲什麼 SequenceBuilder[@RestrictsSuspension](https://github.com/RestrictsSuspension "@RestrictsSuspension") 註解:

@RestrictsSuspension
public abstract class SequenceBuilder<in T> {
    ……
}

參見其 Github 上 的源代碼。

協程的內部機制

我們不是在這裏給出一個關於協程如何工作的完整解釋,然而粗略地認識發生了什麼是相當重要的。

協程完全通過編譯技術實現(不需要來自 VM 或 OS 端的支持),掛起通過代碼來生效。基本上,每個掛起函數(優化可能適用,但我們不在這裏討論)都轉換爲狀態機,其中的狀態對應於掛起調用。剛好在掛起前,下一狀態與相關局部變量等一起存儲在編譯器生成的類的字段中。在恢復該協程時,恢復局部變量並且狀態機從剛好掛起之後的狀態進行。

掛起的協程可以作爲保持其掛起狀態與局部變量的對象來存儲和傳遞。這種對象的類型是 Continuation,而這裏描述的整個代碼轉換對應於經典的延續性傳遞風格(Continuation-passing style)。因此,掛起函數有一個 Continuation 類型的額外參數作爲高級選項。

關於協程工作原理的更多細節可以在這個設計文檔中找到。在其他語言(如 C# 或者 ECMAScript 2016)中的 async/await 的類似描述與此相關,雖然它們實現的語言功能可能不像 Kotlin 協程這樣通用。

協程的實驗性狀態

協程的設計是實驗性的,這意味着它可能在即將發佈的版本中更改。當在 Kotlin 1.1 中編譯協程時,默認情況下會報一個警告:「協程」功能是實驗性的。要移出該警告,你需要指定 opt-in 標誌。

由於其實驗性狀態,標準庫中協程相關的 API 放在 kotlin.coroutines.experimental 包下。當設計完成並且實驗性狀態解除時,最終的 API 會移動到 kotlin.coroutines,並且實驗包會被保留(可能在一個單獨的構件中)以實現向後兼容。

重要注意事項:我們建議庫作者遵循相同慣例:給暴露基於協程 API 的包添加「experimental」後綴(如 com.example.experimental),以使你的庫保持二進制兼容。當最終 API 發佈時,請按照下列步驟操作:

  • 將所有 API 複製到 com.example(沒有 experimental 後綴),
  • 保持實驗包的向後兼容性。

這將最小化你的用戶的遷移問題。

標準 API

協程有三個主要組成部分:

  • 語言支持(即如上所述的掛起功能),
  • Kotlin 標準庫中的底層核心 API,
  • 可以直接在用戶代碼中使用的高級 API。

底層 API:kotlin.coroutines

底層 API 相對較小,並且除了創建更高級的庫之外,不應該使用它。 它由兩個主要包組成:

關於這些 API 用法的更多細節可以在這裏找到。

kotlin.coroutines 中的生成器 API

kotlin.coroutines.experimental 中僅有的「應用程序級」函數是

這些包含在 kotlin-stdlib 中因爲他們與序列相關。這些函數(我們可以僅限於這裏的 buildSequence())實現了 生成器 ,即提供一種廉價構建惰性序列的方法:

kotlin import kotlin.coroutines.experimental.* fun main(args: Array<String>) { //sampleStart val fibonacciSeq = buildSequence { var a = 0 var b = 1 yield(1) while (true) { yield(a + b) val tmp = a + b a = b b = tmp } } //sampleEnd // 輸出前五個斐波納契數字 println(fibonacciSeq.take(8).toList()) }

這通過創建一個協程生成一個惰性的、潛在無限的斐波那契數列,該協程通過調用 yield() 函數來產生連續的斐波納契數。當在這樣的序列的迭代器上迭代每一步,都會執行生成下一個數的協程的另一部分。因此,我們可以從該序列中取出任何有限的數字列表,例如 fibonacciSeq.take(8).toList() 結果是 [1, 1, 2, 3, 5, 8, 13, 21]。協程足夠廉價使這很實用。

爲了演示這樣一個序列的真正惰性,讓我們在調用 buildSequence() 內部輸出一些調試信息:

kotlin import kotlin.coroutines.experimental.* fun main(args: Array<String>) { //sampleStart val lazySeq = buildSequence { print("START ") for (i in 1..5) { yield(i) print("STEP ") } print("END") } // 輸出序列的前三個元素 lazySeq.take(3).forEach { print("$it ") } //sampleEnd }

運行上面的代碼看,是不是我們輸出前三個元素的數字與生成循環的 STEP 有交叉。這意味着計算確實是惰性的。要輸出 1,我們只執行到第一個 yield(i),並且過程中會輸出 START。然後,輸出 2,我們需要繼續下一個 yield(i),並會輸出 STEP3 也一樣。永遠不會輸出再下一個 STEP(以及END),因爲我們再也沒有請求序列的後續元素。

爲了一次產生值的集合(或序列),可以使用 yieldAll() 函數:

kotlin import kotlin.coroutines.experimental.* fun main(args: Array<String>) { //sampleStart val lazySeq = buildSequence { yield(0) yieldAll(1..10) } lazySeq.forEach { print("$it ") } //sampleEnd }

buildIterator() 的工作方式類似於 buildSequence(),但返回一個惰性迭代器。

可以通過爲 SequenceBuilder 類寫掛起擴展(帶有上文描述的 [@RestrictsSuspension](https://github.com/RestrictsSuspension "@RestrictsSuspension") 註解)來爲 buildSequence() 添加自定義生產邏輯(custom yielding logic):

kotlin import kotlin.coroutines.experimental.* //sampleStart suspend fun SequenceBuilder<Int>.yieldIfOdd(x: Int) { if (x % 2 != 0) yield(x) } val lazySeq = buildSequence { for (i in 1..10) yieldIfOdd(i) } //sampleEnd fun main(args: Array<String>) { lazySeq.forEach { print("$it ") } }

其他高級 API:kotlinx.coroutines

只有與協程相關的核心 API 可以從 Kotlin 標準庫獲得。這主要包括所有基於協程的庫可能使用的核心原語和接口。

大多數基於協程的應用程序級API都作爲單獨的庫發佈:kotlinx.coroutines。這個庫覆蓋了

  • 使用 kotlinx-coroutines-core 的平臺無關異步編程
    • 此模塊包括支持 select 和其他便利原語的類似 Go 的管道
    • 這個庫的綜合指南在這裏
  • 基於 JDK 8 中的 CompletableFuture 的 API:kotlinx-coroutines-jdk8
  • 基於 JDK 7 及更高版本 API 的非阻塞 IO(NIO):kotlinx-coroutines-nio
  • 支持 Swing (kotlinx-coroutines-swing) 和 JavaFx (kotlinx-coroutines-javafx)
  • 支持 RxJava:kotlinx-coroutines-rx

這些庫既作爲使通用任務易用的便利的 API,也作爲如何構建基於協程的庫的端到端示例。