原文:Cancellation in coroutines
做者:Florina Muntenescu
譯者:luckykelanandroid
在開發中就像在生活中同樣,咱們知道要避免作過多的工做,由於這會浪費內存和經歷。這個原則一樣適用於協程。您須要確保控制好協程的生命週期並在不須要時取消它 —這就是協程結構化併發所表現的。git
⚠️ 爲了無障礙的閱讀本文的其他部分,請閱讀並理解本系列的第一章github
當啓動多個協程時,逐個跟蹤或取消它們可能會很麻煩,可是咱們能夠依靠取消父協程或協程做用域,由於這將取消它建立的全部協程。安全
//假設咱們已經爲如下代碼定義了一個做用域scope
val job1 = scope.launch {...}
val job2 = scope.launch {...}
scope.cancel()
複製代碼
取消一個協程做用域將同時取消此做用域下的全部子協程(Cancelling the scope cancels its children)多線程
有時您可能只須要取消一個協程,調用job1.cancel()
可確保僅取消特定協程,全部它的同級協程都不會受到影響。併發
val job1 = scope.launch { … }
val job2 = scope.launch { … }
// 第一個協程被取消,第二個不受影響
job1.cancel()
複製代碼
被取消的子協程不會影響到其餘同級的協程(A cancelled child doesn’t affect other siblings)async
協程經過拋出一個特殊的異常來處理取消操做:CancellationException 。若是您想要提供更多關於取消緣由的細節,能夠在調用在調用cancel()
方法時傳入一個CancellationException 實例,由於這是cancel()
的完整方法簽名:ide
fun cancel(cause: CancellationException? = null)
複製代碼
若是使用缺省調用,則會建立一個默認的CancellationException 實例(此處有完整代碼)函數
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}
複製代碼
由於協程的取消會拋出CancellationException ,因此咱們能夠利用這個機制來對協程的取消進行一些操做。關於如何操做的詳細說明,請參見本文下面的「處理取消反作用」小節。
在底層,子協程的取消會經過拋出異常來通知父級,而父級根據取消的緣由來肯定是否須要處理異常。若是子協程是因爲CancellationException 而被取消,那麼父級就不須要再執行其餘操做。oop
⚠️ 咱們沒法在一個已經取消了的協程做用域內再建立新協程
當使用androidx KTX庫時,大多數狀況咱們不須要建立本身的做用域,所以咱們也不負責取消它們。好比在ViewModel 中咱們可使用viewModelScope ,或者當咱們想啓動一個與頁面生命週期相關的協程時可使用lifecycleScope 。viewModelScope 和lifecycleScope 都是能夠在正確的時間能夠被自動取消的協程做用域對象。例如,當ViewModel 被清除時,也會同時取消在 viewModelScope中啓動的協程。
若是咱們僅調用cancel()
方法,並不意味着協程的工做就會馬上中止。若是協程正在執行一些比較繁重的計算,好比從多個文件中讀取數據,則不會有任何東西可讓此協程自動中止。 讓咱們舉個更簡單的例子看看會發生什麼。假設咱們須要使用協程以每秒兩次的速度打印"Hello",咱們讓協程運行一秒鐘而後取消它;
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時,咱們正在建立一個處於活動狀態的新協程,咱們讓這個協程運行1000毫秒,如今咱們看到了:
Hello 0
Hello 1
Hello 2
複製代碼
一旦調用了job.cancel()
,協程會進入Cancelling 狀態,可是以後咱們仍然看看到了Hello3和Hello4被打印到了控制檯上。只有協程在完成工做後,纔會被移入 Cancelled 狀態。
協程不會在調用job.cancel()
時當即中止,因此咱們須要修改代碼並按期檢查協程是否處於活動狀態。
⚠️ 協程的取消是協做式的,是須要配合的。
咱們須要確保全部協程都是與取消是協做的,所以須要按期或在任何開始長時間運行的工做以前檢查是否有取消。例如,當咱們從磁盤讀取多個文件,在開始讀取每一個文件以前都應該檢查協程是否已被取消,這樣,當再也不須要CPU時,就能夠避免執行CPU密集型操做以減小消耗。
val job = launch {
for(file in files) {
// TODO check for cancellation
readFile(file)
}
}
複製代碼
kotlinx.coroutines
中的全部掛起函數都是能夠被取消的,如withContext()
, delay()
等。所以,若是咱們使用它們中的任何一個,都不須要檢查取消並當即中止或拋出CancellationException 。但若是不使用這些,爲了使咱們的協程可協做式取消,咱們有兩個選擇:
job.isActive
或ensureActive()
來檢查依然以上面的代碼爲例,第一種選擇是在while(i < 5)
處添加一個協程的狀態檢查
//由於咱們在協程內部,因此咱們能夠訪問job.isActive,
while (i < 5 && isActive)
複製代碼
這意味着工做應該只在協程處於活動狀態時執行,同時一旦咱們離開了while,若是想要執行其餘操做,好比記錄Job 是否被取消了,則能夠添加一個檢查!isActive
。 協程庫提供了另外一個頗有用的方法 —ensureActive()
,它的實現是:
fun Job.ensureActive(): Unit {
if (!isActive) {
throw getCancellationException()
}
}
複製代碼
由於這個方法會在Job 處於不活躍時當即拋出異常,因此咱們能夠將其做爲while循環中的第一個操做:
while (i < 5) {
ensureActive()
…
}
複製代碼
經過使用ensureActive()
方法,咱們能夠避免本身實現isActive
所需的if語句從而減小須要編寫的樣板代碼,可是也失去了執行其餘操做(好比日誌記錄)的靈活性。
若是咱們想要執行的操做是 1)佔用大量CPU資源,2)可能耗盡線程池,3)以及但願容許線程執行其餘工做而沒必要向池中添加更多線程,應使用。yield()
的第一個操做就是檢查Job 是否完成,若是Job 完成,則經過拋出CancellationException 來退出協程yield()
能夠做爲按期檢查中的第一個函數,就像上文中的ensureActive()
譯者注:使用yield()函數應注意,在大多數狀況下,yield()會使當前協程暫時掛起以讓其餘運行在同一線程的協程執行,它提供了一種讓多個須要長時間運行的任務公平佔用線程的機制。特殊狀況以下:1)若是當前協程的調度器爲Dispatchers.Unconfined時,僅當有其餘調度器一樣是Dispatchers.Unconfined且已經造成Event-looper,當前協程纔會掛起;2)若是當前協程的上下文中未指定協程調度器,那麼yield()不會掛起當前協程。
有兩種方式能夠等待協程的結果:從launch 返回的Job 實例能夠調用join 方法,從async 返回的Deferred (Job 的子類)能夠調用await 方法。
Job.join()
會掛起一個協程直到協程的工做完成。和Job.cancel()
一塊兒使用會根據咱們的調用順序產生結果:
Job.cancel()
而後調用Job.join()
,協程將掛起直到Job 完成。Job.join()
而後調用和Job.cancel()
,將不會產生任何效果,由於協程已經完成了。當咱們對協程的結果更感興趣時,可使用Deferred 。當協程完成時,結果會經過Deferred.await()
返回。Deferred 是Job 的子類,因此它也是可被取消的。
對已經被取消的Deferred 調用await()
會拋出JobCancellationException 異常。
val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!
複製代碼
由於await 的做用是掛起協程直到獲得結果,因爲協程已經被取消,所以沒法再計算結果。所以,取消後再調用await 會致使JobCancellationException: Job was cancelled
。
另外一方面,若是在deferred.await()
以後調用deferred.cancel()
不會有任何效果,由於協程已經完成了。
假設咱們想在協程被取消時執行某個特定的操做好比關閉可能正在使用的任何資源,記錄取消或執行其餘清理代碼,有幾種方法能夠作到這一點:
若是咱們按期檢查了isActive 的狀態,那麼一旦退出while循環,咱們就能夠作一些清理資源的工做,上面的代碼能夠更新爲:
while (i < 5 && isActive) {
// print a message twice a second
if (…) {
println(「Hello ${i++}」)
nextPrintTime += 500L
}
}
// 協程已經完成,咱們能夠清理了
println(「Clean up!」)
複製代碼
能夠在這裏看看運行效果。
因此如今,當協程再也不活躍時,while循環會中斷,咱們能夠進行清理一些資源。
因爲協程被取消時會拋出一個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 的狀態,那麼它就不能再次掛起。
⚠️ 處於取消狀態的協程不能再次掛起!
爲了可以再協程被取消時調用掛起函數,咱們須要在一個不可取消的協程上下文中切換清理工做,這將容許代碼掛起,並將協程保持在Cancelling 狀態直至完成工做:
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!」)
複製代碼
能夠在此處實踐上述代碼。
若是使用suspendCoroutine 方法將回調改爲協程,那麼最好使用suspendCancellableCoroutine 。可使用continuation.invokeOnCancellation
來完成取消工做:
suspend fun work() {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
// 能夠作一些清理工做
}
// 其他的實現
}
複製代碼
爲了安全的享用結構化併發的好處並不作沒必要要的工做,咱們須要確保咱們協程是可取消的。
使用在JetPack中定義的CoroutineScopes (viewModelScope 或 lifecycleScope )可確保看成用域結束時內部的協程也會取消。若是咱們要建立本身的CoroutineScopes ,應將它綁定到一個Job 並在須要時取消。 協程的取消時協做式的,因此在使用協程時要確保它的取消是惰性的以免執行沒必要要的操做。