破解 Kotlin 協程(2) - 協程啓動篇

關鍵詞:Kotlin 協程 啓動模式安全

如今你已經知道協程大概是怎麼回事了,也應該想要本身嘗試一把了吧。本文將爲你們詳細介紹協程的幾種啓動模式之間的不一樣,固然,我不打算如今就開始深刻源碼剖析原理,你們只須要記住這些規則就能很好的使用協程了。bash

1. 回想一下剛學 Thread 的時候

我相信如今接觸 Kotlin 的開發者絕大多數都有 Java 基礎,咱們剛開始學習 Thread 的時候,必定都是這樣乾的:多線程

val thread = object : Thread(){
    override fun run() {
        super.run()
        //do what you want to do.
    }
}

thread.start()

複製代碼

確定有人忘了調用 start,還特別納悶爲啥我開的線程不啓動呢。說實話,這個線程的 start 的設計實際上是很奇怪的,不過我理解設計者們,畢竟當年還有 stop 能夠用,結果他們很快發現設計 stop 就是一個錯誤,由於不安全而在 JDK 1.1 就廢棄,稱得上是最短命的 API 了吧。異步

既然 stop 是錯誤,那麼老是讓初學者丟掉的 start 是否是也是一個錯誤呢?ide

哈,有點兒跑題了。咱們今天主要說 Kotlin。Kotlin 的設計者就頗有想法,他們爲線程提供了一個便捷的方法:函數

val myThread = thread {
    //do what you want
}

複製代碼

這個 thread 方法有個參數 start 默認爲 true,換句話說,這樣創造出來的線程默認就是啓動的,除非你實在不想讓它立刻投入工做:post

val myThread = thread(start = false) {
    //do what you want
}

//later on ...
myThread.start()

複製代碼

這樣看上去天然多了。接口設計就應該讓默認值知足 80% 的需求嘛。學習

2. 再來看看協程的啓動

說了這麼多線程,緣由嘛,畢竟你們對它是最熟悉的。協程的 API 設計其實也與之一脈相承,咱們來看一段最簡單的啓動協程的方式:spa

GlobalScope.launch {
    //do what you want
}

複製代碼

那麼這段代碼會怎麼執行呢?咱們說過,啓動協程須要三樣東西,分別是 上下文啓動模式協程體協程體 就比如 Thread.run 當中的代碼,自沒必要說。.net

本文將爲你們詳細介紹 啓動模式。在 Kotlin 協程當中,啓動模式是一個枚舉:

public enum class CoroutineStart {
    DEFAULT,
    LAZY,
    @ExperimentalCoroutinesApi
    ATOMIC,
    @ExperimentalCoroutinesApi
    UNDISPATCHED;
}

複製代碼
模式 功能
DEFAULT 當即執行協程體
ATOMIC 當即執行協程體,但在開始運行以前沒法取消
UNDISPATCHED 當即在當前線程執行協程體,直到第一個 suspend 調用
LAZY 只有在須要的狀況下運行

2.1 DEFAULT

四個啓動模式當中咱們最經常使用的實際上是 DEFAULTLAZY

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 的輸出順序其實並無那麼重要。

JVM 上默認調度器的實現也許你已經猜到,沒錯,就是開了一個線程池,但區區幾個線程足以調度成千上萬個協程,並且每個協程都有本身的調用棧,這與純粹的開線程池去執行異步任務有本質的區別。

固然,咱們說 Kotlin 是一門跨平臺的語言,所以上述代碼還能夠運行在 JavaScript 環境中,例如 Nodejs。在 Nodejs 中,Kotlin 協程的默認調度器則並無實現線程的切換,輸出結果也會略有不一樣,這樣彷佛更符合 JavaScript 的執行邏輯。

更多調度器的話題,咱們後續還會進一步討論。

2.2 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
複製代碼

2.3 ATOMIC

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

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

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

複製代碼

咱們建立了協程後當即 cancel,但因爲是 ATOMIC 模式,所以協程必定會被調度,所以 一、二、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 之間加了一個 delaydelay 會使得協程體的執行被掛起,1000ms 以後再次調度後面的部分,所以 3 會在 2 執行以後 1000ms 時輸出。對於 ATOMIC 模式,咱們已經討論過它必定會被啓動,實際上在遇到第一個掛起點以前,它的執行是不會中止的,而 delay 是一個 suspend 函數,這時咱們的協程迎來了本身的第一個掛起點,剛好 delay 是支持 cancel 的,所以後面的 3 將不會被打印。

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

2.4 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 模式,按照咱們前面的討論,協程啓動後會當即在當前線程執行,所以 一、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 的調度取決於這個最外層的協程的調度規則了。關於協程的調度,咱們後面再聊。

3. 小結

本文經過一些例子來給你們逐步揭開協程的面紗。相信你們讀完對於協程的執行機制有了一個大概的認識,同時對於協程的調度這個話題想必也很是好奇或者感到困惑,這是正常的——由於咱們尚未講嘛,放心,調度器的內容已經安排了 : )。

附錄

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")

複製代碼

歡迎關注 Kotlin 中文社區!

中文官網:www.kotlincn.net/

中文官方博客:www.kotliner.cn/

公衆號:Kotlin

知乎專欄:Kotlin

CSDN:Kotlin中文社區

掘金:Kotlin中文社區

簡書:Kotlin中文社區

相關文章
相關標籤/搜索