2.4-结构化并发:协程的结构化异常管理

avatar
作者
筋斗云
阅读量:0

文章目录

协程的异常很多人都觉得很难,[难] 主要体现在两个地方,表面原因是它很复杂,深层原因是大多数人没有理解协程的异常结构化管理有什么用

所以接下来我们就从这两个 [难] 的原因由浅到深的讲解协程的结构化异常管理。

下面的内容将会在 协程的结构化取消 的基础上讲解,建议提前了解才能更好掌握协程结构化异常流程。

协程结构化异常流程

协程结构化异常流程和取消流程的区别

了解结构化流程我们直接上示例代码:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	scope.launch { 		println("Parent started") 		launch { 			println("Child started") 			throw RuntimeException() 			println("Child finished") 		} 		println("Parent finished") 	} 	delay(10000) }  输出结果: Parent started Parent finished Child started // 抛出异常 

上面的代码在子协程抛出了异常,会导致父协程也被取消。更具体的说,当协程抛出异常时(除了 CancellationException),它往上、往下的整个父子协程树全都会因为这个异常而被取消掉

实际上协程的异常处理和协程的取消用的是同一套逻辑。我们可以通过打印协程的状态验证:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	var childJob: Job? = null 	val parentJob = scope.launch { 		childJob = launch { 			println("Child started") 			delay(3000) 			println("Child done") 		} 		delay(1000) 		throw IllegalStateException("Wrong user!") 	} 	// 抛异常前打印状态 	delay(500) 	println("before, isActive: parent - ${parentJob.isActive}, child - ${childJob?.isActive}") 	println("before, isCancelled: parent - ${parentJob.isCancelled}, child - ${childJob?.isCancelled}") 	// 抛异常后打印状态 	delay(1000) 	println("after, isActive: parent - ${parentJob.isActive}, child - ${childJob?.isActive}") 	println("after, isCancelled: parent - ${parentJob.isCancelled}, child - ${childJob?.isCancelled}") 	delay(10000) }  输出结果: Child started // 子协程只打印了一行 before, isActive: parent - true, child - true before, isCancelled: parent - false, child - false // 抛出异常 after, isActive: parent - false, child - false after, isCancelled: parent - true, child - true // 抛异常会导致协程都取消 

上面的代码会延时 500ms 时打印父子协程的状态,在父协程 1s 后抛出异常,抛异常后再打印状态。可以看到父协程抛出了异常,父子协程的 isActive 都打印为 false,isCancelled 都打印为 true,说明父子协程都被取消了。

如果我们把异常换成了 CancellationException:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	var childJob: Job? = null 	val parentJob = scope.launch { 		childJob = launch { 			println("Child started") 			delay(3000) 			println("Child done") 		} 		delay(1000) 		throw CancellationException("Wrong user!") 	} 	// 抛异常前打印状态 	delay(500) 	println("before, isActive: parent - ${parentJob.isActive}, child - ${childJob?.isActive}") 	println("before, isCancelled: parent - ${parentJob.isCancelled}, child - ${childJob?.isCancelled}") 	// 抛异常后打印状态 	delay(1000) 	println("after, isActive: parent - ${parentJob.isActive}, child - ${childJob?.isActive}") 	println("after, isCancelled: parent - ${parentJob.isCancelled}, child - ${childJob?.isCancelled}") 	delay(10000) }  输出结果: Child started before, isActive: parent - true, child - true before, isCancelled: parent - false, child - false after, isActive: parent - false, child - false after, isCancelled: parent - true, child - true 

可以看到除了 CancellationException 不会导致打印异常之外,父子协程状态 isActive 都打印 false,isCancelled 都打印 true,父子协程的状态更改都是一样的,也能印证异常流程和取消流程用的是同一套逻辑。

也可以理解为 当在协程抛出的是 CancellationException 时,协程会走简化版的异常流程,也就是取消流程

那么取消流程比异常流程简化掉了什么呢?

  • 取消流程的连带取消只是向内的,即协程取消只会连带性的取消它的子协程

  • 异常流程的连带取消是双向的,不仅会向内取消它的子协程,还会向外取消它的父协程,每一个被取消的父协程序也会把它们的每个子协程取消,直到整个协程树都被取消

