Kotlin Coroutines Job 的那一兩件事
前言
我們都知道 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。
那… + 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:
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 拿出來解釋,還是會有很多不解的地方呢,這個就等未來有緣之時吧。
如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。