【譯】kotlin 協程官方文件(1)-協程基礎(Coroutine Basics)

葉志陳發表於2020-03-21

最近一直在瞭解關於kotlin協程的知識,那最好的學習資料自然是官方提供的學習文件了,看了看後我就萌生了翻譯官方文件的想法。前後花了要接近一個月時間,一共九篇文章,在這裡也分享出來,希望對讀者有所幫助。個人知識所限,有些翻譯得不是太順暢,也希望讀者能提出意見

協程官方文件:coroutines-guide

協程官方文件中文翻譯:coroutines-cn-guide

協程官方文件中文譯者:leavesC

[TOC]

此章節涵蓋了協程的基本概念

一、你的第一個協程程式(Your first coroutine)

執行以下程式碼:

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch { // 在後臺啟動一個新協程,並繼續執行之後的程式碼
        delay(1000L) // 非阻塞式地延遲一秒
        println("World!") // 延遲結束後列印
    }
    println("Hello,") //主執行緒繼續執行,不受協程 delay 所影響
    Thread.sleep(2000L) // 主執行緒阻塞式睡眠2秒,以此來保證JVM存活
}
複製程式碼

輸出結果

Hello,
World!
複製程式碼

本質上,協程可以稱為輕量級執行緒。協程在 CoroutineScope (協程作用域)的上下文中通過 launch、async 等協程構造器(coroutine builder)來啟動。在上面的例子中,在 GlobalScope ,即全域性作用域內啟動了一個新的協程,這意味著該協程的生命週期只受整個應用程式的生命週期的限制,即只要整個應用程式還在執行中,只要協程的任務還未結束,該協程就可以一直執行

可以將以上的協程改寫為常用的 thread 形式,可以獲得相同的結果

fun main() {
    thread {
        Thread.sleep(1000L)
        println("World!")
    }
    println("Hello,")
    Thread.sleep(2000L)
}
複製程式碼

但是如果僅僅是將 GlobalScope.launch 替換為 thread 的話,編譯器將提示錯誤:

Suspend function 'delay' should be called only from a coroutine or another suspend function
複製程式碼

這是由於 delay() 是一個掛起函式(suspending function),掛起函式只能由協程或者其它掛起函式進行排程。掛起函式不會阻塞執行緒,而是會將協程掛起,在特定的時候才再繼續執行

開發者需要明白,協程是執行於執行緒上的,一個執行緒可以執行多個(可以是幾千上萬個)協程。執行緒的排程行為是由 OS 來操縱的,而協程的排程行為是可以由開發者來指定並由編譯器來實現的。當協程 A 呼叫 delay(1000L) 函式來指定延遲1秒後再執行時,協程 A 所在的執行緒只是會轉而去執行協程 B,等到1秒後再把協程 A 加入到可排程佇列裡。所以說,執行緒並不會因為協程的延時而阻塞,這樣可以極大地提高執行緒的併發靈活度

二、橋接阻塞與非阻塞的世界(Bridging blocking and non-blocking worlds)

在第一個協程程式裡,混用了非阻塞程式碼 delay() 與阻塞程式碼 Thread.sleep() ,使得我們很容易就搞混當前程式是否是阻塞的。可以改用 runBlocking 來明確這種情形

import kotlinx.coroutines.*

fun main() { 
    GlobalScope.launch { // launch a new coroutine in background and continue
        delay(1000L)
        println("World!")
    }
    println("Hello,") // main thread continues here immediately
    runBlocking {     // but this expression blocks the main thread
        delay(2000L)  // ... while we delay for 2 seconds to keep JVM alive
    } 
}
複製程式碼

執行結果和第一個程式是一樣的,但是這段程式碼只使用了非阻塞延遲。主執行緒呼叫了 runBlocking 函式,直到 runBlocking 內部的所有協程執行完成後,之後的程式碼才會繼續執行

可以將以上程式碼用更喜歡的方式來重寫,使用 runBlocking 來包裝 main 函式的執行體:

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> { // start main coroutine
    GlobalScope.launch { // launch a new coroutine in background and continue
        delay(1000L)
        println("World!")
    }
    println("Hello,") // main coroutine continues here immediately
    delay(2000L)      // delaying for 2 seconds to keep JVM alive
}
複製程式碼

這裡 runBlocking<Unit> { ... } 作為用於啟動頂層主協程的介面卡。我們顯式地指定它的返回型別 Unit,因為 kotlin 中 main 函式必須返回 Unit 型別,但一般我們都可以省略型別宣告,因為編譯器可以自動推導(這需要程式碼塊的最後一行程式碼語句沒有返回值或者返回值為 Unit)

這也是為掛起函式編寫單元測試的一種方法:

class MyTest {
    @Test
    fun testMySuspendingFunction() = runBlocking<Unit> {
        // here we can use suspending functions using any assertion style that we like
    }
}
複製程式碼

需要注意的是,runBlocking 程式碼塊預設執行於其宣告所在的執行緒,而 launch 程式碼塊預設執行於執行緒池中,可以通過列印當前執行緒名來進行區分

三、等待作業(Waiting for a job)

延遲一段時間來等待另一個協程執行並不是一個好的選擇,可以顯式(非阻塞的方式)地等待協程執行完成

import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
    val job = GlobalScope.launch { // launch a new coroutine and keep a reference to its Job
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    job.join() // wait until child coroutine completes
//sampleEnd    
}
複製程式碼

