Kotlin Coroutines 那一兩件事情

Jast Lai
Jastzeonic
Published in
21 min readJun 21, 2019

本篇文章於 2020 10/03 更新

還記得頭一回聽到 Coroutines 的時候,納悶了一下,口乳停,這是甚麼新的 番號招式(誤),之後其實也沒有多在意了,好一段時間,因為一個檔案的 I/O 會把 UI Thread 卡住,必須要用異步程序去處理,寫 Handler Thread 可以避免我是絕對會極力避免的(儘管某些時候還是避不掉),寫 RX 又總覺得微妙感覺有點殺雞用牛刀的感覺,後來看了一下決定用 Coroutines ,至於跌跌撞撞研究出結果後又是另一個故事了。

是甚麼問題要解決?

functionA()
functionB()
functionC()

普通情況下,執行的順序會是很直白的 functionA() -> functionB() -> functionC()。

如果只有一個 thread ,這樣很順暢沒問題。

但假如這是一個跑在 main thread上,而 function A 是需要另一個 thread 的處理結果,而該結果是需要該 thread 耗費長時間作業才可以獲得的。這邊姑且稱為 IO thread 好了。那不就意味著 function A 得等到 IO thread 處理結束並告知結果才能繼續執行 function A 乃至 function B 之後才是 function C 呢?

那在等待 function A 的時候 main thread 啥事都不能做,只能 idle 在那邊動也不動。

這如果是在 Android main thread 上,這樣的行為會讓畫面 freeze ,時間稍微長一點就會 ANR 被 OS 當作壞掉進行異常排除了。

其實這個異步問題解決方案很多,諸如自己寫一個 call back ,或者自幹 Handler thread 去控管或者是用 RX ,去訂閱之類。某些時候顯得不直觀,或者使用起來麻煩,總有種殺雞何需使用牛刀的感覺。

那有沒有可能?我就寫成上面那樣,但是當 function A 在等待 IO thread 時,讓 main thread 去做其他的事情,等到 IO thread 結束耗時處理後,再回來繼續執行function A,function B 、 function C 呢?

是的,可以,這個解決方案便是 Coroutine。

Coroutines 到底是甚麼?

Coroutines ,這個單字會被標成錯字,理由是他其實是兩個單字合併而成的,分別是 cooperation + routine, cooperation 意指合作,routine 意指例行作業、慣例,照這裡直接翻譯就會是合作式例行作業。

想到輝夜姬讓人想告白提到了慣例行為,也是念作 routine

那我們看到的翻譯多半會是協程、協作程序…這樣講沒啥前後感,誰協助程序?協助啥程序? blablabla ,滿頭問號浮現而出。

這裡 routine 指得是程序中被呼叫的 function、method ,也就是說,我們將 function 、method 協同其他更多的 function、method 共同作業這件事情稱為 Coroutines。

協同作業聽起來還是很抽象,具體協同甚麼呢?

這便是 Coroutines 最典型的特色,允許 method 被暫停( suspended)執行之後再回復(resumed)執行,而暫停執行的 method 狀態允許被保留,復原後再以暫停時的狀態繼續執行。

換句話說,就是我在 main thread 執行到 function A 需要等 IO thread 耗時處理的結果,那我先暫停 function A, 協調讓出 main thread 讓 main thread 去執行其他的事情,等到 IO thread 的耗時處理結束後得到結果後再回復 function A 繼續執行,已得到我要的結果,這便是 Coroutines 的概念,這聽起來不是很美好呢?

事實上這個概念早在 1964 年就已經被提出了,而很多語言也都有這樣的概念,只是 Android 上頭類似的東西一直沒有被積極推廣,直到 Kotlin 成為官方語言後,Coroutines 以 Support Library 的形式被推廣才又在 Android 業界流行起來。

Kotlin Coroutines

首先,因為 Kotlin 的 Coroutine 並沒有包含在原有包裝中,而是以 Support Library 的形式提供開發者使用,所以需要另外導入該 Library。

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'

目前版本是到 1.6.1 ,之後應該還會再更新。

那因為是在 Android 上使用的, Android 上頭的 main thread 跟普通 java 有點不一樣,所以還需要另一個 implementation,不然會報錯。

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'

導入之後就可以開始使用了。

一個簡單的開始

這邊我想做的是畫面上有一個會倒數的 Text ,用 Coroutines 可以簡單地做到

class CoroutineActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_coroutine)
GlobalScope.launch(Dispatchers.Main) {

for (i in 10 downTo 1) {
textView.text = "count down $i ..." // update text
delay(1000)
}
textView.text = "Done!"
}
}

}

那跑起來結果就像這樣:

這樣如果要 Thread 有相同的結果可以寫成這樣:

Thread {
for (i in 10 downTo 1) {
Thread.sleep(1000)
runOnUiThread {
textView.text = "count down $i ..."
}
}
runOnUiThread {
textView.text = "Done!"
}


}
.start()

這樣會有什麼問題就是另一個故事了,至少現在這樣不會馬上噴 Exception (最常見的就是使用者離開畫面沒多久就噴一個 Exception),不過也並不是說用 Coroutines 就不會發生這些問題,記得這些做法沒有什麼優劣,差別在都選擇就是了。

說回 Coroutines ,那跟 Thread 一樣,某些時候我們會想要臨時把它停住,那 GlobalScope.launch 會回傳一個 Job class 的玩意

val job: Job = GlobalScope.launch(Dispatchers.Main) {
// launch coroutine in the main thread
for (i in 10 downTo 1) { // countdown from 10 to 1
textView.text = "count down $i ..." // update text
delay(1000) // wait half a second
}
textView.text = "Done!"
}

想要把它停住的話就用 cancel 即可

job.cancel()

Scope

GlobalScope 是什麼玩意?

Scope 指得是範圍,Coroutines 可以作用的範圍。在 Main thread 上或是 IO thread 上,又或者希望開更多的 Worker thread,然後是可以在某個控制流(e.g Activity 的生命週期)中可被控制的。

原則上,在 Kotlin 裡頭使用任何標記 suspend 的 method(後面會提)都會在 Scope 裡面,這樣才可以控制 Coroutines 的行進與存活與否。

那這邊舉的例子, GlobalScope 繼承自 CoroutineScope。它是 CoroutineScope 的一個實作,它的概念就是最高層級的 Coroutines ,它的作用的範圍伴隨著 Application 的生命週期,那其實他的概念與 Dispatch.Unconfined 相同(待會會提到),用他其實可以避免 Coroutines 被過早結束,但也要記得是,這個用法類似直接呼叫靜態函數,需要注意。

那如果直接實作 CoroutineScope 呢?

class CoroutineActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = TODO("not implemented")
//...ignore
}

那會要求實作一個 CoroutineContext ,這是什麼玩意?指的就是 Coroutines 作用的情景,這邊可以指定他是在 Main thread 上或者就直接弄一個 Job 給他:

class CoroutineActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = job

private val job = Job()
//...ignore}

這樣 launch 的時候就會使用這個 Job 來操作了,如果沒有特別定義,那這個 Job 就是跑在 Worker thread 上了,用它更新 UI 會噴 Exception ,這方面可以依據自己的需求去做調整。

不過更多時候我會希望他能夠跑在 Main Thread 上,Koltinx Coroutine 有提供 CoroutineScope 的實作 - MainScrope

 class AndroidActivity {
private val scope = MainScope()

override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}

有時會寫成這樣,這樣寫多半是週期需要配合 View 本身而非 Activity ,不過大多時候我會需要配合 Activity 的生命週期,所以個人更常寫成這樣:

 class MyActivity : AppCompatActivity(), CoroutineScope by MainScope() {
override fun onDestroy() {
cancel()
}
}

Job

上面時不時提到 Job,如果從字面上的意思來看,這意指工作,事實上他也指的是工作。

那 Job 具體指的到底是甚麼?若以 Scope 來說,Scope 可以操作它旗下所有的 Coroutines ,但反過來說,假設我 cancel 了 Scope ,它底下所有的 Coroutines 也都取消了,這樣直接錯殺一百的方式好像不太妥當,那是不是有甚麼方式可以直接控制該 Coroutine 本身呢?

那其實可以注意到,如果調用 Scope 的 Extension launch 回傳的是一個 Job。

是的,就是 Job,Job 就是 Coroutines,更確切來說,Job 指的是 單一個 Coroutines 的生命週期。

去挖 Job 的原始法可以看到相當精美的註解

CoroutineContext

講到 Context 頭就很痛,Context 字面的意思就是上下文,題外話我個人經常是把上下文(Context)和場景(Scenario)搞混,後者涵蓋的範圍會更大,通常會涵蓋 Context 本身。

