Kotlin协程理解
一、前言:
1、什么是协程?
图片.png协程可以理解就是一种用户空间线程(另外一种线程),他的调度是由程序员自己写程序来管理的,对内核来说不可见。这种线程叫做『用户空间线程』。
重要亮点:
- 协程基于线程,可以看做轻量级的线程;
- 协程让异步逻辑同步化,杜绝回调地狱;
2、线程和协程的关系
协程:
协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
图片.png
线程:
一是系统线程会占用非常多的内存空间,
二是过多的线程切换会占用大量的系统时间。
注意:协程可以在一个线程上挂起并在其它线程上恢复。 如果没有特殊工具,甚至对于一个单线程的调度器也是难以弄清楚协程在何时何地正在做什么事情。
3、协程特点:
- 协同,因为是由程序员自己写的调度策略,其通过协作而不是抢占来进行切换
- 在用户态完成创建,切换和销毁
- ⚠️ 从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
- generator经常用来实现协程
4、kotlin协程相较于线程的优势
- 在不使用回调的前提下完成来线程的切换,代码看上亲也是干净整洁很多。
- 因为线程没有上下文,不能控制线程执行完成后应该回到哪里,但是协程完全帮我们实现自动化,执行完毕自动回到上下文线程中,一般情况下是主线程,可以通过设置来决定要回到哪个线程中。
- 协程可以通过suspend关键字来标志耗时操作,通过编译器来帮助我们避免一些性能上的问题。
5、 Android平台上
- 处理耗时任务 阻塞UI主线程 -> ANR
- 保证主线程安全 确保安全的从主线程调用任何的suspend 函数
二、协程的基本使用
1、创建协程
runBlocking {
delay(2000)
Log.d("LUO","runBlocking 启动一个协程")
}
GlobalScope.launch {
delay(2000)
Log.d("LUO","launch 启动一个协程")
}
GlobalScope.async {
delay(2000)
Log.d("LUO","async 启动一个协程")
}
1、runBlocking
runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T
CoroutineContext: 协程上下文
suspend CoroutineScope.() -> T: 协程体
返回参数: T
小结: 启动一个新的协程并阻塞调用它的线程,直到里面的代码执行完毕,返回值是泛型T。
2、GlobalScope
GlobalScope.launch
CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
CoroutineScope: 协程作用域
CoroutineStart:启动模式
返回参数: Job
小结:启动一个协程但不会阻塞调用线程,必须要在协程作用域(CoroutineScope)中才能调用,返回值是一个Job
3、GlobalScope.async
CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T>
返回参数: Deferred Deferred<out T> : Job
public suspend fun await(): T
高级用法(热数据通道Channel、冷数据流Flow...)
总结:启动协程 协程作用域范围
- runBlocking{} - 主要用于测试
该方法的设计目的是让suspend风格编写的库能够在常规阻塞代码中使用,常在main方法和测试中使用。- GlobalScope.launch/async{} - 不推荐使用
由于这样启动的协程存在启动协程的组件已被销毁但协程还存在的情况,极限情况下可能导致资源耗尽,因此并不推荐这样启动,尤其是在客户端这种需要频繁创建销毁组件的场景。
二、协程作用域理解
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
1、CoroutineContext: 协程上下文
- 线程行为、生命周期、异常以及调试;
- 包含用户定义的一些数据集合,这些数据与协程密切相关;
- 它是一个有索引的 Element 实例集合,一个介于 set 和 map之间的数据结构。每个 element 在这个集合有一个唯一的 Key ;
2、Job: 控制协程的生命周期
- CoroutineDispatcher: 向合适的线程分发任务
- CoroutineName: 协程的名称,调试的时候很有用
- CoroutineExceptionHandler: 处理未被捕捉的异常
3、CoroutineContext
public interface CoroutineContext {}
//可以通过 key 来获取这个 Element。由于这是一个 get 操作符,所以可以像访问 map 中的元素一样使用 context[key] 这种中括号的形式来访问
public operator fun <E : Element> get(key: Key<E>): E?
//和 Collection.fold 扩展函数类似,提供遍历当前 context 中所有 Element 的能力
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
//和 Set.plus 扩展函数类似,返回一个新的 context 对象,新的对象里面包含了两个里面的所有 Element,如果遇到重复的(Key 一样的),
// 那么用+号右边的 Element 替代左边的。+ 运算符可以很容易的用于结合上下文,但是有一个很重要的事情需要小心 —— 要注意它们结合的次序,因为这个 + 运算符是不对称的。
public operator fun plus(context: CoroutineContext): CoroutineContext{...}
// 返回一个上下文,其中包含该上下文中的元素,但不包含具有指定key的元素。
public fun minusKey(key: Key<*>): CoroutineContext
public interface Key<E : Element>
public interface Element : CoroutineContext {...}
4、Job 负责管理协程的生命周期
*
* wait children
* +-----+ start +--------+ complete +-------------+ finish +-----------+
* | New | -----> | Active | ---------> | Completing | -------> | Completed |
* +-----+ +--------+ +-------------+ +-----------+
* | cancel / fail |
* | +----------------+
* | |
* V V
* +------------+ finish +-----------+
* | Cancelling | --------------------------------> | Cancelled |
* +------------+ +-----------+
*
1、调用该函数来启动这个 Coroutine,如果当前 Coroutine 还没有执行调用该函数返回 true,如果当前 Coroutine 已经执行或者已经执行完毕,则调用该函数返回 false。
public fun start(): Boolean
2、通过可选的取消原因取消此作业。 原因可以用于指定错误消息或提供有关取消原因的其他详细信息,以进行调试。
public fun cancel(): Unit = cancel(null)
3、通过这个函数可以给 Job 设置一个完成通知,当 Job 执行完成的时候会同步执行这个通知函数。
public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
//CompletionHandler 参数代表了 Job 是如何执行完成的。 cause 有下面三种情况:
// – 如果 Job 是正常执行完成的,则 cause 参数为 null
// – 如果 Job 是正常取消的,则 cause 参数为 CancellationException 对象。这种情况不应该当做错误处理,这是任务正常取消的情形。所以一般不需要在错误日志中记录这种情况。
// – 其他情况表示 Job 执行失败了。
// 这个函数的返回值为 DisposableHandle 对象,如果不再需要监控 Job 的完成情况了, 则可以调用 DisposableHandle.dispose 函数来取消监听。如果 Job 已经执行完了, 则无需调用 dispose 函数了,会自动取消监听
4、join 函数和前面三个函数不同,这是一个 suspend 函数;所以只能在 Coroutine 内调用。
- 这个函数会暂停当前所处的 Coroutine直到该Coroutine执行完成。所以 Job 函数一般用来在另外一个 Coroutine 中等待 job 执行完成后继续执行。
- 当 Job 执行完成后, job.join 函数恢复,这个时候 job 这个任务已经处于完成状态了,而调用 job.join 的Coroutine还继续处于 activie 状态。
- 请注意,只有在其所有子级都完成后,作业才能完成
该函数的挂起是可以被取消的,并且始终检查调用的Coroutine的Job是否取消。>- 如果在调用此挂起函数或将其挂起时,调用Coroutine的Job被取消或完成,则此函数将引发 CancellationException
public suspend fun join()
5、Deferred
public suspend fun await(): T -》 Future
用来等待这个Coroutine执行完毕并返回结果。
6、suspend关键字- 挂起点
GlobalScope.launch(Dispatchers.IO) {
}
- Dispatchers.Default
- 默认的调度器,适合处理后台计算,是一个CPU密集型任务调度器。如果创建 Coroutine 的时候没有指定 dispatcher,则一般默认使用这个作为默认值。Default dispatcher 使用一个共享的后台线程池来运行里面的任务。注意它和IO共享线程池,只不过限制了最大并发数不同。
- Dispatchers.IO
- 顾名思义这是用来执行阻塞 IO 操作的,是和Default共用一个共享的线程池来执行里面的任务。根据同时运行的任务数量,在需要的时候会创建额外的线程,当任务执行完毕后会释放不需要的线程。
- Dispatchers.Unconfined
- 由于Dispatchers.Unconfined未定义线程池,所以执行的时候默认在启动线程。遇到第一个挂起点,之后由调用resume的线程决定恢复协程的线程。
- Dispatchers.Main:
- 指定执行的线程是主线程,在Android上就是UI线程·
4、协程启动模式
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {}
注意:由于子Coroutine 会继承父Coroutine 的 context,所以为了方便使用,我们一般会在 父Coroutine 上设定一个 Dispatcher,然后所有 子Coroutine 自动使用这个 Dispatcher。
1、CoroutineStart:
- CoroutineStart.DEFAULT:
- 协程创建后立即开始调度,在调度前如果协程被取消,其将直接进入取消响应的状态
- 虽然是立即调度,但也有可能在执行前被取消
- CoroutineStart.ATOMIC:
- 协程创建后立即开始调度,协程执行到第一个挂起点之前不响应取消
- 虽然是立即调度,但其将调度和执行两个步骤合二为一了,就像它的名字一样,其保证调度和执行是原子操作,因此协程也一定会执行。
-CoroutineStart.LAZY:
-只要协程被需要时,包括主动调用该协程的start、join或者await等函数时才会开始调度,如果调度前就被取消,协程将直接进入异常结束状态
-CoroutineStart.UNDISPATCHED:
-协程创建后立即在当前函数调用栈中执行,直到遇到第一个真正挂起的点
-是立即执行,因此协程一定会执行
5、CoroutineScope: 协程作用域
//示例1
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
//示例2
public object GlobalScope : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}
//示例3
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
- CoroutineScope 只是定义了一个新 Coroutine 的执行 Scope。每个 coroutine builder 都是 CoroutineScope 的扩展函数,并且自动的继承了当前 Scope 的
coroutineContext分类及行为规则
官方框架在实现复合协程的过程中也提供了作用域,主要用以明确写成之间的父子关系,以及对于取消或者异常处理等方面的传播行为。该作用域包括以下三种:
- 顶级作用域
没有父协程的协程所在的作用域为顶级作用域。- 协同作用域
协程中启动新的协程,新协程为所在协程的子协程,这种情况下,子协程所在的作用域默认为协同作用域。此时子协程抛出的未捕获异常,都将传递给父协程处理,父协程同时也会被取消。
解析:coroutineScope 内部的异常会向上传播,子协程未捕获的异常会向上传递给父协程,任何一个子协程异常退出,会导致整体的退出。
public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return suspendCoroutineUninterceptedOrReturn { uCont ->
val coroutine = ScopeCoroutine(uCont.context, uCont)
coroutine.startUndispatchedOrReturn(coroutine, block)
}
}
- 主从作用域
与协同作用域在协程的父子关系上一致,区别在于,处于该作用域下的协程出现未捕获的异常时,不会将异常向上传递给父协程。
解析:supervisorScope属于主从作用域,会继承父协程的上下文,它的特点就是子协程的异常不会影响父协程。
public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return suspendCoroutineUninterceptedOrReturn { uCont ->
val coroutine = SupervisorCoroutine(uCont.context, uCont)
coroutine.startUndispatchedOrReturn(coroutine, block)
}
}
三、在Android中怎么用?
1、协程上下文与调度器
- 协程总是运行在一些以 CoroutineContext 类型为代表的上下文中,它们被定义在了 Kotlin 的标准库里。
- 协程上下文是各种不同元素的集合。其中主元素是协程中的 Job。
2、调度器与线程
- 协程上下文包含一个 协程调度器 (参见 CoroutineDispatcher)它确定了相关的协程在哪个线程或哪些线程上执行。协程调度器可以将协程限制在一个特定的线程执行,或将它分派到一个线程池,亦或是让它不受限地运行。
3、调度器
所有的协程构建器诸如 launch 和 async 接收一个可选的 CoroutineContext 参数,它可以被用来显式的为一个新协程或其它上下文元素指定一个调度器。
launch { // 运行在父协程的上下文中,即 runBlocking 主协程
println("main runBlocking : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // 不受限的——将工作在主线程中
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // 将会获取默认调度器
println("Default : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // 将使它获得一个新的线程
println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}
//结果:
Unconfined : I'm working in thread main
Default : I'm working in thread DefaultDispatcher-worker-1
newSingleThreadContext: I'm working in thread MyOwnThread
main runBlocking : I'm working in thread main
当调用 launch { …… }
时不传参数,它从启动了它的 CoroutineScope 中承袭了上下文(以及调度器)。在这个案例中,它从 main
线程中的 runBlocking
主协程承袭了上下文。
Dispatchers.Unconfined 是一个特殊的调度器且似乎也运行在 main
线程中,但实际上, 它是一种不同的机制,这会在后文中讲到。
当协程在 GlobalScope 中启动时,使用的是由 Dispatchers.Default 代表的默认调度器。 默认调度器使用共享的后台线程池。 所以 launch(Dispatchers.Default) { …… }
与 GlobalScope.launch { …… }
使用相同的调度器。
newSingleThreadContext 为协程的运行启动了一个线程。 一个专用的线程是一种非常昂贵的资源。 在真实的应用程序中两者都必须被释放,当不再需要的时候,使用 close 函数,或存储在一个顶层变量中使它在整个应用程序中被重用。
4、子协程
当一个协程被其它协程在 CoroutineScope 中启动的时候, 它将通过 CoroutineScope.coroutineContext 来承袭上下文,并且这个新协程的 Job 将会成为父协程作业的 子 作业。当一个父协程被取消的时候,所有它的子协程也会被递归的取消。
然而,当使用 GlobalScope 来启动一个协程时,则新协程的作业没有父作业。 因此它与这个启动的作用域无关且独立运作。
// 启动一个协程来处理某种传入请求(request)
val request = launch {
// 孵化了两个子作业, 其中一个通过 GlobalScope 启动
GlobalScope.launch {
println("job1: I run in GlobalScope and execute independently!")
delay(1000)
println("job1: I am not affected by cancellation of the request")
}
// 另一个则承袭了父协程的上下文
launch {
delay(100)
println("job2: I am a child of the request coroutine")
delay(1000)
println("job2: I will not execute this line if my parent request is cancelled")
}
}
delay(500)
request.cancel() // 取消请求(request)的执行
delay(1000) // 延迟一秒钟来看看发生了什么
println("main: Who has survived request cancellation?")
结果:
job1: I run in GlobalScope and execute independently!
job2: I am a child of the request coroutine
job1: I am not affected by cancellation of the request
main: Who has survived request cancellation?
5、父协程的职责
一个父协程总是等待所有的子协程执行结束。父协程并不显式的跟踪所有子协程的启动,并且不必使用 Job.join 在最后的时候等待它们:
// 启动一个协程来处理某种传入请求(request)
val request = launch {
repeat(3) { i -> // 启动少量的子作业
launch {
delay((i + 1) * 200L) // 延迟 200 毫秒、400 毫秒、600 毫秒的时间
println("Coroutine $i is done")
}
}
println("request: I'm done and I don't explicitly join my children that are still active")
}
request.join() // 等待请求的完成,包括其所有子协程
println("Now processing of the request is complete")
结果:
request: I'm done and I don't explicitly join my children that are still active
Coroutine 0 is done
Coroutine 1 is done
Coroutine 2 is done
Now processing of the request is complete
6、 组合上下文中的元素
有时我们需要在协程上下文中定义多个元素。我们可以使用 +
操作符来实现。 比如说,我们可以显式指定一个调度器来启动协程并且同时显式指定一个命名:
launch(Dispatchers.Default + CoroutineName("test")) {
println("I'm working in thread ${Thread.currentThread().name}")
}
结果:
I'm working in thread DefaultDispatcher-worker-1 @test#2
7、协程作用域
让我们将关于上下文,子协程以及作业的知识综合在一起。假设我们的应用程序拥有一个具有生命周期的对象,但这个对象并不是一个协程。举例来说,我们编写了一个 Android 应用程序并在 Android 的 activity 上下文中启动了一组协程来使用异步操作拉取并更新数据以及执行动画等等。所有这些协程必须在这个 activity 销毁的时候取消以避免内存泄漏。当然,我们也可以手动操作上下文与作业,以结合 activity 的生命周期与它的协程,但是 kotlinx.coroutines
提供了一个封装:CoroutineScope 的抽象。 你应该已经熟悉了协程作用域,因为所有的协程构建器都声明为在它之上的扩展。
我们通过创建一个 CoroutineScope 实例来管理协程的生命周期,并使它与 activity 的生命周期相关联。CoroutineScope
可以通过 CoroutineScope() 创建或者通过MainScope() 工厂函数。前者创建了一个通用作用域,而后者为使用 Dispatchers.Main 作为默认调度器的 UI 应用程序 创建作用域:
class Activity {
//协程立即结束
val mainScope = MainScope()
//协程运行一会结束; 我们使用 Dispatchers.Default 在后台线程池中启动了一个新的协程,所以它工作在线程池中的不同线程中
// val mainScope = CoroutineScope(Dispatchers.Default)
//主从协程立即结束
// val mainScope = CoroutineScope(Dispatchers.Main)
//协程立即结束
// val mainScope = CoroutineScope(Dispatchers.Main + Job())
//协程立即结束
val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun destroy() {
mainScope.cancel()
}
// 继续运行……
}
注意:CoroutineScope(Dispatchers.Default)是你自己定义了一个协程,上下文是Dispatchers.Default 此时你外面如果有其他的协程比如runBlocking,它们是独立的。
完整例子
class TwoActivity :AppCompatActivity() {
//协程立即结束
// var mainScope = MainScope()
//协程运行一会结束; 我们使用 Dispatchers.Default 在后台线程池中启动了一个新的协程,所以它工作在线程池中的不同线程中
var mainScope = CoroutineScope(Dispatchers.Default)
//主从协程立即结束
// var mainScope = CoroutineScope(Dispatchers.Main)
//协程立即结束
// val mainScope = CoroutineScope(Dispatchers.Main + Job())
//协程立即结束
val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_two)
//点击事件
btn2.setOnClickListener {
runBlocking {
doSomething() // 运行测试函数
Log.d("LUO","Launched coroutines")
delay(500L) // 延迟半秒钟
Log.d("LUO","Destroying activity!")
finish() // 取消所有的协程
delay(1000) // 为了在视觉上确认它们没有工作
}
}
}
//执行函数
fun doSomething() {
// 在示例中启动了 20 个协程,且每个都工作了不同的时长
repeat(20) { i ->
mainScope.launch {
delay((i + 1) * 200L) // 延迟 200 毫秒、400 毫秒、600 毫秒等等不同的时间
Log.d("LUO","Coroutine $i is done")
}
}
}
//销毁
override fun onDestroy() {
super.onDestroy()
mainScope.cancel()
Log.d("LUO","Coroutine=====cancel=====")
}
}
8、阻塞式协程和非阻塞式协程
1、阻塞式协程:
-runBlocking {} 是阻塞式协程
private fun run1() {
Log.d("LUO","=======1111====="+DateTimeHelper.format(Date(),"yyyy-MM-dd HH:mm:ss"))
runBlocking {
delay(2000L) // 延迟半秒钟
Log.d("LUO","=====2222====="+DateTimeHelper.format(Date(),"yyyy-MM-dd HH:mm:ss"))
}
Log.d("LUO","=====3333====="+DateTimeHelper.format(Date(),"yyyy-MM-dd HH:mm:ss"))
}
结果:
可以看到3333的日志时间和1111日志时间不一样, 在协程延迟2秒后执行,也就是阻塞了协程。
LUO: =======1111=====2021-09-01 17:24:01
LUO: =====2222=====2021-09-01 17:24:03
LUO: =====3333=====2021-09-01 17:24:03
2、非阻塞式协程:
-注意:下面所有的协程作用域都是非阻塞式的
//1、协程立即结束
var mainScope = MainScope()
//2、协程运行一会结束; 我们使用 Dispatchers.Default 在后台线程池中启动了一个新的协程,所以它工作在线程池中的不同线程中
var mainScope = CoroutineScope(Dispatchers.Default)
//3、协程立即结束
var mainScope = CoroutineScope(Dispatchers.Main)
//4、协程立即结束
val mainScope = CoroutineScope(Dispatchers.Main + Job())
//5、协程立即结束
val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
class TwoActivity :AppCompatActivity() {
//协程立即结束
var mainScope = MainScope()
//协程运行一会结束; 我们使用 Dispatchers.Default 在后台线程池中启动了一个新的协程,所以它工作在线程池中的不同线程中
// var mainScope = CoroutineScope(Dispatchers.Default)
//主从协程立即结束
// var mainScope = CoroutineScope(Dispatchers.Main)
//协程立即结束
// val mainScope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_two)
//点击事件
btn2.setOnClickListener {
run2()
}
}
private fun run2() {
Log.d("LUO","=======1111====="+DateTimeHelper.format(Date(),"yyyy-MM-dd HH:mm:ss"))
mainScope.launch {
delay(2000L) // 延迟半秒钟
Log.d("LUO","=====2222====="+DateTimeHelper.format(Date(),"yyyy-MM-dd HH:mm:ss"))
}
Log.d("LUO","=====3333====="+DateTimeHelper.format(Date(),"yyyy-MM-dd HH:mm:ss"))
}
//销毁
override fun onDestroy() {
super.onDestroy()
mainScope.cancel()
}
}
结果:
可以看到3333的日志时间 和日志1111时间一样,没有延迟,也就是没有阻塞协程。
LUO: =======1111=====2021-09-01 17:56:25
LUO: =====3333=====2021-09-01 17:56:25
LUO: =====2222=====2021-09-01 17:56:27
9、SupervisorJob()和Job()区别
- Job是协程的默认context
- 子协程不会继承父协程的context,会使用默认值
- 协程的上下文为SupervisorJob时,该协程中的异常不会向外传播,因此不会影响其父亲/兄弟协程,也不会被其兄弟协程抛出的异常影响
正确使用SupervisorJob的方法
// job1、job2、job3和job4的上下文都是SupervisorJob
val scope = CoroutineScope(SupervisorJob())
job1 = scope.launch {...}
job2 = scope.launch {...}
supervisorScope {
job3 = launch {...}
job4 = launch {...}
}
- launch(SupervisorJob()){...}只会使该协程的上下文变为SupervisorJob,其大括号内部的上下文依然是Job。