Coroutines 的第一件事|取消的那些眉角(2/4)

Connie Lin
12 min readFeb 24, 2021

作為開發者,我們都知道要避免讓應用程式做太多額外的任務,來節省記憶體與資源。因此,使用 coroutine 時要確保有妥善管理它的生命週期,並且適時的取消它。接下來會循序漸境地介紹 coroutine cancellation。

在開始前,請先確保你看過並理解 Part 1 的相關概念。

呼叫 cancel()

取消 scope 也會取消 scope 內的所有 children 。

執行多個 coroutines 之後,要一一追蹤並且取消是很困難的。為此我們可以改以取消整個 scope 來連帶取消所屬於該 scope 的所有 coroutines 任務。

// assume we have a scope defined for this layer of the app
val job1 = scope.launch { … }
val job2 = scope.launch { … }
**scope.cancel()**

取消任一 child job 並不會影響同個階層的其他 children job

有時候可能只需要取消特定 coroutine,呼叫 job1.cancel() 可以結束 job1 並且不會影響其他同階層的任務。

Coroutines 是透過拋出 CancellationException 來處理 cancellation 的。你也可以在呼叫 .cancel() 時帶入 CancellationException 的 instance 來交代取消原因。

fun cancel(cause: CancellationException? = null)

若沒有帶入 CancellationException 的 instance,會改採預設的方式(詳細的程式碼 請見此):

public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}

Coroutines 是藉由拋出 CancellationException 的機制來處理 cancellation。接下來會說明取消機制可能帶來的幾項 side effects。

在底層,child job 的 cancellation 會透過 exception 來通知 parent 。Parent 會判別 cause 的類型來決定是否需要處理當下的例外,假如取消的原因是 CancellationException,parent 就不會再另做處理。

一旦取消 scope ,就不能在 cancelled scope 內執行任何新的 coroutines。

若因為使用情境而選擇採用 KTX Libraries 提供的 scope,就不需要處理 cancellation 了。例如要在 ViewModel 使用 coroutines 可以採用 viewModelScope ,或是想要一個會附屬於特定生命週期的 scope,則可以採用 lifecycleScope。這兩種都是實作好的 CoroutineScope 物件,已經處理好 cancellation 的職責了,像是 ViewModel.onCleared() 時會自動取消 viewModelScope 的所有 coroutines。

為何 coroutine 沒有停止?

即便呼叫了 .cancel() ,並不代表 coroutine 任務會在當下立即停止。如果當下正在進行一些複雜運算,例如讀取複數檔案,是沒有辦法讓它自動停止的。

來看看一個簡單的範例:我們運用 coroutines 來每秒印兩次 Hello ,並且在 coroutine 運行一秒後取消它。

import kotlinx.coroutines.*

fun main(args: Array<String>) = runBlocking<Unit> {
val startTime = System.currentTimeMillis()
val job = launch (Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) {
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("Hello ${i++}")
nextPrintTime += 500L
}
}
}
delay(1000L)
println("Cancel!")
job.cancel()
println("Done!")
}

一步步來看:呼叫 launch 的同時,會創建一個 Active state 的 coroutine,coroutine 運行到 1000ms 的當下,可以看到 terminal 有這些訊息:

Hello 0
Hello 1
Hello 2

因為這時候呼叫了 job.cancel() ,coroutine 也會立即進入 Cancelling state。但是接下來, Hello 3Hello 4 也會被印下來,直到整個任務結束才會正式取消進入 Cancelled state。

Coroutine 任務並不會在 cancel 呼叫的當下立即取消,因此,我們需要在程式碼中定期地去確認 coroutine 的狀態。

Coroutine 不會在中途自動取消,因此需要與程式碼配合

讓你的 coroutine 任務可以被取消

你必須確保你實作的 coroutine 任務是可以被取消的,除此之外,你也必須定期或是在長時間任務前去確認 coroutine 的狀態。若是需要讀取大量檔案,那麼就在讀取任一檔案前去確認 coroutine 有沒有被取消,藉此才不會將 CPU 資源浪費在過時的工作上。

val job = launch {
for(file in files) {
// TODO check for cancellation
readFile(file)
}
}

所有 kotlinx.coroutinessuspend function 都可以被取消,因此使用 withContextdelay 等功能,就不需要確認 cancel 狀態來停止任務執行或是拋出 CancellationException。但若非使用這些方法,請透過下面擇一方式,讓你的 coroutine 可以配合取消。

  • 檢查 job.isActive 或是 ensureActive()
  • 使用 yield() 來執行其他任務

檢查 job 的狀態

其中一個方法是在 while(i < 5) 加入 coroutine state 的判斷:while (i < 5 && isActive)

