前言
協程系列文章:
上篇分析了線程異常&取消操作以及協程Job相關知識,有了這些基礎知識,我們再來看協程的取消與異常處理就比較簡單了。
通過本篇文章,你將了解到:
- 協程取消的幾種方式
- 協程異常處理幾種方式
- 協程異常傳遞原理
1. 協程取消的幾種方式
非阻塞狀態時取消
先看Demo:
class CancelDemo {
fun testCancel() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
println("job1 start")
Thread.sleep(200)
var count = 0
while (count < 1000000000) {
count++
}
println("job1 end count:$count")
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消協程)
job1.cancel()
println("end cancel job1")
}
}
}
fun main(args: Array<String>) {
var demo = CancelDemo()
demo.testCancel()
Thread.sleep(1000000)
}
先啟動一個子協程,它返回Job對象,當子協程成功運行后再取消它。
結果如下:
該打印反饋出兩個信息:
- 子協程啟動并運行后才開始取消它。
- 子協程并沒有終止運行,而是正常運行到結束。
你可能對第2點比較困惑,為啥取消沒效果呢?
還記得我們上篇分析的線程的終止嗎?在非阻塞狀態下,通過Thread.interrupt()調用下僅僅只是喚醒線程并且設置標記位。
與線程類似,協程Job.cancel()函數僅僅只是將state值改變而已,當然我們可以主動獲取協程當前的狀態。
runBlocking() {
var job1 = launch(Dispatchers.IO) {
println("job1 start")
Thread.sleep(80)
var count = 0
//判斷協程的狀態,若是活躍則繼續循環
//isActive = coroutineContext[Job]?.isActive ?: true
while (count < 1000000000 && isActive) {
count++
}
println("job1 end count:$count")
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消協程)
job1.cancel()
println("end cancel job1")
}
}
運行結果:
從打印結果可以看出:
協程確實被取消了,可以通過Job.isActive 判斷取消是否成功,若Job.isActive = false 則表示協程被取消了。
阻塞狀態時取消
說到阻塞狀態,你可能會說:"簡單,我幾行代碼就給你演示了:"
fun testCancel3() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
Thread.sleep(3000)
println("coroutine isActive:$isActive")//①
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消協程)
job1.cancel()
println("end cancel job1")
}
}
先猜猜①會打印嗎?有同學說不會打印,因為Thread.sleep(xx)方法會拋出異常。
實際結果卻是:①會打印。
認為不會打印的同學可能將線程的阻塞與協程的阻塞(掛起)混淆了,Thread.sleep(xx)是阻塞協程所在的線程,它是線程的專屬方法,因此它會響應線程的中斷:Thread.interrupt()并拋出異常,而不會響應協程的Job.cancel()函數。
協程阻塞(掛起)并不會阻塞其所在的線程,改造Demo如下:
fun testCancel4() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
//協程掛起
println("job1 start")
delay(3000)
println("coroutine isActive:$isActive")//①
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消協程)
job1.cancel()
println("end cancel job1")
}
}
觀察打印結果,我們發現①始終無法打印出來,我們有理由相信協程執行到delay(xx)時拋出了異常,導致后續的代碼無法執行,接著驗證猜想。
fun testCancel4() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
//協程掛起
println("job1 start")
try {
delay(3000)
} catch (e : Exception) {
println("delay exception:$e")
}
println("coroutine isActive:$isActive")//①
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消協程)
job1.cancel()
println("end cancel job1")
}
}
如上,給delay(xx)函數加了異常處理,打印結果如下:
果然不出所料,Job.cancel(xx)引發了delay(xx)異常,它拋出的異常為:JobCancellationException,該異常在JVM平臺繼承自CancellationException。
如何"優雅"地取消協程
結合阻塞/非阻塞狀態下取消協程的分析,與線程處理方式類似:對于阻塞狀態的協程,我們可以捕獲異常,對于非阻塞的地方我們使用狀態判斷。
根據不同的結果來決定協程被取消后代碼的處理邏輯。
fun testCancel5() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
try {
//掛起函數
} catch (e : Exception) {
println("delay exception:$e")
}
if (!isActive) {
println("cancel")
}
}
}
}
2. 協程異常處理幾種方式
try...catch異常
上面提及了協程的取消異常,它是比較特殊的異常,我們先來看看普通的異常處理。
fun testException() {
runBlocking {
try {
var job1 = launch(Dispatchers.IO) {
println("job1 start")
//異常
1 / 0
println("job1 end")
}
} catch (e: Exception) {
}
}
}
先猜猜這樣能夠捕獲異常嗎?根據我們上篇線程異常捕獲的經驗,此處的子協程運行在子線程里,在子線程里發生的異常,主線程當然無法通過try 捕獲到。
當然,萬能的方式是在子協程里捕獲:
fun testException2() {
runBlocking {
var job1 = launch(Dispatchers.IO) {
try {
println("job1 start")
//異常
1 / 0
println("job1 end")
} catch (e : Exception) {
println("e=$e")
}
}
}
}
全局捕獲異常
與線程類似,協程也可以全局捕獲異常。
//創建處理異常對象
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("handle exception:$exception")
}
fun testException3() {
runBlocking {
//聲明協程作用域
var scope = CoroutineScope(Job() + exceptionHandler)
var job1 = scope.launch(Dispatchers.IO) {
println("job1 start")
//異常
1 / 0
println("job1 end")
}
}
}
如上Demo,先定義一個異常處理對象,然后將它與協程作用域關聯起來。
當子協程發生了異常,這個異常往上拋給父Job,最后交給CoroutineExceptionHandler 處理。
此時,ArithmeticException 異常被CoroutineExceptionHandler 捕獲了。
注,雖然能夠捕獲異常,但是發生異常的協程還是不能往下執行了。
3. 協程異常傳遞原理
協程對異常的再加工
launch{}
花括號里的內容即為協程體,而執行這部分的邏輯在BaseContinuationImpl.resumeWith()函數里:
你可發現此處的重點?
這里將協程體的執行加了try...catch 捕獲了,也就是說不論協程體里發生了什么異常,在這里都能夠被捕獲。
你可能會問了,既然能夠捕獲,為啥還會有異常拋出呢?我們有理由相信,協程內部一定記錄了這個異常,然后在某個地方再次將它拋出。
此處捕獲了異常之后,將它構造為Result,并記錄在變量outcome里,接著看看后續對這個值的處理。
流程有點長,直接看調用棧:
重點看紅色框里的兩個函數。
#handleCoroutineExceptionImpl.kt
public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
try {
//從context里取出異常處理對象,對應外部設置的全局捕獲回調對象
context[CoroutineExceptionHandler]?.let {
//具體處理
it.handleException(context, exception)
//處理ok,直接退出
return
}
} catch (t: Throwable) {
handleCoroutineExceptionImpl(context, handlerException(exception, t))
return
}
//再次嘗試處理
handleCoroutineExceptionImpl(context, exception)
}
internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
// 嘗試handler處理
// 從當前線程拋出異常
val currentThread = Thread.currentThread()
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
}
如果我們定義了CoroutineExceptionHandler,那么使用該Handler處理異常,如果沒有定義,則直接拋出異常。
以上即為協程對異常的再加工處理過程。
異常在協程之間的傳遞(Job)
先看Demo:
fun testException4() {
runBlocking {
//聲明協程作用域
var rootJob = Job()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
//異常
1 / 0
println("job1 end")
}
job1.join()
//檢查父Job 狀態
println("rootJob isActive:${rootJob.isActive}")
}
}
rootJob 作為父Job,通過launch(xx)函數創建了子Job:job1。
等待job1執行完畢后,再檢查父Job 狀態。
打印結果如下:
此時我們發現:
當子Job 發生異常時,會取消父Job。
除了對父Job 有影響,對其它兄弟Job 是否有影響呢?
繼續做嘗試:
fun testException5() {
runBlocking {
//聲明協程作用域
var rootJob = Job()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(100)
//異常
1 / 0
println("job1 end")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(200)
//檢查jo2狀態
println("jo2 isActive:$isActive")
}
job1.join()
//檢查父Job 狀態
println("rootJob isActive:${rootJob.isActive}")
}
}
如上,父Job 分別創建了兩個子Job:job1、job2,當job1 發生異常時,分別檢測父Job與job2的狀態,打印結果如下:
很明顯得出結論:
當子Job 發生異常時,會將異常傳遞給父Job,父Job 先將自己名下的所有子Job都取消,然后將自己取消,最后繼續將異常往上拋。
這部分的傳遞依靠Job 鏈完成,上篇文章我們有深入分析過Job 結構:
從源碼分析其傳遞流程,先看調用棧:
重點看notifyCancelling(xx)函數:
#JobSupport.kt
//list == 子Job 鏈表
private fun notifyCancelling(list: NodeList, cause: Throwable) {
//回調,忽略
onCancelling(cause)
//取消所有子Job
notifyHandlers<JobCancellingNode>(list, cause)//①
//取消父Job
cancelParent(cause) //②
}
分為兩個要點:
①
#JobSupport.kt
private inline fun <reified T: JobNode> notifyHandlers(list: NodeList, cause: Throwable?) {
var exception: Throwable? = null
list.forEach<T> { node ->
try {
//遍歷list,調用node
node.invoke(cause)
} catch (ex: Throwable) {
//...
}
}
//..
}
調用至此,實際上是job1.notifyCancelling(xx),因為job1沒有子Job,因此①處list 里沒有節點。
②
#JobSupport.kt
private fun cancelParent(cause: Throwable): Boolean {
val isCancellation = cause is CancellationException
val parent = parentHandle
if (parent === null || parent === NonDisposableHandle) {
//沒有父Job,無法繼續往上,停止
return isCancellation
}
//取消父Job
return parent.childCancelled(cause) || isCancellation
}
如果你看過上篇文章的分析,再看此處就比較容易了,此處再貼一下Node 結構:
#JobSupport.kt
//主要有2個成員變量
//childJob: ChildJob 表示當前node指向的子Job
//parent: Job 表示當前node 指向的父Job
internal class ChildHandleNode(
@JvmField val childJob: ChildJob
) : JobCancellingNode(), ChildHandle {
override val parent: Job get() = job
//父Job 取消其所有子Job
override fun invoke(cause: Throwable?) = childJob.parentCancelled(job)
//子Job向上傳遞,取消父Job
override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}
對于①來說,list 里的node 為ChildHandleNode,node.invoke(cause)其實調用的就是childJob.parentCancelled(job),而childJob 表示每個子Job。
#JobSupport.kt
public final override fun parentCancelled(parentJob: ParentJob) {
//遍歷Job 下的子Job,取消它們
cancelImpl(parentJob)
}
就這么層層遍歷下去,直至取消完所有層級的子Job。
而對于②而言,parent.childCancelled(cause)==job.childCancelled(cause),而job 表示的是當前job 的父Job。
#JobSupport.kt
public open fun childCancelled(cause: Throwable): Boolean {
//如果是取消異常,則忽略
if (cause is CancellationException) return true
//取消父Job
return cancelImpl(cause) && handlesException
}
這段代碼透露出兩個意思:
- 取消時候產生的異常稱為"取消異常",該異常比較特殊,當某個job 發生異常時,它不會往上傳遞。
- 如果不是取消異常,則調用cancelImpl(xx)函數,該函數取消當前Job的所有子Job 與自己。
因為Job 鏈類似樹的結構,因此異常傳遞是遞歸形式的。
Job 發生異常時,不僅取消自己名下的所有Job,也會取消父Job,往上遞歸直至根Job。
SupervisorJob 作用與原理
作用
子協程發生異常后,會取消父協程、兄弟協程的執行,這在有些場景是不合理的,因為傷害范圍太廣,明明是一個子協程的鍋,非得所有協程來背。
還好官方考慮過這個問題,提供了SupervisorJob 來解決該問題。
fun testException6() {
runBlocking {
//聲明協程作用域
var rootJob = SupervisorJob()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(100)
//異常
1 / 0
println("job1 end")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(200)
//檢查jo2狀態
println("jo2 isActive:$isActive")
}
job1.join()
//檢查父Job 狀態
println("rootJob isActive:${rootJob.isActive}")
}
}
僅僅改動了一個地方:將Job()換為SupervisorJob()。
結果如下:
job1 發生異常的時候,job2 和父job都沒受到影響。
原理
當需要取消父Job 時,勢必會調用到:job.childCancelled(cause)
而SupervisorJob 重寫了該函數:
#Supervisor.kt
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
override fun childCancelled(cause: Throwable): Boolean = false
}
不做任何處理,當然就不能取消父Job了,不能取消父Job,也就不能取消父Job 下的子Job。
對比Job()與SupervisorJob() 可知:
取消異常的傳遞
job.childCancelled(cause) 表示要取消父Job,而該函數實現里有對取消異常進行了特殊處理,因此取消異常不會往上傳遞。
fun testException7() {
runBlocking {
//聲明協程作用域
var rootJob = SupervisorJob()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(2000)
println("job1 end")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(1000)
//檢查jo2狀態
println("jo2 isActive:$isActive")
}
Thread.sleep(300)
job1.cancel()
//檢查父Job 狀態
println("rootJob isActive:${rootJob.isActive}")
}
}
取消job1,不會影響父Job,也不會影響子Job。
當取消父Job時,查看子Job 是否受影響。
fun testException8() {
runBlocking {
//聲明協程作用域
var rootJob = SupervisorJob()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(2000)
println("jo1 isActive:$isActive")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(1000)
//檢查jo2狀態
println("jo2 isActive:$isActive")
}
Thread.sleep(300)
rootJob.cancel()
//檢查父Job 狀態
println("rootJob isActive:${rootJob.isActive}")
}
}
當父Job 取消時,子Job 都會被取消。
至此,所有內容分析完畢,小結一下之前的內容:
- 協程的異常會沿著Job鏈傳遞,子協程發生異常會導致父協程(祖父協程...)、兄弟協程的取消。
- 若要防止上述情況,需要使用SupervisorJob作為父Job,它將忽略子Job產生的異常,不將它傳遞出去。
- 取消異常不會向上傳遞,父協程的取消會導致其下所有的子協程被取消。
關于協程的取消與異常處理到此分析完畢,下篇將分析launch/async/delay/runBlocking 的使用、原理以及異同點。
本文基于Kotlin 1.5.3,文中完整Demo請點擊
您若喜歡,請點贊、關注、收藏,您的鼓勵是我前進的動力
持續更新中,和我一起步步為營系統、深入學習Android/Kotlin
1、Android各種Context的前世今生
2、Android DecorView 必知必會
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分發全套服務
6、Android invalidate/postInvalidate/requestLayout 徹底厘清
7、Android Window 如何確定大小/onMeasure()多次執行原因
8、Android事件驅動Handler-Message-Looper解析
9、Android 鍵盤一招搞定
10、Android 各種坐標徹底明了
11、Android Activity/Window/View 的background
12、Android Activity創建到View的顯示過
13、Android IPC 系列
14、Android 存儲系列
15、Java 并發系列不再疑惑
16、Java 線程池系列
17、Android Jetpack 前置基礎系列
18、Android Jetpack 易懂易學系列
19、Kotlin 輕松入門系列
20、Kotlin 協程系列全面解讀