協(xié)程中的取消和異常 | 取消操作詳解

image

在日常的開(kāi)發(fā)中,我們都知道應(yīng)該避免不必要的任務(wù)處理來(lái)節(jié)省設(shè)備的內(nèi)存空間和電量的使用——這一原則在協(xié)程中同樣適用员萍。您需要控制好協(xié)程的生命周期靡羡,在不需要使用的時(shí)候?qū)⑺∠@也是結(jié)構(gòu)化并發(fā)所倡導(dǎo)的螃征,繼續(xù)閱讀本文來(lái)了解有關(guān)協(xié)程取消的來(lái)龍去脈搪桂。

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

調(diào)用 cancel 方法

當(dāng)啟動(dòng)多個(gè)協(xié)程時(shí)踢械,無(wú)論是追蹤協(xié)程狀態(tài)酗电,還是單獨(dú)取消各個(gè)協(xié)程,都是件讓人頭疼的事情内列。不過(guò)撵术,我們可以通過(guò)直接取消協(xié)程啟動(dòng)所涉及的整個(gè)作用域 (scope) 來(lái)解決這個(gè)問(wèn)題,因?yàn)檫@樣可以取消所有已創(chuàng)建的子協(xié)程话瞧。

// 假設(shè)我們已經(jīng)定義了一個(gè)作用域

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

scope.cancel()

取消作用域會(huì)取消它的子協(xié)程

有時(shí)候嫩与,您也許僅僅需要取消其中某一個(gè)協(xié)程,比如用戶輸入了某個(gè)事件交排,作為回應(yīng)要取消某個(gè)進(jìn)行中的任務(wù)划滋。如下代碼所示,調(diào)用 job1.cancel 會(huì)確保只會(huì)取消跟 job1 相關(guān)的特定協(xié)程埃篓,而不會(huì)影響其余兄弟協(xié)程繼續(xù)工作处坪。

// 假設(shè)我們已經(jīng)定義了一個(gè)作用域

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

被取消的子協(xié)程并不會(huì)影響其余兄弟協(xié)程

協(xié)程通過(guò)拋出一個(gè)特殊的異常 CancellationException 來(lái)處理取消操作架专。在調(diào)用 .cancel 時(shí)您可以傳入一個(gè) CancellationException 實(shí)例來(lái)提供更多關(guān)于本次取消的詳細(xì)信息同窘,該方法的簽名如下:

fun cancel(cause: CancellationException? = null)

如果您不構(gòu)建新的 CancellationException 實(shí)例將其作為參數(shù)傳入的話,會(huì)創(chuàng)建一個(gè)默認(rèn)的 CancellationException (請(qǐng)查看 完整代碼)部脚。

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

一旦拋出了 CancellationException 異常想邦,您便可以使用這一機(jī)制來(lái)處理協(xié)程的取消。有關(guān)如何執(zhí)行此操作的更多信息睛低,請(qǐng)參考下面的處理取消的副作用一節(jié)案狠。

在底層實(shí)現(xiàn)中,子協(xié)程會(huì)通過(guò)拋出異常的方式將取消的情況通知到它的父級(jí)钱雷。父協(xié)程通過(guò)傳入的取消原因來(lái)決定是否來(lái)處理該異常骂铁。如果子協(xié)程因?yàn)?CancellationException 而被取消,對(duì)于它的父級(jí)來(lái)說(shuō)是不需要進(jìn)行其余額外操作的罩抗。

不能在已取消的作用域中再次啟動(dòng)新的協(xié)程

如果您使用的是 androidx KTX 庫(kù)的話拉庵,在大部分情況下都不需要?jiǎng)?chuàng)建自己的作用域,所以也就不需要負(fù)責(zé)取消它們套蒂。如果您是在 ViewModel 的作用域中進(jìn)行操作钞支,請(qǐng)使用 viewModelScope,或者如果在生命周期相關(guān)的作用域中啟動(dòng)協(xié)程操刀,那就應(yīng)該使用 lifecycleScope烁挟。viewModelScope 和 lifecycleScope 都是 CoroutineScope 對(duì)象,它們都會(huì)在適當(dāng)?shù)臅r(shí)間點(diǎn)被取消骨坑。例如撼嗓,當(dāng) ViewModel 被清除時(shí)柬采,在其作用域內(nèi)啟動(dòng)的協(xié)程也會(huì)被一起取消。

為什么協(xié)程處理的任務(wù)沒(méi)有停止且警?

如果我們僅是調(diào)用了 cancel 方法粉捻,并不意味著協(xié)程所處理的任務(wù)也會(huì)停止。如果您使用協(xié)程處理了一些相對(duì)較為繁重的工作斑芜,比如讀取多個(gè)文件肩刃,那么您的代碼不會(huì)自動(dòng)就停止此任務(wù)的進(jìn)行。