這樣一來任務只會在 coroutine active 時執行,在跳離 while 之後,如果要在 job cancelled 的情況下再執行特定任務,例如記下 log ,便可以再加入一層 !isActive 的判斷。

另外也可以使用 coroutines library 提供的 ensureActive() 方法:

fun Job.ensureActive(): Unit {
if (!isActive) {
throw getCancellationException()
}
}

這個方法會在 !isActive 時立即拋出 exception,因此可以在 while loop 開始時先做檢查。

while (i < 5) {
ensureActive()

}

藉由 ensureActive() 可以減少你的程式碼,不需要再去處理 if isActive 的判斷式;但因為會直接拋出例外,就不會像前一個例子那麼彈性,可以在取消之後再依據情況處理其他行為。

使用 yield() 來執行其他任務

如果你要執行的任務 容易耗費 CPU 資源 或是 佔據線程 ,或是你希望 線程可以執行其他任務,但不用使用新的線程 ,那麼 yield() 就是你的選擇。yield 完成第一個任務之後會確認狀態,假如 job 處於 Completed state 便拋出 CancellationException。如同 ensureActive() ,在執行週期性任務前可以先 call yield()

Job.join() vs Deferred.await() 的取消

有兩種等待 coroutine 任務完成的方法:

  • 使用 join() 來等待 launch 返回的 job
  • 使用 await() 來等待 async 返回的 deferred (也是一種 job

Job.join() 會暫停 coroutine 直到任務完成,搭配 job.cancel() 的預期行為是這樣子的:

  • 先呼叫 job.join() 再呼叫 job.cancel(),job 完成後才會中斷 coroutine。
  • job.join() 之後才呼叫 job.cancel() 是沒有效果的,因為 cancel 作用時 job 就已經完成了。

需要回傳值時的情境,會使用 asyncdeferred 的搭配,並且在 coroutine 任務結束時,透過 Deferred.await() 來取得資料。DeferredJob 的一種類型,也可以被取消。

要是對已經取消的 deferred 呼叫 await() ,會拋出 JobCancellationException 的例外。

val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!

Await 的職責是暫停 coroutine ,直到有結果可以回傳。因此在 cancelled 後才 call await ,也會導致例外拋出:JobCancellationException: Job was cancelled

另一方面,如果在 deferred.await() 之後才呼叫 deferred.cancel() 是沒有效果的,因為 cancel 作用時 coroutine 任務就已經完成了。

處理與 cancellation 相關的 side effects

假設你需要在 coroutine 中斷之後再做某些事情(如關掉使用的資源、記下 log 或是其他你需要的 cleanup 任務),那麼你可以採取這些方法:

確認 !isActive

如果你會定期確認 isActive 的狀態,那麼一旦跳出迴圈就可以準備清理資源了。

Try-catch-finally

我們可以使用 try/catch 來包住 suspend function,當 coroutine 中斷並拋出 CancellationException 之後,就會執行寫在 finally 內的 cleanup 方法。

val job = launch {
try {
work()
} catch (e: CancellationException){
println(“Work cancelled!”)
} finally {
println(“Clean up!”)
}
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

但要留意的是,如果 cleanup 任務也是 suspend function,上述的流程就沒有用了。一旦 coroutine 進入 Cancelling state,就不能執行 suspend 任務了。

為了在 Cancelling state 執行 suspend function,我們必須將 cleanup 任務轉交給 NonCancellableCoroutineContext ,如此一來便可以執行 suspend function ,coroutine 也會保持在 Cancelling state 直到任務結束。

val job = launch {
try {
work()
} catch (e: CancellationException){
println(“Work cancelled!”)
} finally {
withContext(NonCancellable){
delay(1000L) // or some other suspend fun
println(“Cleanup done!”)
}
}
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

可以從 這邊 查看程式碼的運作方式。

suspendCancellableCoroutineinvokeOnCancellation

如果你是運用 suspendCoroutine 的方法來將 callback 轉換為 coroutines,那麼請使用 suspendCancellableCoroutine,並且用 continuation.invokeOnCancellation 來定義 coroutine 取消後要執行的任務:

suspend fun work() {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
// do cleanup
}
// rest of the implementation
}

請確保你實作的 coroutine 是可以被取消的,如此才能充分顯現 結構性並行 (structure concurrency)的優點,並確保不會做多餘的工作。

你可以使用 Jetpack 提供的 CoroutineScopeviewModelScope 以及 lifecycleScope 來為你在 scope 結束時也取消 coroutine 任務。

但要是使用了自己定義的 CoroutineScope,請確保你有將它與 Job 綁定,必且有在程式碼內去做 coroutine 狀態的相關確認,才能適時地取消任務以避免做多餘的事。

--

--