協程中的取消和異常 | 異常處理詳解

在平常的開發中,咱們都知道應該避免沒必要要的任務處理來節省設備的內存空間和電量的使用——這一原則在協程中一樣適用。您須要控制好協程的生命週期,在不須要使用的時候將它取消,這也是結構化併發所倡導的,繼續閱讀本文來了解有關協程取消的前因後果。html

⚠️ 爲了可以更好地理解本文所講的內容,建議您首先閱讀本系列中的第一篇文章: 協程中的取消和異常 | 核心概念介紹android

調用 cancel 方法

當啓動多個協程時,不管是追蹤協程狀態,仍是單獨取消各個協程,都是件讓人頭疼的事情。不過,咱們能夠經過直接取消協程啓動所涉及的整個做用域 (scope) 來解決這個問題,由於這樣能夠取消全部已建立的子協程。git

// 假設咱們已經定義了一個做用域

val job1 = scope.launch { … }
val job2 = scope.launch { … }

scope.cancel()

取消做用域會取消它的子協程github

有時候,您也許僅僅須要取消其中某一個協程,好比用戶輸入了某個事件,做爲迴應要取消某個進行中的任務。以下代碼所示,調用 job1.cancel 會確保只會取消跟 job1 相關的特定協程,而不會影響其他兄弟協程繼續工做。多線程

// 假設咱們已經定義了一個做用域

val job1 = scope.launch { … }
val job2 = scope.launch { … }
 
// 第一個協程將會被取消,而另外一個則不受任何影響
job1.cancel()

被取消的子協程並不會影響其他兄弟協程併發

協程經過拋出一個特殊的異常 CancellationException 來處理取消操做。在調用 .cancel 時您能夠傳入一個 CancellationException 實例來提供更多關於本次取消的詳細信息,該方法的簽名以下:async

fun cancel(cause: CancellationException? = null)

若是您不構建新的 CancellationException 實例將其做爲參數傳入的話,會建立一個默認的 CancellationException (請查看 完整代碼)。ide

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

一旦拋出了 CancellationException 異常,您即可以使用這一機制來處理協程的取消。有關如何執行此操做的更多信息,請參考下面的處理取消的反作用一節。函數

在底層實現中,子協程會經過拋出異常的方式將取消的狀況通知到它的父級。父協程經過傳入的取消緣由來決定是否來處理該異常。若是子協程由於 CancellationException 而被取消,對於它的父級來講是不須要進行其他額外操做的。post

不能在已取消的做用域中再次啓動新的協程

若是您使用的是 androidx KTX 庫的話,在大部分狀況下都不須要建立本身的做用域,因此也就不須要負責取消它們。若是您是在 ViewModel 的做用域中進行操做,請使用 viewModelScope.viewModelScope:kotlinx.coroutines.CoroutineScope),或者若是在生命週期相關的做用域中啓動協程,那就應該使用 [lifecycleScope](https://developer.android.goo...
)。viewModelScope 和 lifecycleScope 都是 CoroutineScope 對象,它們都會在適當的時間點被取消。例如,當 ViewModel 被清除時,在其做用域內啓動的協程也會被一塊兒取消。

爲何協程處理的任務沒有中止?

若是咱們僅是調用了 cancel 方法,並不意味着協程所處理的任務也會中止。若是您使用協程處理了一些相對較爲繁重的工做,好比讀取多個文件,那麼您的代碼不會自動就中止此任務的進行。

讓咱們舉一個更簡單的例子看看會發生什麼。假設咱們須要使用協程來每秒打印兩次 "Hello"。咱們先讓協程運行一秒,而後將其取消。其中一個版本實現以下所示:

咱們一步一步來看發生了什麼。當調用 launch 方法時,咱們建立了一個活躍 (active) 狀態的協程。緊接着咱們讓協程運行了 1,000 毫秒,打印出來的結果以下:

Hello 0
Hello 1
Hello 2

當 job.cancel 方法被調用後,咱們的協程轉變爲取消中 (cancelling) 的狀態。可是緊接着咱們發現 Hello 3 和 Hello 4 打印到了命令行中。當協程處理的任務結束後,協程又轉變爲了已取消 (cancelled) 狀態。

協程所處理的任務不會僅僅在調用 cancel 方法時就中止,相反,咱們須要修改代碼來按期檢查協程是否處於活躍狀態。

讓您的協程能夠被取消

您須要確保全部使用協程處理任務的代碼實現都是協做式的,也就是說它們都配合協程取消作了處理,所以您能夠在任務處理期間按期檢查協程是否已被取消,或者在處理耗時任務以前就檢查當前協程是否已取消。例如,若是您從磁盤中獲取了多個文件,在開始讀取文件內容以前,先檢查協程是否被取消了。相似這樣的處理方式,您能夠避免處理沒必要要的 CPU 密集型任務。

val job = launch {
    for(file in files) {
        // TODO 檢查協程是否被取消
        readFile(file)
    }
}

全部 kotlinx.coroutines 中的掛起函數 (withContext, delay 等) 都是可取消的。若是您使用它們中的任一個函數,都不須要檢查協程是否已取消,而後中止任務執行,或是拋出 CancellationException 異常。可是,若是沒有使用這些函數,爲了讓您的代碼可以配合協程取消,可使用如下兩種方法:

  • 檢查 job.isActive 或者使用 ensureActive()
  • 使用 yield() 來讓其餘任務進行

