如何優雅的取消協程 ?

這是關於 協程的取消和異常 系列第二篇文章,強烈推薦!

原文作者:Florina Muntenescu

原文地址:Cancellation in coroutines

譯者:秉心說

微信不支持外鏈,閱讀原文體驗更佳!

在軟件開發乃至生活中,我們都要避免過多無用的工作,這樣只會浪費內存和精力。這個原則對協程也是一樣。確保你可以控制協程的生命週期,在它不需要工作的時候取消它,這就是 結構化併發 。繼續閱讀下面的內容,來了解關於協程取消的來龍去脈。

如果你更傾向於視頻,可以點擊下面的鏈接觀看 Manuel Vivo 和我在 KotlinConf’19 上的演講。

https://www.youtube.com/watch?v=w0kfnydnFWI&feature=emb_logo

爲了幫你更好的理解本文的剩餘內容,建議首先閱讀該系列的第一篇文章 Coroutines: First things first

調用 cancel

當啓動多個協程時,逐個的追蹤管理和取消它們是很痛苦的。相反,我們可以依賴於取消整個協程作用域來取消所有通過其創建的子協程。

// 假設我們在這定義了一個協程作用域 scope
val job1 = scope.launch { … }
val job2 = scope.launch { … }
scope.cancel()

取消協程作用域將取消它的所有子協程。

有的時候你可能僅僅只需取消一個協程,例如響應用戶輸入。job1.cancel 可以確保只有特定的協程被取消,而其他的不受影響。

// 假設我們在這定義了一個協程作用域 scope
val job1 = scope.launch { … }
val job2 = scope.launch { … }
// 第一個協程將被取消,而其他的不受影響
job1.cancel()

取消子協程不會影響其他子協程。

Coroutines 通過拋出一個特殊的異常 CancellationException 來實現協程的取消。如果你想提供更多關於取消原因的細節信息,在調用 cancel() 方法是可以傳入一個自定義的 CancellationException 實例:

fun cancel(cause: CancellationException? = null)

如果你並沒有提供自己的 CancellationException 實例,系統會提供默認實現。(完整代碼在這裏)

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

由於拋出了 CancellationException ,你就可以利用此機制來處理協程的取消了。詳見下面的 處理協程取消帶來的副作用 章節。

實際上,子 Job 通過異常機制來通知父親它的取消。父親通過取消的原因來決定是否處理異常。如果子任務是由於 CancellationException 而取消,父親就不會做其他額外處理。

⚠️ 協程作用域一旦被取消,就不能在其中創建新協程了。

如果你在使用 androidx KTX 類庫的話,大多數情況下你不需要創建自己的作用域,因此你也不需要負責取消它們。如果你在 ViewModel 中工作,直接使用 viewModelScope 。如果你想在生命週期相關的作用內啓動協程,直接使用 lifecyclescope

viewModelScopelifecycleScope 都是 CoroutineScope 對象,並且會在適當的時機自動取消。例如,當 ViewModel 進入 cleared 狀態時,會自動取消其中啓動的所有協程。

爲什麼協程中的工作沒有停止?

當我們調用 cancel 時,並不意味着協程中的工作會立即停止。如果你正在進行重量級的操作,例如讀取多個文件,取消協程並不能自動阻止你的代碼運行。

讓我們做一個小測試看看會發生什麼。通過協程每秒打印兩次 “Hello”,運行 1 秒之後取消協程。實現代碼如下:

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 狀態。接着讓協程運行了 1000ms,會打印如下內容:

Hello 0
Hello 1
Hello 2

一旦 Job.cancel() 被調用,協程變爲 Cancelling 狀態。但是,控制檯仍然打印了 Hello3Hello4 。只有當工作完成之後,協程才進入 Cancelled 狀態。

當 cancel 被調用時協程中的工作並不會立即停止。因此,我們需要修改代碼來定期檢查協程是否處於 active 狀態。

代碼需要配合完成協程的取消!

讓你的協程工作可以被取消

你需要確保創建的所有協程都是可以配合實現取消的,因此你需要定期或者在執行耗時任務之前檢查協程狀態。例如,你正在從磁盤讀取多個文件,那麼在讀每個文件之前,檢查協程是否被取消。這樣可以避免進行一些不需要的 CPU 密集型工作。

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

kotlinx.coroutines 中的所有掛起函數都是可取消的:withContextdelay 等等。因此你在使用它們時不需要檢查,反之,爲了是你的協程代碼可以配合實現取消,有下面兩種方案:

  • 檢查 job.isActive 或者 ensureActive

  • Let other work happen using yield()