Context 在這邊其實可以當作是 Coroutines 的前後關係,以 CoroutineContext 來說,更直白的意思是,它是 Coroutines 各個元素的集合索引實例,也就是 Coroutines 有各式各樣的元素你可以塞給它再用它去找出來就是了。

Scope 、CoroutineContext 、Job 三者的關係。最基礎的情況會是 Scope 包含了 CoroutinContext,而 CoroutineContext 裡面包含了 Job。

Job 是 CorotineContext 的 Element 沒問題, Scope 和 CoroutineContext 的關係比較玄學,但主要是因為這涉及到 Parent 和 Child 的關係,這邊有趣的是,當有兩個 ChildContext 加在一塊,它們兩個的 Scope 會怎麼變化?沒錯,再包成一個新的 Coroutines,變成另一個涵蓋這兩個 Scope 的 Job 還有 CoroutineContext 以及 Parent Scope。這段比較難理解,不過這對使用上影響不大可以慢慢消化,若是有興趣可以參考這篇:

Dispatcher

Dispatcher 又什麼玩意?

至於 Dispatcher 的意思是調度員,那其實就是完整包裝好提供開發者輕鬆使用的 CoroutineContext Element,需要注意的是 Dispatchers 的定義是 Feature Request 的,使用方式在不同的平台會有不同的結果要住,在 Kotlinx coroutine中有四種 Dispatcher 分別是:

  • Dispatchers.Main:就是 Main thread 的包裝,在 Android 需要操作到 UI thread 通常會用它
  • Dispatchers.Default:也就是預設,這裡容易和 CoroutineStart.DEFAULT 搞混,事實上它的意思是 Dispatchers 的預設,就是執行預設的 CoroutineScheduler,原則上是會開另一個 Thread,通常不會跑在 Main thread 上。
  • Dispatchers.IO:原則上它是基於 Default 去加強的 Dispatchers ,它跟 Default 最本質的區別在於 ,它開的 thread 數量會比較多,以 Default 來說是 N ( JVM 給的數量,個平台機子會不同),而 IO 則會給上 64 個,如果 JVM 給更多,那就更多那樣。那它與 Default 都是負責不把 Main thread 堵住的耗時處理。
  • Dispatchers.Unconfined:unconfined 有不受限制之意,通常他會跑在執行該 Coroutines 的 thread 上,但是在 suspend (暫停,之後會提到)後被回復可能會跑到另一個 thread 上,使用上需要注意。

像是 MainScope 的 CoroutineContext 是 supervisorJob() + Dispatcher.Main ,這邊暫時不管 supervisorJob() ,它算是一個管控用容器,所以 MainScop 實質上是提供一個 Main thread 為 context 的 Scope。

launch 如果沒有傳入參數,則會使用 CoroutineScope 所定義的 CoroutineContext 。

在 Android 上 Dispatcher.Main 會將 launch 的程序跑在 UI thread 上,那 Dispatcher.IO 則會將 launch 的程序跑在 worker thread 上,通常會用來跑耗時作業,那其實意思等同於用比較簡易的方式決定 Coroutines 執行的 context。

舉個例子,我希望去記憶卡上讀個檔案,檔案很大,讀取需要花點時間,那因為行規(?,我不能堵住 UI Thread ,所以我必須另外開個 Worker thread 去讀取,那會寫成這樣:

class CoroutineActivity : AppCompatActivity(), CoroutineScope by MainScope() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_coroutine)


launch(Dispatchers.Main) {
progressBar.visibility = View.VISIBLE
readSomeFile()
progressBar.visibility = View.GONE
}
}

Suspend

咦?上面只是這樣看起來像是都在 main thread 上跑啊。

這時就要介紹到 Coroutines 最精神象徵物 — suspend了,此刻我的 readSome 是寫作這樣:

suspend fun readSomeFile() = withContext(Dispatchers.IO) {
// read some file
}

withContext(Dispathcers.IO) 顧名思義,就是要讓這東西跑在 Worker thread 上。

另外可以注意到,這個 function 前面加了一個 suspend ,這意思很直白,就是執行到這個 function 時是可以被 suspend (暫停)的。

什麼意思呢?暫停?這樣不就把這個 function 停住了嗎?還記得前面說到的嗎?我希望在這個 function 在命令需要長時間作業的 Thread 去作業後,就暫停,讓這個 function 所佔用的 thread 可以去執行其他事情,這邊做的就是這樣的事情。