每个协程被取消时,它的父协程都会调用 childCancelled 函数:

JobSupport.kt  public open class JobSupport constructor(active: Boolean) : Job, ChildJob, ParentJob { 	// 每个协程被取消的时候,它的父协程都会调用这个函数 	// cause:触发子协程取消的那个异常 	// cancelImpl(cause) 就是取消父协程自己 	public open fun childCancelled(cause: Throwable): Boolean { 		if (cause is CancellationException) return true // 协程取消不会取消父协程 		return cancelImpl(cause) && handlesException // 普通异常会取消父协程 	} } 

在 childCancelled 函数判断异常类型为 CancellationException 时,协程取消不会连带性把父协程取消直接返回 true;如果是其他异常时,它会执行父协程的取消流程 cancelImpl。

用一个简单的示例验证下:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	scope.launch { 		launch { 			// 开启一个孙子协程 			launch { 				println("Grand child started") 				delay(3000) 				println("Grand child done") 			} 			delay(1000) 			// 在子协程取消 			throw CancellationException("Child cancelled!") 			// throw IllegalStateException("User invalid!") 		} 		println("Parent started") 		delay(3000) 		println("Parent done") 	} 	delay(500) 	delay(10000) }  输出结果: // 子协程抛出 CancellationException Parent started Grand child started Parent done // 子协程取消,孙子协程没有打印,父协程有打印没有被取消  // 子协程抛出其他异常 Parent started Grand child started // 父协程和孙子协程都没有打印,都被取消了 

上面的代码在子协程抛出 CancellationException 时,也就是子协程被取消了,可以看到父协程还是正常打印,但在子协程启动的孙子协程后续没有再打印了;而抛出其他异常时,父协程和孙子协程都没有再打印。

总结下结构化异常流程和结构化取消流程的区别

  • 异常流程的子协程抛异常会导致父协程被取消;取消流程只会取消子协程

  • 异常流程只有内部抛异常的方式触发(也很好理解,调用函数触发异常也太奇怪了);取消流程可以从外部 job.cancel() 或者内部抛 CancellationException 取消

  • 异常流程抛的异常除了用来取消协程,还会把这个异常暴露给线程世界;取消流程抛 CancellationException 只用来取消

子协程异常为什么要连带取消父协程?

或许看了上面的代码和示例代码验证后你会很疑惑:为什么异常流程除了取消子协程还要取消父协程?子协程抛出异常就只会影响它的子协程取消?

因为子协程一旦抛异常,影响了外部大流程的执行,让其他协程的执行失去了意义,父协程的执行也失去了意义

协程的结构化有两个特性:

  • 父子协程连带性取消的性质:从正常逻辑来讲,当一个大流程都被取消的时候,它内部的子流程也就直接没用

  • 父协程等待子协程的性质:父协程会等待所有子协程都完成之后才完成自己,这也是子流程都完成了,整个大流程才能算是完成

子流程被取消,对外部大流程来说可能是一个正常的事件而已,所以子协程的取消并不会导致父协程取消

但如果子协程是抛异常了(非 CancellationException 的异常),这通常被理解为子协程坏掉了,导致大流程无法完成,也就相当于父协程也坏掉了。所以协程因为普通异常而被取消,它的父协程也被以这个异常为原因而取消

简单理解就是,子协程的异常通常意味着父协程的损坏,所以干脆把父协程取消

CoroutineExceptionHandler

当我们在协程抛出异常时,和在线程内抛异常一样最终会导致崩溃,但在协程世界和线程世界之间如果还想捕获异常,还有一个方法:CoroutineExceptionHandler。

异常协程异常的最后一道拦截:CoroutineExceptionHandler

有一些协程的初学者在写协程代码的时候可能会这么写:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	try { 		scope.launch { 			throw RuntimeException() 		} 	} catch (e: Exception) { 		// 	} 	delay(10000) }  输出结果: // 抛出异常 

上面的代码很简单,用 try-catch 包住协程的执行,想要捕获协程的异常,但是最终发现并没有捕获到。

为什么按上面这么写无法捕获到里面的异常呢?我把上面的代码换一下你就明白了:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	try { 		// 换成了线程,线程内部抛异常,也是没法捕获 		thread { 			throw RuntimeException() 		} 	} catch (e: Exception) { 		// 	} 	delay(10000) } 

我们把协程换成了线程,在线程内部抛异常,外部的 try-catch 也是无法捕获异常的,都在不同的线程了肯定不能捕获到异常。

那么刚才的协程也是同理,try-catch 只能捕获协程启动的代码,协程启动结束了 try-catch 代码块也就结束了。

协程太简洁了让我们产生了错觉,觉得 launch 的大括号里面和外面是同一套流程,但实际上协程都是并行的流程是无法互相 try-catch

当然这也是因为协程是可以套着写启动子协程,写多了就会有一个错觉能用 try-catch 捕获异常,在线程我们很少像协程一样套着启动线程所以没这个想法:

scope.launch { 	try { 		launch {} 	} catch (e: Exception) { 	} 	 	launch {} } 

如果我们想捕获协程的异常,可以用 CoroutineExceptionHandler 传给最外面的父协程

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	val handler = CoroutineExceptionHandler { _, exception ->  		println("Caught $exception") 	} 	// CoroutineExceptionHandler 设置给最外层的协程 	scope.launch(handler) { 		launch { 			throw RuntimeException("Error!") 		} 	} 	delay(10000) }  输出结果: Caught java.lang.RuntimeException: Error! 

对于一个多层结构的协程树,只要给它们最外层的父协程设置一个 CoroutineExceptionHandler,它里面所有子协程包括最外层父协程的异常都会集中到它这里来统一处理

需要注意的是,CoroutineExceptionHandler 只能设置到最外面的父协程,设置到内层协程是没用的

CoroutineExceptionHandler 为什么只能设置给最外层协程才有效?

在上面我们有提到使用 CoroutineExceptionHandler 必须将它设置给最外层的父协程才能生效捕获到子协程抛出的异常。

为什么只能设置给最外层呢?在讲解这个原理之前,我们先了解下 Java 提供的 UncaughtExceptionHandler。

fun main() = runBlocking { 	// 对所有线程异常都捕获的 UncaughtExceptionHandler 	Thread.setDefaultUncaughtExceptionHandler { t, e ->  		println("Caught default: $e") 		// 记录异常日志收尾后将程序杀死或重启 	} 	val thread = Thread { 		throw RuntimeException("Thread error!") 	} 	// 针对单个线程的 UncaughtExceptionHandler 	// 优先级比 Thread.setDefaultUncaughtExceptionHandler 高 	// 如果线程处理的东西比较独立也可以针对线程设置 	// 但大多数时候都是用通用的设置 //	thread.setUncaughtExceptionHandler { t, e ->  //	  println("Caught $e") //	} 	thread.start() }  输出结果: Caught default: java.lang.RuntimeException: Thread error! 

示例代码分别提供了两种方式:

  • thread.setUncaughtExceptionHandler:指定线程单独设置,线程抛出异常时捕获异常

  • Thread.setDefaultUncaughtExceptionHandler:通用设置,对所有线程都捕获异常

设置 UncaughtExceptionHandler 能让线程在抛出异常时被捕获到,它主要的作用是在线程发生了意料之外的未知异常(线程内没有 try-catch 我们有意识处理的异常)时用于记录使用,而不是将异常捕获吞掉让程序运行在异常混乱的状态

UncaughtExceptionHandler 是异常处理的最后一道拦截,此时线程已经结束执行无法修复,只能收尾后杀死应用或重启

比如在 Android 抛出异常就会直接 force close,也是用的 UncaughtExceptionHandler 捕获异常记录后让应用停止运行。

说完 UncaughtExceptionHandler,现在我们说回 CoroutineExceptionHandler。

CoroutineExceptionHandler 其实和针对单个线程设置 thread.setUncaughtExceptionHandler 是一样的,没法像线程那样设置 Thread.setDefaultUncaughtExceptionHandler 对全局协程处理

因为协程是在线程之上的,所以有未知异常设置了 CoroutineExceptionHandler 就会在它这里捕获到,如果没有设置那也会被线程的 UncaughtExceptionHandler 捕获

fun main() = runBlocking { 	Thread.setDefaultUncaughtExceptionHandler { t, e ->  		println("Caught default: $e") 	} 	val scope = CoroutineScope(EmptyCoroutineContext) 	val handler = CoroutineExceptionHandler { _, e ->  		println("Caught in Coroutine: $e") 	} 	scope.launch(handler) { 		launch { 			throw RuntimeException("Error!") 		} 	} 	delay(10000) }  输出结果: // scope.launch 设置 CoroutineExceptionHandler Caught in Coroutine: java.lang.RuntimeException: Error!  // scope.launch 没设置 CoroutineExceptionhandler Caught default: java.lang.RuntimeException: Error! 

通常我们使用协程可以独立的完成一个完整的功能,在某个子协程抛出异常时能取消掉整个协程树的条件下(这在线程是很麻烦的),又能将异常汇报到一个地方(CoroutineExceptionHandler),然后做一些善后工作就很方便。

CoroutineExceptionHandler 的作用就是针对单个协程树的未知异常做善后工作,因为注册 CoroutineExceptionHandler 的目的是做善后工作,那么自然的它就得在最外层的父协程设置是最方便处理

async() 对异常的处理

启动协程有两种方式:launch 和 async。

在之前的讲解协程结构化异常都是用的 launch,不过 async 对异常的处理其实跟 launch 基本上是一致的,但还是有一些区别:

fun main() = runBlocking {     val scope = CoroutineScope(EmptyCoroutineContext)     val handler = CoroutineExceptionHandler { _, e ->         println("Caught in Coroutine: $e")     }     scope.launch(handler) {         val deferred = async {             delay(1000)             throw RuntimeException("Error!")         }         launch {             // async 抛出了异常,await() 也会抛出异常             // 进而在它调用所在的协程也会影响抛出异常             try {                 deferred.await()             } catch (e: Exception) {                 println("Caught in await: $e")             }              // 验证协程是否被取消             try {                 delay(1000)             } catch (e: Exception) {                 println("Caught in delay $e")             }         }     }     delay(10000) }  输出结果: // 先触发了 async 抛出的异常而不是 CancellationException // 因为结构化的异常流程还没走完,就先提前触发了 RuntimeException Caught in await: java.lang.RuntimeException: Error! Caught in delay kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=StandaloneCoroutine{Cancelling}@1924f17a Caught in Coroutine: java.lang.RuntimeException: Error! 

上面的例子用 async 启动了一个协程,然后在另一个协程调用 await(),当 async 抛异常时,调用 deferred.await() 的位置也会抛异常,进而让所在的协程也被取消,最终最外层协程设置的 CoroutineExceptionHandler 捕获了异常。

但看输出的日志打印,deferred.await() 并不是抛出的 CancellationException,而是在 async 抛出的 RuntimeException。按照我们对结构化异常流程的理解,为什么在 deferred.await() 的 try-catch 捕获的不是 CancellationException?

实际上 在 async 里面抛异常时是有 [双重影响] 的:它不仅会用这个异常来触发它所在的协程树的结构化异常处理流程取消协程,还会直接让它的 await() 调用也抛出这个异常

async 和 launch 的另一个异常流程的区别是,即使 async 作为最外层父协程,对 async 设置 CoroutineExceptionHandler 也是没有效果的

fun main() = runBlocking {     Thread.setDefaultUncaughtExceptionHandler { _, e ->         println("Caught default: $e")     }     val scope = CoroutineScope(EmptyCoroutineContext)     val handler = CoroutineExceptionHandler { _, e ->         println("Caught in Coroutine: $e")     }     val deferred = scope.async (handler) {         launch {             throw RuntimeException("Error!")         }     }     deferred.await()     delay(10000) }  输出结果: // 异常被抛到线程世界,没有被 CoroutineExceptionHandler 拦截 Caught default: java.lang.RuntimeException: Error!  

** async 不会往线程世界抛异常,因为 async 抛出的异常要给 await(),await() 还是运行在协程的;而 launch 会把内部的异常抛给线程世界是因为它已经是整个流程的终点了,CoroutineExceptionHandler 只能用在 launch 启动的最外层的父协程**。

SupervisorJob

在了解协程结构化异常管理机制后,我们知道子协程在抛出异常时,整个协程树都会被连带性的取消:

JobSupport.kt  public open class JobSupport constructor(active: Boolean) : Job, ChildJob, ParentJob { 	// 每个协程被取消的时候,它的父协程都会调用这个函数 	// cause:触发子协程取消的那个异常 	// cancelImpl(cause) 就是取消父协程自己 	public open fun childCancelled(cause: Throwable): Boolean { 		if (cause is CancellationException) return true // 协程取消不会取消父协程 		return cancelImpl(cause) && handlesException // 普通异常会取消父协程 	} } 

那么是否会有一种需求:子协程在抛出异常时不希望父协程也被连带性取消。为此协程也提供了 SupervisorJob。

SupervisorJob 中的 [Supervisor] 就是主管的意思,它和 Job 的区别是它重写了 childCancelled() 直接返回 false

Supervisor.kt  private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {     override fun childCancelled(cause: Throwable): Boolean = false } 

SupervisorJob 的作用是,它的子协程因为非 CancellationException 异常取消时,父协程不会连带性的被取消。简单说就是 [取消你的时候是你的父协程,你抛异常的时候不是你的父协程]

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	val supervisorJob = SupervisorJob() 	// supervisorJob 作为父协程 	scope.launch(supervisorJob) { 		throw RuntimeException("Error!") 	} 	delay(1000) 	println("Parent Job cancelled: ${supervisorJob.isCancelled}") 	delay(10000) }  输出结果: // 打印异常 Parent Job cancelled: false // 父协程没有被取消 

SupervisorJob 在实际使用场景会有两种常见方式。

第一种常见的方式是 SupervisorJob 作为子协程和父协程之间的桥梁,类似半链条的方式:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	val parentJob = scope.launch { 		// 加上 coroutineContext.job 是为了让 SupervisorJob 和外部协程是父子关系 		// 此时 SupervisorJob 是外面协程的子协程,也是里面协程的父协程 		// 这时 SupervisorJob 子协程抛出异常不会影响外部父协程被取消 		// 外部父协程取消时又能正常取消子协程,SupervisorJob 半链条的写法很常用 		launch(SupervisorJob(coroutineContext.job)) { 			throw RuntimeException("Error!") 		} 	} 	delay(1000) 	println("Parent Job cancelled: ${parentJob.isCancelled}") 	delay(10000) }  输出结果: Parent Job cancelled: false 

将 SupervisorJob 传给子协程作为它的父协程,同时为了不断开和外部协程的关系,SupervisorJob 也传入 coroutineContext.job 让外部协程取消时能正常取消子协程

第二种方式是将 SupervisorJob 提供给 CoroutineScope:

val scope = CoroutineScope(SupervisorJob()) scope.launch {} 

用这个 scope 启动的所有协程在抛异常时,都不会触发外面的 CoroutineScope 的取消;一旦外面的 CoroutineScope 取消,scope 启动的所有协程都会被取消。这也符合 SupervisorJob 的特性。

SupervisorJob 在异常管理时也会完全像一个 [最外层父协程] 一样工作:

fun main() = runBlocking { 	val scope = CoroutineScope(EmptyCoroutineContext) 	val parentJob = scope.launch { 		val handler = CoroutineExceptionHandler { _, e ->  			println("Caught in handler: $e") 		} 		// 按照结构化异常管理机制,不是最外层父协程设置 CoroutineExceptionHandler 是无效的 		// 但在这里却生效能捕获了,说明 SupervisorJob 充当了 [最外层的父协程] 		launch(SupervisorJob(coroutineContext.job) + handler) { 			launch { 				throw RuntimeException("Error!") 			} 		} 	} 	delay(1000) 	println("Parent Job cancelled: ${parentJob.isCancelled}") 	delay(10000) }  输出结果: Caught java.lang.RuntimeException: Error! Parent Job cancelled: false 

当子协程抛出异常时,SupervisorJob 充当了 [最外层的父协程] 的角色,设置 CoroutineExceptionHandler 捕获子协程异常生效了

SupervisorJob 就是一个 [会取消子协程,但不会被子协程取消] 的 Job

总结

一、协程结构化异常流程

协程的异常处理和协程的取消用的是同一套逻辑

  • 取消流程的连带取消只是向内的,即协程取消只会连带性的取消它的子协程

  • 异常流程的连带取消是双向的,不仅会向内取消它的子协程,还会向外取消它的父协程,每一个被取消的父协程序也会把它们的每个子协程取消,直到整个协程树都被取消

结构化异常流程和结构化取消流程的区别

  • 异常流程的子协程抛异常会导致父协程被取消;取消流程只会取消子协程

  • 异常流程只有内部抛异常的方式触发;取消流程可以从外部 job.cancel() 或者内部抛 CancellationException 取消

  • 异常流程抛的异常除了用来取消协程,还会把这个异常暴露给线程世界;取消流程抛 CancellationException 只用来取消

二、子协程异常为什么要连带取消父协程?

子协程一旦抛异常,影响了外部大流程的执行,让其他协程的执行失去了意义,父协程的执行也失去了意义

  • 子流程被取消,对外部大流程来说可能是一个正常的事件而已,所以子协程的取消并不会导致父协程取消

  • 子协程抛异常(非 CancellationException 的异常),这通常被理解为子协程坏掉了,导致大流程无法完成,也就相当于父协程也坏掉了。所以协程因为普通异常而被取消,它的父协程也被以这个异常为原因而取消

一句话总结:子协程的异常通常意味着父协程的损坏,所以干脆把父协程取消

三、CoroutineExceptionHandler

1、UncaughtExceptionHandler 和 CoroutineExceptionHandler 的类比

在线程分别提供了两种方式拦截异常:

  • thread.setUncaughtExceptionHandler:指定线程单独设置,线程抛出异常时捕获异常

  • Thread.setDefaultUncaughtExceptionHandler:通用设置,对所有线程都捕获异常

UncaughtExceptionHandle 的作用是在线程发生了意料之外的未知异常(线程内没有 try-catch 我们有意识处理的异常)时用于记录使用,而不是将异常捕获吞掉让程序运行在异常混乱的状态

UncaughtExceptionHandler 是异常处理的最后一道拦截,此时线程已经结束执行无法修复,只能收尾后杀死应用或重启

CoroutineExceptionHandler 和针对单个线程设置 thread.setUncaughtExceptionHandler 是一样的,没法像线程那样设置 Thread.setDefaultUncaughtExceptionHandler 对全局协程处理

对于一个多层结构的协程树,只要给它们最外层的父协程设置一个 CoroutineExceptionHandler,它里面所有子协程包括最外层父协程的异常都会集中到它这里来统一处理

2、为什么 CoroutineExceptionHandler 只能设置在最外层协程才有效?

CoroutineExceptionHandler 的作用就是针对单个协程树的未知异常做善后工作,又因为协程结构化异常会有连带性的特性(异常流程双向取消并抛异常),最终异常会走到最外层的协程,让它从协程世界将异常抛到线程世界,那么自然的它就得在最外层的父协程设置是最方便处理

四、async 的异常处理

  • 在 async 里面抛异常时是有 [双重影响] 的:它不仅会用这个异常来触发它所在的协程树的结构化异常处理流程取消协程,还会直接让它的 await() 调用也抛出这个异常

  • async 作为最外层父协程,对 async 设置 CoroutineExceptionHandler 也是没有效果的。async 不会往线程世界抛异常,因为 async 抛出的异常要给 await(),await() 还是运行在协程的;而 launch 会把内部的异常抛给线程世界是因为它已经是整个流程的终点了,CoroutineExceptionHandler 只能用在 launch 启动的最外层的父协程

五、SupervisorJob

SupervisorJob 的作用是,它的子协程因为非 CancellationException 异常取消时,父协程不会连带性的被取消。简单说就是 [取消你的时候是你的父协程,你抛异常的时候不是你的父协程]

当子协程抛出异常时,SupervisorJob 充当了 [最外层的父协程] 的角色,设置 CoroutineExceptionHandler 捕获子协程异常会生效

一句话总结:SupervisorJob 就是一个 [会取消子协程,但不会被子协程取消] 的 Job

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!