Kotlin Coroutines Job 的那一兩件事

Jast Lai
Jastzeonic
Published in
15 min readSep 12, 2020

前言

我們都知道 Coroutines 的使用會是類似這樣。

scope.launch {

//do something
}

那使用得當,就可以讓 Thread 順利的切換。

那這邊可能會注意到,我們使用 launch ,它會回傳一個 job。

val job:Job = scope.launch {

//do something
}

我們使用這個 Job 時,最常用到的功能不外乎就是 cancel :

job.cancel()

其實還有 join 和 invokeOnCompletion 等,但先暫且不提,我們先聚焦在 cancel 上,顧名思義,cancel 會將 job 「取消」掉。

什麼意思呢?

很多時候,我們開啟一個 Coroutines ,中途因為某一些狀態的變化,會讓不希望這個 Coroutines 執行到最後(e.g. 某一輪 Coroutines 會在耗時作業後對某個 View 更新,但因為 Lifecycle 的關係,View 已經不復存在了),那就會用到 cancel 。

可以說 Job 就是一趟 Coroutines 的生命週期了。

那麼,這邊應該是 Coroutines 的基本概念了,講這些都是老生常談惹,該來說點比較深入的東西了。

從前從前有一個東西叫 Job

正常情況下:

scope.launch {

println("start")
withContext(Dispatchers.Default) {

println("withContext start")
delay(1000)
println("withContext end")

}
println("end")

}

這個印出來的結果會是:

start
withContext start
withContext end
end

這沒問題,那如果說,我在尾巴加一個 cancel :

val job = scope.launch {

println("start")
withContext(Dispatchers.Default) {

println("withContext start")
delay(1000)
println("withContext end")

}
println("end")

}
job.cancel()

那會印出甚麼呢?

start

這其實不奇怪,因為已經打出去了,打出去後才被取消印出開頭不怎麼怪。

那我們換個方式:

val job = scope.launch {
Thread.sleep(1000)
println("start")
withContext(Dispatchers.Default) {

println("withContext start")
delay(1000)
println("withContext end")

}
println("end")

}
job.cancel()

這樣照上面那個打出去後才取消所以只印開頭這個邏輯,應該是不會印出任何東西的…

然而:

start

拿打頭?

總之先冷靜下來找台時光機。

OK,敏銳的人應該會注意到,我這邊用的不是 coroutines 提供的 suspend function delay ,而是用 thread.sleep。

那如果說用 delay 呢?

val job = scope.launch {
delay(1000)
println("start")
withContext(Dispatchers.Default) {

println("withContext start")
delay(1000)
println("withContext end")

}
println("end")

}
job.cancel()

結果:

It just works。

那問題來了?為什麼用 thread.sleep 會印出開頭,那用 delay 則否?

撇開讓 thread 暫停的實作方法,知道他們兩個都是讓 thread 暫停的功能就好。兩者最具體的差異便是在一個是 suspend function 另一個是直接硬幹。

Emm…這麼說起來,使用 Thread.sleep 也是執行到另一個 suspend function (withContext)之前。

那這個取消終止 Coroutines 是不是跟 suspend function 有關呢?

是的。

不過我們先別急,回頭看看一個概念:Two Phase Termination Pattern (兩階段終結)

話說那兩階段終結

過往我們使用 Java 時,我們在處理異步時,總會有需要使用 thread 的時候,也會有需要把 thread 終止的時候,那 thread 有提供一個 method 叫 stop

但打從我開始使用 java 以來,這個 method 就一直被 deprecated 到現在了,去看 doc 或者是 comment ,會發現更推薦使用的是 interrupt。

stop 和 interrupt 具體差異在哪裡呢?

stop 很直覺,就是 thread 直接停掉,而 interrupt 則是會對 thread 送出一組終止訊號。

咦?那兩者有什麼區別?

那我們直接舉例:

val thread = Thread{
Thread.sleep(1000)
println("Hello World")
}
thread.start()

thread.stop()

這會印出什麼呢?

(nothing....)

跟預期的一樣,那換作是 interrupt 呢?

val thread = Thread{
Thread.sleep(1000)
println("Hello World")
}
thread.start()

thread.interrupt()

這樣會印出