讓我們舉一個(gè)更簡(jiǎn)單的例子看看會(huì)發(fā)生什么杏头。假設(shè)我們需要使用協(xié)程來(lái)每秒打印兩次 "Hello"盈包。我們先讓協(xié)程運(yùn)行一秒,然后將其取消大州。其中一個(gè)版本實(shí)現(xiàn)如下所示:

image

我們一步一步來(lái)看發(fā)生了什么续语。當(dāng)調(diào)用 launch 方法時(shí),我們創(chuàng)建了一個(gè)活躍 (active) 狀態(tài)的協(xié)程厦画。緊接著我們讓協(xié)程運(yùn)行了 1,000 毫秒,打印出來(lái)的結(jié)果如下:

Hello 0
Hello 1
Hello 2

當(dāng) job.cancel 方法被調(diào)用后滥朱,我們的協(xié)程轉(zhuǎn)變?yōu)槿∠?(cancelling) 的狀態(tài)根暑。但是緊接著我們發(fā)現(xiàn) Hello 3 和 Hello 4 打印到了命令行中。當(dāng)協(xié)程處理的任務(wù)結(jié)束后徙邻,協(xié)程又轉(zhuǎn)變?yōu)榱艘讶∠?(cancelled) 狀態(tài)排嫌。

協(xié)程所處理的任務(wù)不會(huì)僅僅在調(diào)用 cancel 方法時(shí)就停止,相反缰犁,我們需要修改代碼來(lái)定期檢查協(xié)程是否處于活躍狀態(tài)淳地。

讓您的協(xié)程可以被取消

您需要確保所有使用協(xié)程處理任務(wù)的代碼實(shí)現(xiàn)都是協(xié)作式的,也就是說(shuō)它們都配合協(xié)程取消做了處理帅容,因此您可以在任務(wù)處理期間定期檢查協(xié)程是否已被取消颇象,或者在處理耗時(shí)任務(wù)之前就檢查當(dāng)前協(xié)程是否已取消。例如并徘,如果您從磁盤(pán)中獲取了多個(gè)文件遣钳,在開(kāi)始讀取文件內(nèi)容之前,先檢查協(xié)程是否被取消了麦乞。類似這樣的處理方式蕴茴,您可以避免處理不必要的 CPU 密集型任務(wù)。

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

所有 kotlinx.coroutines 中的掛起函數(shù) (withContext, delay 等) 都是可取消的姐直。如果您使用它們中的任一個(gè)函數(shù)倦淀,都不需要檢查協(xié)程是否已取消,然后停止任務(wù)執(zhí)行声畏,或是拋出 CancellationException 異常撞叽。但是,如果沒(méi)有使用這些函數(shù),為了讓您的代碼能夠配合協(xié)程取消能扒,可以使用以下兩種方法:

  • 檢查 job.isActive 或者使用 ensureActive()
  • 使用 yield() 來(lái)讓其他任務(wù)進(jìn)行

檢查 job 的活躍狀態(tài)

先看一下第一種方法佣渴,在我們的 while(i<5) 循環(huán)中添加對(duì)于協(xié)程狀態(tài)的檢查:

// 因?yàn)樘幱?launch 的代碼塊中,可以訪問(wèn)到 job.isActive 屬性
while (i < 5 && isActive)

這樣意味著我們的任務(wù)只會(huì)在協(xié)程處于活躍的狀態(tài)下執(zhí)行初斑。同樣辛润,這也意味著在 while 循環(huán)之外,我們?nèi)暨€想處理別的行為见秤,比如在 job 被取消后打日志出來(lái)砂竖,那就可以檢查 !isActive 然后再繼續(xù)進(jìn)行相應(yīng)的處理。

Coroutine 的代碼庫(kù)中還提供了另一個(gè)很有用的方法 —— ensureActive()鹃答,它的實(shí)現(xiàn)如下:

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

如果 job 處于非活躍狀態(tài)乎澄,這個(gè)方法會(huì)立即拋出異常,我們可以在 while 循環(huán)開(kāi)始就使用這個(gè)方法测摔。

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

通過(guò)使用 ensureActive 方法置济,您可以避免使用 if 語(yǔ)句來(lái)檢查 isActive 狀態(tài),這樣可以減少樣板代碼的使用量锋八,但是相應(yīng)地也失去了處理類似于日志打印這種行為的靈活性浙于。

使用 yield() 函數(shù)運(yùn)行其他任務(wù)