也就是說,一開始,我們讓 UI thread 先讓 ProgressBar 顯示,然後執行了 readSomeFile,這裡 readSomeFile 去讓 IO thread 執行了讀取檔案的動作,自己則暫停,放出 UI thread 去執行其他的事情,等到 IO thread 把需要花很多時間讀去的檔案讀完後,再恢復 readSomeFile 的執行,接著讓 progressBar 消失。

那也正因為這樣的特性,所以 fun 前面註記的 suspend 必須要在 coroutineScope 的 launch 、runBlocking 內或者是其他的 suspend method 才能夠被使用。

還有一兩件事情

上面有使用到的有 launch 和 withContext,那根據友人建議,使用 MainScope 容易造成誤會,所以我這邊簡單化,用 intelliJ IDEA 寫個範例。

async

那這邊簡單講一下 async ,async 可以直接透過 scope 調用,那它跟 launch 最大的不同是調用它會回傳的是一個叫做 Deferred 的玩意,而非 Job。

Deferred 顧名思義,就是指緩徵(怎麼聽起來有點討厭),概念有點類似 future ,這樣寫的好處是可以只耗費一次長時間呼叫,那具體要怎麼取得這個資料呢?Deferred 有提供一個 method,叫做 await()

那其實可以發現,await 不能直接調用,因為它是 Suspend function ,需要透過 Coroutine 調用

調用 await 後就可以得到結果了。

這個就我知道的部分應該是 Goroutines 和 SwiftCoroutines 常見的用法,是時上在 Dart 也會看到類似的用法。

此外,async 還有一個很重要的特性是,他是併發的(Concurrency),官方文件這點寫得很含蓄,是某天研究 flow 看到一堆 operator 掛 experiment ,嘴巴開始抱怨這得怎麼用時,猛然看到教學文提到才發現的。

原先應該會跑上 2 秒,但是使用 Async 只花了一秒多,很明顯是並發的

不過人家畢竟叫做 async ,顯然是 asynchronous 的簡稱,或許字面上就代表其意思惹。

但其實他跟 launch 很像,同樣都是 async 之後會立刻執行該 block ,差別在 launch 沒有 deferred 保住最後的結果,而有了 Deferred 也可以利用 Deferred 等待結果,所以若在一個 Coroutines 可以利用 async 打出多個需要併發耗時作業,再利用 await 等待結果,以達到併發的效果。

runBlocking

來聊一下 runBlocking。

其實從字面意思來看,容易有個誤解是這裡的 Blocking 是阻塞而不是區塊,畢竟加了 ing 是動詞嘛,但因為它後面就跟著一個程式碼區塊,所以容易誤解就是了。

那它是真的會把 Thread 給阻塞住,這正是它的目的,它的目的是確保流程的串聯而非並聯,這也是它與 launch以及 async 最大的差異。

最直觀的兩個例子會是這樣,假設我用兩個 launch :

那如果我用 runBlocking

那因為秒數差異,結果會完全顛倒,那這也其實就是併發和串發的差異,在異步問題上最具有哲♂學的問題。

題外話,如果你直接在 Android 的 UI Thread 上以 Dispatchers.Main 調用 runBlocking,它會讓整個 App freeze 住,它會讓 UI Thread 暫停,然後就沒有人叫醒它了。

結語

這裡簡單介紹了 Kotlinx Coroutines 的使用方式,其實 Coroutines 使用起來因為太簡單了,所以會有一點罪惡感,但是熟悉了他的跑法,用起來真的會省事很多,儘管我前面說,如果可以我會想要避開 handler ,但是如果實際下去追,還是會發現, Koltinx coroutines 用在 Android 上頭,還是基於 Thread、handler 的(參見 kotlinx.coroutines.android.HandlerDispatcher),那其實 Kotlinx Coroutine 背後其實是以 Continuation-passing style 編寫的 state machine 加上 handler 去切換 thread 所組成的,那後面的註解其實很精彩,這邊就不加以贅述了。

這篇文章放在這邊也兩年多了,也不知道為啥用 Google Search 搜尋 Coroutine 第一個就是這篇,那當然我在寫這篇文章的時,和現在修改這篇文章的時候,對 Coroutine 的理解有點不同,或許未來也還會有所改變。

如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。

參考文章

然後呢?

如果想了解更多關於 Kotlin Coroutines 相關元件的實際作用可以參考這幾篇:

如果想了解更多 Coroutines 延伸出來的工具可以參考這幾篇:

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.