教你如何使用協程(三)協程的啓動模式

上一篇我們通過代碼實現了協程的啓動,還介紹了三種啓動協程的方式,本文將爲大家詳細介紹一下協程的啓動模式。

看看協程的啓動

我們來看一段最簡單的啓動協程的方式:

GlobalScope.launch {
    //do what you want
}

那麼這段代碼到底是如何執行呢?其實,啓動協程需要三樣東西,分別是 上下文、啓動模式、協程體,協程體 就好比 Thread.run 當中的代碼,自不必說。

在 Kotlin 協程當中,啓動模式是一個枚舉:

public enum class CoroutineStart {
    DEFAULT,
    LAZY,
    @ExperimentalCoroutinesApi
    ATOMIC,
    @ExperimentalCoroutinesApi
    UNDISPATCHED;
}
模式 功能
DEFAULT 立即執行協程體
ATOMIC 立即執行協程體,但在開始運行之前無法取消
UNDISPATCHED 立即在當前線程執行協程體,直到第一個 suspend 調用
LAZY 只有在需要的情況下運行

DEFAULT

四個啓動模式當中我們最常用的其實是 DEFAULT 和 LAZY。

DEFAULT 是餓漢式啓動,launch 調用後,會立即進入待調度狀態,一旦調度器 OK 就可以開始執行。我們來看個簡單的例子:

suspend fun main() {
    log(1)
    val job = GlobalScope.launch {
        log(2)
    }
    log(3)
    job.join()
    log(4)
}

說明: main 函數 支持 suspend 是從 Kotlin 1.3 開始的。另外,main 函數省略參數也是 Kotlin 1.3 的特性。後面的示例沒有特別說明都是直接運行在 suspend main 函數當中。

這段程序採用默認的啓動模式,由於我們也沒有指定調度器,因此調度器也是默認的,在 JVM 上,默認調度器的實現與其他語言的實現類似,它在後臺專門會有一些線程處理異步任務,所以上述程序的運行結果可能是:

19:51:08:160 [main] 1
19:51:08:603 [main] 3
19:51:08:606 [DefaultDispatcher-worker-1] 2
19:51:08:624 [main] 4

也可能是:

20:19:06:367 [main] 1
20:19:06:541 [DefaultDispatcher-worker-1] 2
20:19:06:550 [main] 3
20:19:06:551 [main] 4

這取決於 CPU 對於當前線程與後臺線程的調度順序,不過不要擔心,很快你就會發現這個例子當中 2 和 3 的輸出順序其實並沒有那麼重要。

VM 上默認調度器的實現也許你已經猜到,沒錯,就是開了一個線程池,但區區幾個線程足以調度成千上萬個協程,而且每一個協程都有自己的調用棧,這與純粹的開線程池去執行異步任務有本質的區別。
當然,我們說 Kotlin 是一門跨平臺的語言,因此上述代碼還可以運行在 JavaScript 環境中,例如 Nodejs。在 Nodejs 中,Kotlin 協程的默認調度器則並沒有實現線程的切換,輸出結果也會略有不同,這樣似乎更符合 JavaScript 的執行邏輯。
更多調度器的話題,我們後續還會進一步討論。

LAZY

LAZY 是懶漢式啓動,launch 後並不會有任何調度行爲,協程體也自然不會進入執行狀態,直到我們需要它執行的時候。這其實就有點兒費解了,什麼叫我們需要它執行的時候呢?就是需要它的運行結果的時候, launch 調用後會返回一個 Job 實例,對於這種情況,我們可以:

  • 調用 Job.start,主動觸發協程的調度執行
  • 調用 Job.join,隱式的觸發協程的調度執行

所以這個所謂的”需要“,其實是一個很有趣的措辭,後面你還會看到我們也可以通過 await 來表達對 Deferred 的需要。這個行爲與 Thread.join 不一樣,後者如果沒有啓動的話,調用 join 不會有任何作用。

log(1)
val job = GlobalScope.launch(start = CoroutineStart.LAZY) {
    log(2)
}
log(3)
job.start()
log(4)

基於此,對於上面的示例,輸出的結果可能是:

14:56:28:374 [main] 1
14:56:28:493 [main] 3
14:56:28:511 [main] 4
14:56:28:516 [DefaultDispatcher-worker-1] 2

當然如果你運氣夠好,也可能出現 2 比 4 在前面的情況。而對於 join,

...
log(3)
job.join()
log(4)

因爲要等待協程執行完畢,因此輸出的結果一定是:

14:47:45:963 [main] 1
14:47:46:054 [main] 3
14:47:46:069 [DefaultDispatcher-worker-1] 2
14:47:46:090 [main] 4

ATOMIC