如果要處理的任務(wù)屬于 1) CPU 密集型,2) 可能會(huì)耗盡線程池資源挟纱,3) 需要在不向線程池中添加更多線程的前提下允許線程處理其他任務(wù)羞酗,那么請(qǐng)使用 yield()。如果 job 已經(jīng)完成紊服,由 yield 所處理的首要任務(wù)將會(huì)是檢查任務(wù)的完成狀態(tài)檀轨,完成的話則直接通過(guò)拋出 CancellationException 來(lái)退出協(xié)程。yield 可以作為定期檢查所調(diào)用的第一個(gè)函數(shù)欺嗤,例如上面提到的 ensureActive() 方法参萄。

Job.join ?? Deferred.await cancellation**

等待協(xié)程處理結(jié)果有兩種方法: 來(lái)自 launch 的 job 可以調(diào)用 join 方法,由 async 返回的 Deferred (其中一種 job 類型) 可以調(diào)用 await 方法剂府。

Job.join 會(huì)掛起協(xié)程拧揽,直到任務(wù)處理完成。與 job.cancel 一起使用時(shí)腺占,會(huì)按照以下方式進(jìn)行:

  • 如果您調(diào)用 job.cancel 之后再調(diào)用 job.join淤袜,那么協(xié)程會(huì)在任務(wù)處理完成之前一直處于掛起狀態(tài);
  • 在 job.join 之后調(diào)用 job.cancel 沒(méi)有什么影響衰伯,因?yàn)?job 已經(jīng)完成了铡羡。

如果您關(guān)心協(xié)程處理結(jié)果,那么應(yīng)該使用 Deferred意鲸。當(dāng)協(xié)程完成后烦周,結(jié)果會(huì)由 Deferred.await 返回尽爆。Deferred 是 Job 的其中一種類型,它同樣可以被取消读慎。

在已取消的 deferred 上調(diào)用 await 會(huì)拋出 JobCancellationException 異常漱贱。

val deferred = async { … }

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

為什么會(huì)拿到這個(gè)異常呢?await 的角色是負(fù)責(zé)在協(xié)程處理結(jié)果出來(lái)之前一直將協(xié)程掛起夭委,因?yàn)槿绻麉f(xié)程被取消了那么協(xié)程就不會(huì)繼續(xù)進(jìn)行計(jì)算幅狮,也就不會(huì)有結(jié)果產(chǎn)生。因此株灸,在協(xié)程取消后調(diào)用 await 會(huì)拋出 JobCancellationException 異常: 因?yàn)?Job 已被取消崇摄。

另一方面,如果您在 deferred.cancel 之后調(diào)用 deferred.await 不會(huì)有任何情況發(fā)生慌烧,因?yàn)閰f(xié)程已經(jīng)處理結(jié)束逐抑。

處理協(xié)程取消的副作用

假設(shè)您要在協(xié)程取消后執(zhí)行某個(gè)特定的操作,比如關(guān)閉可能正在使用的資源屹蚊,或者是針對(duì)取消需要進(jìn)行日志打印厕氨,又或者是執(zhí)行其余的一些清理代碼。我們有好幾種方法可以做到這一點(diǎn):

檢查 !isActive

如果您定期地進(jìn)行 isActive 的檢查汹粤,那么一旦您跳出 while 循環(huán)腐巢,就可以進(jìn)行資源的清理。之前的代碼可以更新至如下版本:

while (i < 5 && isActive) {
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
 
// 協(xié)程所處理的任務(wù)已經(jīng)完成玄括,因此我們可以做一些清理工作
println(“Clean up!”)

您可以查看 完整版本

所以現(xiàn)在肉瓦,當(dāng)協(xié)程不再處于活躍狀態(tài)遭京,會(huì)退出 while 循環(huán),就可以處理一些清理工作了泞莉。

Try catch finally

因?yàn)楫?dāng)協(xié)程被取消后會(huì)拋出 CancellationException 異常哪雕,我們可以將掛起的任務(wù)放置于 try/catch 代碼塊中,然后在 finally 代碼塊中執(zhí)行需要做的清理任務(wù)鲫趁。

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      println(“Clean up!”)
    }
}

delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

但是斯嚎,一旦我們需要執(zhí)行的清理工作也掛起了,那上述代碼就不能夠繼續(xù)工作了挨厚,因?yàn)橐坏﹨f(xié)程處于取消中狀態(tài)堡僻,它將不能再轉(zhuǎn)為掛起 (suspend) 狀態(tài)。您可以查看 完整代碼疫剃。

處于取消中狀態(tài)的協(xié)程不能夠掛起