檢查 Job 狀態

一種方案是在 while(i<5) 中添加檢查協程狀態的代碼。

// Since we're in the launch block, we have access to job.isActive
while (i < 5 && isActive)

這樣意味着只有當協程處於 active 狀態時,我們工作的纔會執行。如果我們想在協程被取消之後做些其他工作,例如打印 log,可以檢測 !isActive

協程標準庫提供了一個有用的函數:ensureActive(),它的實現是這樣的:

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

ensureActive() 在協程不在 active 狀態時會立即拋出異常,所以也可以這樣做:

while (i < 5) {
    ensureActive()
    …
}

使用 ensureActive(),無需你手動檢測 isActive,減少了樣板代碼,但喪失了一定靈活性,例如在協程取消後打印 log 。

使用 yield()

如果正在進行的任務是這樣的:

  1. 佔用大量 CPU 資源

  2. 可能會耗盡線程池資源

  3. 允許在不往線程池中添加線程的前提下,執行其他任務

這時候請使用 yield() 。yield 會進行的第一個工作就是檢查任務是否完成,如果 Job 已經完成的話,就會拋出 CancellationException 來結束協程。yield 應該在定時檢查中最先被調用,就像前面提到的 ensureActive 一樣。

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

獲取協程的返回值有兩種方法。第一種是,由 launch 方法啓動的 Job,可以調用它的 join() 方法;async 方法啓動的 Deferred(也是一種 Job),可以調用它的 await() 方法。

Job.join 會掛起協程直到任務結束。它和 Job.cancel() 一起配合會表現如下:

  • 如果先調用 job.cancel,再調用 job.join,協程依然會被掛起直到任務結束。

  • job.join 之後調用 job.cancel 不會產生任務影響,因爲任務已經結束了。

通過 Deferred 也可以獲取協程的執行結果。當任務結束時,Deferred.await 就會返回執行結果。Deferred 是一種 Job,它也是可以被取消的。

對已經被取消的 deferred 調動 await 方法會拋出 JobCancellationException

val deferred = async { … }

deferred.cancel()
val result = deferred.await() // throws JobCancellationException!

await 的作用是掛起協程直到結果被計算出來。由於協程被取消了,結果無法被計算。所以,cancel 之後再 await 會導致 JobCancellationException: Job was cancelled

另外,如果你在 deferred.await 之後調用 deferred.cancel ,那麼什麼都不會發生,因爲任務已經結束了。

處理協程取消帶來的副作用

現在假設我們需要在協程取消時做一些特定的任務:關閉正在使用的資源,打印取消日誌,或者其他一些你想執行的清理類代碼,有以下幾種方法可以實現。

檢查 !isActive

定期檢查 isActive,一旦跳出 while 循環,就可以清理資源了。我們的示例代碼更新如下:

while (i < 5 && isActive) {
    // print a message twice a second
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
// 協程已經完成工作
println(“Clean up!”)

Try catch finally

由於協程被取消時會拋出 CancellationException ,所以我們可以把掛起函數包裹在 try/catch 代碼塊中,這樣就可以在 finally 代碼塊中進行資源清理操作了。

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

但是,如果執行清理任務的函數也是需要掛起的,那麼上面的代碼是無效的,因爲協程已經處於 Cancelling 狀態了。完整代碼在 這裏。

處於取消狀態的協程無法再被掛起!

爲了能夠在協程被取消時調用掛起函數,我們需要將任務切換到 NonCancellable 的協程上下文來執行,它會將協程保持在 Cancelling 狀態直到任務結束。

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!”)

在 這裏 你可以進行練習。

suspendCancellableCoroutine 和 invokeOnCancellation

如果你使用 suspendCoroutine 來將回調轉換爲協程,那麼請考慮使用 suspendCancellableCoroutine 。協程取消時需要進行的工作可以在 continuation.invokeOnCancellation 中實現。

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

最後

爲了實現結構化併發以及避免進行無用工作,你必須確保你的任務可以被取消。

使用 Jetpack 中定義的協程作用域(viewModelScopelifecycleScope)可以幫助你自動取消任務。如果你使用自己定義的協程作用域,請綁定 Job 並在適當的時候取消它。

協程的取消需要代碼配合實現,所以確保你在代碼中檢測了取消,以避免額外的無用工作。

但是,在某些工作模式下,任務不應該被取消?那麼,應該如何實現呢,請等待該系列第四篇文章。


今天的文章就到這裏了,這個系列還有兩篇文章,都很精彩,掃描下方二維碼持續關注吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章