本文轉載自:破解 Kotlin 協程(2) - 協程啟動篇

如果您不了解協程,請閱讀上一篇:破解 Kotlin 協程 - 入門篇

現在你已經知道協程大概是怎麼回事了,也應該想要自己嘗試一把了吧。本文將為大家詳細介紹協程的幾種啟動模式之間的不同,當然,我不打算現在就開始深入源碼剖析原理,大家只需要記住這些規則就能很好的使用協程了。

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 是不是也是一個錯誤呢?

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

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

這個 thread 方法有個參數 start 默認為 true,換句話說,這樣創造出來的線程默認就是啟動的,除非你實在不想讓它馬上投入工作:

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

這樣看上去自然多了。介面設計就應該讓默認值滿足 80% 的需求嘛。

2. 再來看看協程的啟動

說了這麼多線程,原因嘛,畢竟大家對它是最熟悉的。協程的 API 設計其實也與之一脈相承,我們來看一段最簡單的啟動協程的方式:

GlobalScope.launch {
//do what you want
}

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

本文將為大家詳細介紹 啟動模式。在 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 模式,因此協程一定會被調度,因此 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 之間加了一個 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 模式,按照我們前面的討論,協程啟動後會立即在當前線程執行,因此 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 的調度取決於這個最外層的協程的調度規則了。關於協程的調度,我們後面再聊。

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


想要找到好 Offer、想要實現技術進階的迷茫中的 Android 工程師們,推薦大家關注下我的新課《破解Android高級面試》,這門課已經更新完畢,涉及內容均非淺嘗輒止,目前已經有200+同學在學習,你還在等什麼(*≧∪≦):

掃描二維碼或者點擊鏈接《破解Android高級面試》即可進入課程啦!


推薦閱讀:
相关文章