當(dāng)協(xié)程被取消后需要調(diào)用掛起函數(shù)钉疫,我們需要將清理任務(wù)的代碼放置于 NonCancellable CoroutineContext 中。這樣會(huì)掛起運(yùn)行中的代碼巢价,并保持協(xié)程的取消中狀態(tài)直到任務(wù)處理完成牲阁。

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // 或一些其他的掛起函數(shù)
         println(“Cleanup done!”)
      }
    }
}

delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

您可以查看其 工作原理固阁。

suspendCancellableCoroutine 和 invokeOnCancellation

如果您通過(guò) suspendCoroutine 方法將回調(diào)轉(zhuǎn)為協(xié)程,那么您更應(yīng)該使用 suspendCancellableCoroutine 方法城菊”溉迹可以使用 continuation.invokeOnCancellation 來(lái)執(zhí)行取消操作:

suspend fun work() {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // 處理清理工作
       }
   // 剩余的實(shí)現(xiàn)代碼
}

為了享受到結(jié)構(gòu)化并發(fā)帶來(lái)的好處,并確保我們并沒(méi)有進(jìn)行多余的操作凌唬,那么需要保證代碼是可被取消的并齐。

使用在 Jetpack: viewModelScope 或者 lifecycleScope 中定義的 CoroutineScopes,它們?cè)?scope 完成后就會(huì)取消它們處理的任務(wù)法瑟。如果要?jiǎng)?chuàng)建自己的 CoroutineScope冀膝,請(qǐng)確保將其與 job 綁定并在需要時(shí)調(diào)用 cancel。

協(xié)程代碼的取消需要是協(xié)作式的霎挟,因此請(qǐng)將代碼更新為對(duì)協(xié)程的取消操作以延后的方式進(jìn)行檢查窝剖,并避免不必要的操作。

現(xiàn)在酥夭,大家了解了本系列的第一部分 協(xié)程的一些基本概念赐纱、第二部分協(xié)程的取消,在接下來(lái)的文章中熬北,我們將繼續(xù)深入探討學(xué)習(xí)第三部分異常處理疙描,感興趣的讀者請(qǐng)繼續(xù)關(guān)注我們的更新。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末讶隐,一起剝皮案震驚了整個(gè)濱河市起胰,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌巫延,老刑警劉巖效五,帶你破解...
    沈念sama閱讀 207,248評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異炉峰,居然都是意外死亡畏妖,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評(píng)論 2 381
  • 文/潘曉璐 我一進(jìn)店門(mén)疼阔,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)戒劫,“玉大人,你說(shuō)我怎么就攤上這事婆廊⊙赶福” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 153,443評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵否彩,是天一觀的道長(zhǎng)疯攒。 經(jīng)常有香客問(wèn)我,道長(zhǎng)列荔,這世上最難降的妖魔是什么敬尺? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,475評(píng)論 1 279
  • 正文 為了忘掉前任枚尼,我火速辦了婚禮,結(jié)果婚禮上砂吞,老公的妹妹穿的比我還像新娘署恍。我一直安慰自己,他們只是感情好蜻直,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,458評(píng)論 5 374
  • 文/花漫 我一把揭開(kāi)白布盯质。 她就那樣靜靜地躺著,像睡著了一般概而。 火紅的嫁衣襯著肌膚如雪呼巷。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 49,185評(píng)論 1 284
  • 那天赎瑰,我揣著相機(jī)與錄音王悍,去河邊找鬼。 笑死餐曼,一個(gè)胖子當(dāng)著我的面吹牛压储,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播源譬,決...
    沈念sama閱讀 38,451評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼集惋,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了踩娘?” 一聲冷哼從身側(cè)響起刮刑,我...
    開(kāi)封第一講書(shū)人閱讀 37,112評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎养渴,沒(méi)想到半個(gè)月后为朋,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,609評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡厚脉,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,083評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了胶惰。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片傻工。...
    茶點(diǎn)故事閱讀 38,163評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖孵滞,靈堂內(nèi)的尸體忽然破棺而出中捆,到底是詐尸還是另有隱情,我是刑警寧澤坊饶,帶...
    沈念sama閱讀 33,803評(píng)論 4 323
  • 正文 年R本政府宣布泄伪,位于F島的核電站,受9級(jí)特大地震影響匿级,放射性物質(zhì)發(fā)生泄漏蟋滴。R本人自食惡果不足惜染厅,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,357評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望津函。 院中可真熱鬧肖粮,春花似錦、人聲如沸尔苦。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,357評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)允坚。三九已至魂那,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間稠项,已是汗流浹背涯雅。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,590評(píng)論 1 261
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留皿渗,地道東北人斩芭。 一個(gè)月前我還...
    沈念sama閱讀 45,636評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像乐疆,于是被迫代替她去往敵國(guó)和親划乖。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,925評(píng)論 2 344