1. 程式人生 > 實用技巧 >Coroutine中的去除和異常 | 取消操作介紹

Coroutine中的去除和異常 | 取消操作介紹

在日常的開發中,我們都知道應該避免不必要的任務處理來節省裝置的記憶體空間和電量的使用——這一原則在協程中同樣適用。您需要控制好協程的生命週期,在不需要使用的時候將它取消,這也是結構化併發所倡導的,繼續閱讀本文來了解有關協程取消的來龍去脈。

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

呼叫 cancel 方法

當啟動多個協程時,無論是追蹤協程狀態,還是單獨取消各個協程,都是件讓人頭疼的事情。不過,我們可以通過直接取消協程啟動所涉及的整個作用域 (scope) 來解決這個問題,因為這樣可以取消所有已建立的子協程。

// 假設我們已經定義了一個作用域

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

scope.cancel()

取消作用域會取消它的子協程

有時候,您也許僅僅需要取消其中某一個協程,比如使用者輸入了某個事件,作為迴應要取消某個進行中的任務。如下程式碼所示,呼叫 job1.cancel 會確保只會取消跟 job1 相關的特定協程,而不會影響其餘兄弟協程繼續工作。

// 假設我們已經定義了一個作用域

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

被取消的子協程並不會影響其餘兄弟協程

協程通過丟擲一個特殊的異常 CancellationException 來處理取消操作。在呼叫 .cancel 時您可以傳入一個 CancellationException 例項來提供更多關於本次取消的詳細資訊,該方法的簽名如下:

fun cancel(cause: CancellationException? = null)

如果您不構建新的 CancellationException 例項將其作為引數傳入的話,會建立一個預設的 CancellationException (請檢視 完整程式碼)。

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

一旦丟擲了 CancellationException 異常,您便可以使用這一機制來處理協程的取消。有關如何執行此操作的更多資訊,請參考下面的處理取消的副作用一節。

在底層實現中,子協程會通過丟擲異常的方式將取消的情況通知到它的父級。父協程通過傳入的取消原因來決定是否來處理該異常。如果子協程因為 CancellationException 而被取消,對於它的父級來說是不需要進行其餘額外操作的。

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

如果您使用的是 androidx KTX 庫的話,在大部分情況下都不需要建立自己的作用域,所以也就不需要負責取消它們。如果您是在 ViewModel 的作用域中進行操作,請使用 viewModelScope,或者如果在生命週期相關的作用域中啟動協程,那就應該使用 lifecycleScope。viewModelScope 和 lifecycleScope 都是 CoroutineScope 物件,它們都會在適當的時間點被取消。例如,當 ViewModel 被清除時,在其作用域內啟動的協程也會被一起取消。

為什麼協程處理的任務沒有停止?

如果我們僅是呼叫了 cancel 方法,並不意味著協程所處理的任務也會停止。如果您使用協程處理了一些相對較為繁重的工作,比如讀取多個檔案,那麼您的程式碼不會自動就停止此任務的進行。

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

image

我們一步一步來看發生了什麼。當呼叫 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