ATOMIC 只有涉及 cancel 的時候纔有意義,cancel 本身也是一個值得詳細討論的話題,在這裏我們就簡單認爲 cancel 後協程會被取消掉,也就是不再執行了。那麼調用 cancel 的時機不同,結果也是有差異的,例如協程調度之前、開始調度但尚未執行、已經開始執行、執行完畢等等。

爲了搞清楚它與 DEFAULT 的區別,我們來看一段例子:

log(1)
val job = GlobalScope.launch(start = CoroutineStart.ATOMIC) {
    log(2)
}
job.cancel()
log(3)

我們創建了協程後立即 cancel,但由於是 ATOMIC 模式,因此協程一定會被調度,因此 1、2、3 一定都會輸出,只是 2 和 3 的順序就難說了。

20:42:42:783 [main] 1
20:42:42:879 [main] 3
20:42:42:879 [DefaultDispatcher-worker-1] 2

對應的,如果是 DEFAULT 模式,在第一次調度該協程時如果 cancel 就已經調用,那麼協程就會直接被 cancel 而不會有任何調用,當然也有可能協程開始時尚未被 cancel,那麼它就可以正常啓動了。所以前面的例子如果改用 DEFAULT 模式,那麼 2 有可能會輸出,也可能不會。

需要注意的是,cancel 調用一定會將該 job 的狀態置爲 cancelling,只不過ATOMIC 模式的協程在啓動時無視了這一狀態。爲了證明這一點,我們可以讓例子稍微複雜一些:

log(1)
val job = GlobalScope.launch(start = CoroutineStart.ATOMIC) {
    log(2)
    delay(1000)
    log(3)
}
job.cancel()
log(4)
job.join()

我們在 2 和 3 之間加了一個 delay,delay 會使得協程體的執行被掛起,1000ms 之後再次調度後面的部分,因此 3 會在 2 執行之後 1000ms 時輸出。對於 ATOMIC 模式,我們已經討論過它一定會被啓動,實際上在遇到第一個掛起點之前,它的執行是不會停止的,而 delay 是一個 suspend 函數,這時我們的協程迎來了自己的第一個掛起點,恰好 delay 是支持 cancel 的,因此後面的 3 將不會被打印。

我們使用線程的時候,想要讓線程裏面的任務停止執行也會面臨類似的問題,但遺憾的是線程中看上去與 cancel 相近的 stop 接口已經被廢棄,因爲存在一些安全的問題。不過隨着我們不斷地深入探討,你就會發現協程的 cancel 某種意義上更像線程的 interrupt。

UNDISPATCHED

有了前面的基礎,UNDISPATCHED 就很容易理解了。協程在這種模式下會直接開始在當前線程下執行,直到第一個掛起點,這聽起來有點兒像前面的 ATOMIC,不同之處在於 UNDISPATCHED 不經過任何調度器即開始執行協程體。當然遇到掛起點之後的執行就取決於掛起點本身的邏輯以及上下文當中的調度器了。

log(1)
val job = GlobalScope.launch(start = CoroutineStart.UNDISPATCHED) {
    log(2)
    delay(100)
    log(3)
}
log(4)
job.join()
log(5)

我們還是以這樣一個例子來認識下 UNDISPATCHED 模式,按照我們前面的討論,協程啓動後會立即在當前線程執行,因此 1、2 會連續在同一線程中執行,delay 是掛起點,因此 3 會等 100ms 後再次調度,這時候 4 執行,join 要求等待協程執行完,因此等 3 輸出後再執行 5。以下是運行結果:

22:00:31:693 [main] 1
22:00:31:782 [main @coroutine#1] 2
22:00:31:800 [main] 4
22:00:31:914 [DefaultDispatcher-worker-1 @coroutine#1] 3
22:00:31:916 [DefaultDispatcher-worker-1 @coroutine#1] 5

方括號當中是線程名,我們發現協程執行時會修改線程名來讓自己顯得頗有存在感。運行結果看上去還有一個細節可能會讓人困惑,join 之後的 5 的線程與 3 一樣,這是爲什麼?我們在前面提到我們的示例都運行在 suspend main 函數當中,所以 suspend main 函數會幫我們直接啓動一個協程,而我們示例的協程都是它的子協程,所以這裏 5 的調度取決於這個最外層的協程的調度規則了。關於協程的調度,我們後面再聊。

小結

本文繼續通過一些例子來給大家逐步揭開協程的面紗。相信大家讀完對於協程的執行機制的認識是更加深入了一些
附錄

log 函數的定義:

val dateFormat = SimpleDateFormat("HH:mm:ss:SSS")

val now = {
    dateFormat.format(Date(System.currentTimeMillis()))
}

fun log(msg: Any?) = println("${now()} [${Thread.currentThread().name}] $msg")
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章