現在,程式碼的執行結果仍然是相同的,但是主協程與後臺作業的持續時間沒有任何關係,這樣好多了

四、結構化併發(Structured concurrency)

以上對於協程的使用還有一些需要改進的地方。GlobalScope.launch 會建立一個頂級協程。儘管它很輕量級,但在執行時還是會消耗一些記憶體資源。如果開發者忘記保留對該協程的引用,它將可以一直執行直到整個應用程式停止。我們會遇到一些比較麻煩的情形,比如協程中的程式碼被掛起(比如錯誤地延遲了太多時間),或者啟動了太多協程導致記憶體不足。此時我們需要手動保留對所有已啟動協程的引用以便在需要的時候停止協程,但這很容易出錯

kotlin 提供了更好的解決方案。我們可以在程式碼中使用結構化併發。正如我們通常使用執行緒那樣(執行緒總是全域性的),我們可以在特定的範圍內來啟動協程

在上面的示例中,我們通過 runBlocking 將 main() 函式轉為協程。每個協程構造器(包括 runBlocking)都會將 CoroutineScope 的例項新增到其程式碼塊的作用域中。我們可以在這個作用域中啟動協程,而不必顯式地 join,因為外部協程(示例程式碼中的 runBlocking)在其作用域中啟動的所有協程完成之前不會結束。因此,我們可以簡化示例程式碼:

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
    launch { // launch a new coroutine in the scope of runBlocking
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}
複製程式碼

launch 函式是 CoroutineScope 的擴充套件函式,而 runBlocking 的函式體引數也是被宣告為 CoroutineScope 的擴充套件函式,所以 launch 函式就隱式持有了和 runBlocking 相同的協程作用域。此時即使 delay 再久, println("World!") 也一定會被執行

五、作用域構建器(Scope builder)

除了使用官方的幾個協程構建器所提供的協程作用域之外,還可以使用 coroutineScope 來宣告自己的作用域。coroutineScope 用於建立一個協程作用域,直到所有啟動的子協程都完成後才結束

runBlocking 和 coroutineScope 看起來很像,因為它們都需要等待其內部所有相同作用域的子協程結束後才會結束自己。兩者的主要區別在於 runBlocking 方法會阻塞當前執行緒,而 coroutineScope 只是掛起並釋放底層執行緒以供其它協程使用。由於這個差別,所以 runBlocking 是一個普通函式,而 coroutineScope 是一個掛起函式

可以通過以下示例來演示:

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
    launch { 
        delay(200L)
        println("Task from runBlocking")
    }
    
    coroutineScope { // Creates a coroutine scope
        launch {
            delay(500L) 
            println("Task from nested launch")
        }
    
        delay(100L)
        println("Task from coroutine scope") // This line will be printed before the nested launch
    }
    
    println("Coroutine scope is over") // This line is not printed until the nested launch completes
}
複製程式碼

執行結果:

Task from coroutine scope
Task from runBlocking
Task from nested launch
Coroutine scope is over 
複製程式碼

注意,在 “Task from coroutine scope” 訊息列印之後,在等待 launch 執行完之前 ,將執行並列印“Task from runBlocking”,儘管此時 coroutineScope 尚未完成

六、提取函式並重構(Extract function refactoring)

抽取 launch 內部的程式碼塊為一個獨立的函式,需要將之宣告為掛起函式。掛起函式可以像常規函式一樣在協程中使用,但它們的額外特性是:可以依次使用其它掛起函式(如 delay 函式)來使協程掛起

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch { doWorld() }
    println("Hello,")
}

// this is your first suspending function
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}
複製程式碼

但是如果提取出的函式包含一個在當前作用域中呼叫的協程構建器的話該怎麼辦? 在這種情況下,所提取函式上只有 suspend 修飾符是不夠的。為 CoroutineScope 寫一個擴充套件函式 doWorld 是其中一種解決方案,但這可能並非總是適用的,因為它並沒有使 API 更加清晰。 常用的解決方案是要麼顯式將 CoroutineScope 作為包含該函式的類的一個欄位, 要麼當外部類實現了 CoroutineScope 時隱式取得。 作為最後的手段,可以使用 CoroutineScope(coroutineContext),不過這種方法結構上並不安全, 因為你不能再控制該方法執行的作用域。只有私有 API 才能使用這個構建器。

七、協程是輕量級的(Coroutines ARE light-weight)

執行以下程式碼:

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(100_000) { // launch a lot of coroutines
        launch {
            delay(1000L)
            print(".")
        }
    }
}
複製程式碼

以上程式碼啟動了10萬個協程,每個協程延時一秒後都會列印輸出。如果改用執行緒來完成的話,很大可能會發生記憶體不足異常,但用協程來完成的話就可以輕鬆勝任

八、全域性協程類似於守護執行緒(Global coroutines are like daemon threads)

以下程式碼在 GlobalScope 中啟動了一個會長時間執行的協程,它每秒列印兩次 "I'm sleeping" ,然後在延遲一段時間後從 main 函式返回

import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
    GlobalScope.launch {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // just quit after delay
//sampleEnd    
}
複製程式碼

你可以執行程式碼並看到它列印了三行後終止執行:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
複製程式碼

這是由於 launch 函式依附的協程作用域是 GlobalScope,而非 runBlocking 所隱含的作用域。在 GlobalScope 中啟動的協程無法使程式保持活動狀態,它們就像守護執行緒(當主執行緒消亡時,守護執行緒也將消亡)

相關文章