檢查 job 的活躍狀態

先看一下第一種方法,在咱們的 while(i<5) 循環中添加對於協程狀態的檢查:

// 由於處於 launch 的代碼塊中,能夠訪問到 job.isActive 屬性
while (i < 5 && isActive)

這樣意味着咱們的任務只會在協程處於活躍的狀態下執行。一樣,這也意味着在 while 循環以外,咱們若還想處理別的行爲,好比在 job 被取消後打日誌出來,那就能夠檢查 !isActive 而後再繼續進行相應的處理。

Coroutine 的代碼庫中還提供了另外一個頗有用的方法 —— ensureActive(),它的實現以下:

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

若是 job 處於非活躍狀態,這個方法會當即拋出異常,咱們能夠在 while 循環開始就使用這個方法。

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

經過使用 ensureActive 方法,您能夠避免使用 if 語句來檢查 isActive 狀態,這樣能夠減小樣板代碼的使用量,可是相應地也失去了處理相似於日誌打印這種行爲的靈活性。

使用 yield() 函數運行其餘任務

若是要處理的任務屬於 1) CPU 密集型,2) 可能會耗盡線程池資源,3) 須要在不向線程池中添加更多線程的前提下容許線程處理其餘任務,那麼請使用 yield()。若是 job 已經完成,由 yield 所處理的首要任務將會是檢查任務的完成狀態,完成的話則直接經過拋出 CancellationException 來退出協程。yield 能夠做爲按期檢查所調用的第一個函數,例如上面提到的 ensureActive() 方法。

Job.join 🆚 Deferred.await cancellation

等待協程處理結果有兩種方法: 來自 launch 的 job 能夠調用 join 方法,由 async 返回的 Deferred (其中一種 job 類型) 能夠調用 await 方法。

Job.join 會掛起協程,直到任務處理完成。與 job.cancel 一塊兒使用時,會按照如下方式進行:

  • 若是您調用  job.cancel 以後再調用 job.join,那麼協程會在任務處理完成以前一直處於掛起狀態;
  • 在 job.join 以後調用 job.cancel 沒有什麼影響,由於 job 已經完成了。

若是您關心協程處理結果,那麼應該使用 Deferred。當協程完成後,結果會由 Deferred.await 返回。Deferred 是 Job 的其中一種類型,它一樣能夠被取消。

在已取消的 deferred 上調用 await 會拋出 JobCancellationException 異常。

val deferred = async { … }

deferred.cancel()
val result = deferred.await() // 拋出 JobCancellationException 異常

爲何會拿到這個異常呢?await 的角色是負責在協程處理結果出來以前一直將協程掛起,由於若是協程被取消了那麼協程就不會繼續進行計算,也就不會有結果產生。所以,在協程取消後調用 await 會拋出 JobCancellationException 異常: 由於 Job 已被取消。

另外一方面,若是您在 deferred.cancel 以後調用 deferred.await 不會有任何狀況發生,由於協程已經處理結束。

處理協程取消的反作用

假設您要在協程取消後執行某個特定的操做,好比關閉可能正在使用的資源,或者是針對取消須要進行日誌打印,又或者是執行其他的一些清理代碼。咱們有好幾種方法能夠作到這一點:

檢查 !isActive

若是您按期地進行 isActive 的檢查,那麼一旦您跳出 while 循環,就能夠進行資源的清理。以前的代碼能夠更新至以下版本:

while (i < 5 && isActive) {
    if (…) {
        println(「Hello ${i++}」)
        nextPrintTime += 500L
    }
}
 
// 協程所處理的任務已經完成,所以咱們能夠作一些清理工做
println(「Clean up!」)

您能夠查看 完整版本

因此如今,當協程再也不處於活躍狀態,會退出 while 循環,就能夠處理一些清理工做了。

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

可是,一旦咱們須要執行的清理工做也掛起了,那上述代碼就不可以繼續工做了,由於一旦協程處於取消中狀態,它將不能再轉爲掛起 (suspend) 狀態。您能夠查看 完整代碼

處於取消中狀態的協程不可以掛起

當協程被取消後須要調用掛起函數,咱們須要將清理任務的代碼放置於 NonCancellable CoroutineContext 中。這樣會掛起運行中的代碼,並保持協程的取消中狀態直到任務處理完成。

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(「Work cancelled!」)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // 或一些其餘的掛起函數
         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 { 
          // 處理清理工做
       }
   // 剩餘的實現代碼
}

爲了享受到結構化併發帶來的好處,並確保咱們並無進行多餘的操做,那麼須要保證代碼是可被取消的。

使用在 Jetpack: viewModelScope 或者 lifecycleScope 中定義的 CoroutineScopes,它們在 scope 完成後就會取消它們處理的任務。若是要建立本身的 CoroutineScope,請確保將其與 job 綁定並在須要時調用 cancel。

協程代碼的取消須要是協做式的,所以請將代碼更新爲對協程的取消操做以延後的方式進行檢查,並避免沒必要要的操做。

如今,你們瞭解了本系列的第一部分 協程的一些基本概念、第二部分協程的取消,在接下來的文章中,咱們將繼續深刻探討學習第三部分異常處理,感興趣的讀者請繼續關注咱們的更新。

相關文章
相關標籤/搜索