這是關於 協程的取消和異常 系列第二篇文章,強烈推薦!
原文作者: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 。
viewModelScope
和 lifecycleScope
都是 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
狀態。但是,控制檯仍然打印了 Hello3 和 Hello4 。只有當工作完成之後,協程才進入 Cancelled
狀態。
當 cancel 被調用時協程中的工作並不會立即停止。因此,我們需要修改代碼來定期檢查協程是否處於 active 狀態。
代碼需要配合完成協程的取消!
讓你的協程工作可以被取消
你需要確保創建的所有協程都是可以配合實現取消的,因此你需要定期或者在執行耗時任務之前檢查協程狀態。例如,你正在從磁盤讀取多個文件,那麼在讀每個文件之前,檢查協程是否被取消。這樣可以避免進行一些不需要的 CPU 密集型工作。
val job = launch {
for(file in files) {
// TODO check for cancellation
readFile(file)
}
}
kotlinx.coroutines
中的所有掛起函數都是可取消的:withContext
,delay
等等。因此你在使用它們時不需要檢查,反之,爲了是你的協程代碼可以配合實現取消,有下面兩種方案:
檢查
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()
如果正在進行的任務是這樣的:
佔用大量 CPU 資源
可能會耗盡線程池資源
允許在不往線程池中添加線程的前提下,執行其他任務
這時候請使用 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 中定義的協程作用域(viewModelScope
和 lifecycleScope
)可以幫助你自動取消任務。如果你使用自己定義的協程作用域,請綁定 Job 並在適當的時候取消它。
協程的取消需要代碼配合實現,所以確保你在代碼中檢測了取消,以避免額外的無用工作。
但是,在某些工作模式下,任務不應該被取消?那麼,應該如何實現呢,請等待該系列第四篇文章。
今天的文章就到這裏了,這個系列還有兩篇文章,都很精彩,掃描下方二維碼持續關注吧!