Exception in thread "Thread-0" java.lang.InterruptedException: sleep interrupted
at java.base/java.lang.Thread.sleep(Native Method)
at CoroutinePlayKt$main$thread$1.run(CoroutinesPlay.kt:24)
at java.base/java.lang.Thread.run(Thread.java:830)
Process finished with exit code 0

師傅你聽我說,這樣用會壞掉啊。

且聽我說,這個 Exception 是來自 Thread.sleep 的,那概念是 Thread.sleep 的時候得到了 interrupt 的訊息,Thread.sleep 要對呼叫他的人通知我被 interrupt 了,拋了一個 Exception 來告知呼叫他的人。

那這樣其實就很簡單了,用一個 try catch 包起來就行了。

val thread = Thread{
try {
Thread.sleep(1000)
println("Hello World")
}catch(ex:InterruptedException){

}
}
thread.start()

thread.interrupt()

那結果是:

(nothing....)

It just works.

那很顯然地,Thread.sleep 是用來測試用的,那假設我們有一個耗時作業:

val thread = Thread{
try {
someLongTimeWork()
println("Hello World")
}catch(ex:InterruptedException){
}
}
thread.start()

thread.interrupt()

那麼結果會是:

Hello World

師傅你聽我說,這個 interrupt 沒作用啊。

這是很正常的,因為 someLongTimeWork() 並沒有讓他拋出耗時作業的功能,他也沒有接到 interrupt 給的訊號,拿要做其實很簡單。

val thread = Thread {

someLongTimeWork()
if (Thread.interrupted()) {
return@Thread
}
println("Hello World")


}
thread.start()

thread.interrupt()

結果會是:

(nothing....)

It just works.

只是為啥毛這麼多啊?直接用 Stop 不是很好?就這樣把 thread 關掉了不就沒事惹。

一般情況下直接用 Stop 無所謂,但是假如有類似 Socket 或者是一些你需要主動停止的東西的話,直接把 thread stop 掉,這會造成一個狀況 —

你永遠關不掉他。

這就是一個很美麗的 leak。

所以我們會需要兩階段終止,第一階段:關閉 thread;第二階段:把殘留的實體優雅地關掉。

那跟 Job 的關係?

那話說回來,那這些東西跟 Job 有什麼關係呢?

可以回想一下,我們在使用 Job 的 cancel 時,情況跟 interrupt 很像,我預期他會關掉,他沒關掉;我預期他不會關掉,但他關掉了。

那我們把 Job 對應 Coroutines 的流程拿出來看

這裡其實就是 Job 裏頭的 isActive 、 isCompleted 、isCancelled 。

這裡比較重要的是 isActive,因為這是實際上會影響到是不是該做下去的判斷物。

不過在這之前,我們先來溫習一下 Coroutines 的流程。

//Coroutine Producerscope.launch(Dispatchers.Main) {

// produce something.
consumer()
// finish

}
//Coroutine Consumer

suspend fun consumer() = withContext(Dispatchers.Default) {
// consume something

}

這裡我們知道 producer 會先執行,然後到 consumer 時會被 suspend ,等到 consumer 執行完後,再還給 producer 跑一次。

那中間過程其實也沒那麼複雜,所謂 producer suspend 就是把自己當作一個 continuation 傳入 consumer 之中,consumer 結束後會再呼叫 continuation 再恢復執行 producer,所以流程我們能看成是這樣 — >

執行 producer -> 執行 consumer ->利用 continuation 恢復 producer -> 繼續執行 producer。

那麼假設我在 run consumer 時,cancel 掉它,那 job 會在要恢復 continuation 時提供狀態,決定是否要繼續下去。

執行 producer -> 執行 consumer -> 因為 job 的關係,得到 cancel 狀態-> 不繼續執行 producer。

這樣就能解釋這段 Code:

val job = scope.launch {

println("start")
withContext(Dispatchers.Default) {

println("withContext start")
delay(1000)
println("withContext end")

}
println("end")

}
job.cancel()

這就是為什麼已經 cancel 了,卻還是會印出 start。

那這段 code 怎麼解釋?

val job = scope.launch {
delay(1000)
println("start")
withContext(Dispatchers.Default) {

println("withContext start")
delay(1000)
println("withContext end")

}
println("end")

}
job.cancel()

為什麼它不會印出 start 呢?

因為 delay 也是一個 suspend function 阿,所以會變成是:

執行 launch -> 執行 delay -> 因為 job 的關係,得到 cancel 狀態-> 不繼續執行 launch。

實際運行可以參照下面這段 Code。

這段可以參照 DispatchedTask.Kt

那… + Job 的話,是怎麼取消的?

我們常常會看到 launch 會被這樣使用:

val otherJob = Job()

scope.launch(dispatcher + otherJob) {
delay(1000)
println("coroutines 1")
}

scope.launch(dispatcher + otherJob) {
delay(1000)
println("coroutines 2")
}

scope.launch(dispatcher + otherJob) {
delay(1000)
println("coroutines 3")
}

「+」 對初次看到的人可能會納悶這甚麼碗糕,直白地說會比較玄學一點,這邊的意思是要生成一個新 CoroutinesContext 然後再把 launch 所生的 Coroutines 塞進去。看不懂沒關係,這個我未來可能在另一篇文章做解釋。

那如果在尾巴加上:

otherJob.cancel()

會發生甚麼事呢?

答案是

(nothing....)

因為所有的 Coroutines 全部 Job 給取消了,所以印不出來任何東西?

那這個 Job 具體是怎麼作用的,它是不是會把原有 Coroutines 的 Job 給蓋掉?

答案是:不會

launch 後的 Coroutines 自己會生成一個 Job,那麼該 Job 會是 Scope 所屬 Job 的 Child,更重要的是這個 Coroutines 所生成的 Job 會變成那個 otherJob 的 Child。

這個在語法上很反直覺,為什麼用 + 反而騎上去變成該 instance 的 parent 了。

這個就跟 CoroutinesConext 的生成有關係了,那這裡我們先略過 CoroutinesContext 的生成,來看看這段 Code:

JobSuspport.kt Line: 1312

Job 在 instance 的時候,會執行這段,如果 parent 存在的話,就會把自己 attach 進去。

那顯然 Job cancel 掉後,也會把自己的 child 全部 cancel 掉。

這也就能解釋為什麼我把 otherJob cancel 掉後,這個 Coroutines 也會跟著停掉了。

從屬關係這樣講實在相當抽象,這也是我覺得 Job 最有趣的地方,我這邊直接舉一個例子:

val job1 = scope.launch(dispatcher) {
delay(1000)
println("job 1")
}

val job2 = scope.launch(dispatcher + job1) {
delay(1000)
println("job 2")
}

val job3 = scope.launch(dispatcher + job2) {
delay(1000)
println("job 3")
}

job1.cancel()

這樣會印出甚麼結果呢?

沒錯,甚麼都印不出來。

因為 job1 變成了 job2 的 parent ,而 job2 變成 job3 的 parent ,所以取消了 job1 就取消了包括它的 child job2,取消了 job2 就取消了包括它的 child job3。

那換成是這樣呢?

val job1 = scope.launch(dispatcher) {
delay(1000)
println("job 1")
}

val job2 = scope.launch(dispatcher + job1) {
delay(1000)
println("job 2")
}

val job3 = scope.launch(dispatcher + job2) {
delay(1000)
println("job 3")
}

job2.cancel()

輸出的結果會是 :

job 1

因為取消的是 job2,跟 job1 無關,所以 job1 仍舊印出了東西,雖然 job1 有寫在 job2 的 launch 上,但是那是將 job2 變成 job1 的 child ,所以取消 job2 並不會影響到 job1。

結語

其實從 thread 和 handler 的時代就接觸到 interrupt 和 stop 兩個東西,那時的我還納悶為什麼我 call 的 interrupt 還沒終止,照樣把我想取消執行的東西給執行了。後來就接觸 AsyncTask 乃至於後來接觸 RXJava 到現在的 Coroutines ,就只知道,要取消這種耗時處理,還是會讓耗時的某部分處理完,只是不觸發 callback 而已。

那天在看 Coroutines 的 Job 的 cancel 機制,追了老半天看不懂,這時心理產生了一個疑問,為什麼我們取消耗時作業大多都會是讓耗時作業做完然後不去呼叫 callback ,而不是直接把耗時作業給取消掉呢?因而得到了兩階段式終結這個名子。

說起來,寫到尾巴,發現 CoroutinesContext 的奧妙之處,想想好像不把 CoroutinesContext 拿出來解釋,還是會有很多不解的地方呢,這個就等未來有緣之時吧。

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

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.