深入理解Kotlin协程协程作用域启动模式调度器异常和取消使用篇

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解Kotlin协程协程作用域启动模式调度器异常和取消使用篇相关的知识,希望对你有一定的参考价值。

startCoroutine和createCoroutine这两个API不太适合在业务开发中直接使用,因此对于协程的创建,框架中提供了不同目的的协程构造器。 这两组 API 的差异在千 Receiver 的有无。Receiver 通常用千约束和扩展协程体,剩下的部分就是作为协程体的 suspend 函数和作为协程完成后回调的 completion。             我们对协程的这两组 API 做进一步的封装,目的就是降低协程的创建和管理的成本。而降低协程的创建成本无非就是提供一个函数来简化操作,就像 async 函数那样;而要降低管理的成本,就必须引入一个新的类型来描述协程本身,并且提供相应的 API 来控制协程的执行。              无返回值的 launch               如果一个协程的返回值 Unit,  那么我们可以称它“无返回值 ”( 或者返回值为“空”类型)。对于这样的协程,我们只需要启动它即可。

 

其中 StandaloneCoroutine 是 AstractCoroutine 的子类,目前只有一个空实现,如代码清单 5-14 所示

 CoroutineScope - 协程作用域

官方框架在实现复合协程的过程中也提供了作用域,主要用以明确协程之间的父子关系,以及对于取消或者异常处理等方面的传播行为。
public interface CoroutineScope 
    public val coroutineContext: CoroutineContext
该作用域包括以下三种:
  • 顶级作用域 没有父协程的协程所在的作用域为顶级作用域。
  • 协同作用域 协程中启动新的协程,新协程为所在协程的子协程,这种情况下,子协程所在的作用域默认为协同作用域。此时子协程抛出的未捕获异常,都将传递给父协程处理,父协程同时也会被取消。
  • 主从作用域 与协同作用域在协程的父子关系上一致,区别在于,处于该作用域下的协程出现未捕获的异常时,不会将异常向上传递给父协程
除了三种作用域中提到的行为以外,父子协程之间还存在以下规则:
  • 父协程被取消,则所有子协程均被取消。由于协同作用域和主从作用域中都存在父子协程关系,因此此条规则都适用。
  • 父协程需要等待子协程执行完毕之后才会最终进入完成状态,不管父协程自身的协程体是否已经执行完。
  • 子协程会继承父协程的协程上下文中的元素如果自身有相同key的成员,则覆盖对应的key,覆盖的效果仅限自身范围内有效。
简单总结就是,主从关系:无法坑爹,爹可以坑儿子。协同关系:可以坑爹,可以坑儿子,互相坑。 通过 GlobalScope 创建的协程将不会有父协程,我们也可以把它称作根协程,协程的协程体的 Receiver 就是作用域实例,因此可以在它的协程体内部再创建新的协程,最终产生一个协程树(如图 5-11 所示 )。 如代码清单 5-68 所示

 

 当然,如果在协程内部再次使用 GlobalScope 建协程 ,那么新协程仍然是根协程,如代码清单 5-69 所示

使用协程作用域来创建协程

当我们创建一个协程的时候,都会需要一个  CoroutineScope 我们一般使用它的  launch 或  async 函数去进行协程的创建。 CoroutineScope 会跟踪它使用  launch 或  async 创建的所有协程。您可以随时调用  scope.cancel() 以取消正在运行的协程。不过,与调度程序不同,CoroutineScope 不运行协程,它只是确保您不会失去对协程的追踪。为了确保所有的协程都会被追踪,Kotlin 不允许在没有使用 CoroutineScope 的情况下启动新的协程。             CoroutineScope 可被看作是一个具有超能力的轻量级版本的ExecutorService。CoroutineScope 会跟踪所有协程,同样它还可以取消由它所启动的所有协程。这在 android 开发中非常有用,比如它能够在用户离开界面时停止执行协程。                在 Android 中,某些 KTX 库为某些生命周期类提供自己的 CoroutineScope。主要有以下4种: 
  • GlobeScope:全局范围,不会自动结束执行。
  • MainScope:主线程的作用域,全局范围
  • lifecycleScope:生命周期范围,用于activity等有生命周期的组件,在Desroyed的时候会自动结束。
  • viewModeScope:ViewModel范围,用于ViewModel中,在ViewModel被回收时会自动结束
 所有的Scope都是 CoroutineScope 的子类。以上4种可以认为是最顶级的协程作用域,能在Activity、Fragment、ViewModel等类的 普通函数直接调用,其中只有 lifecycleScopeviewModelScope具备页面销毁状态感知自动取消协程的功能,而另外两种则没有具备这种感知功能。 

如何使用 coroutineScope 启动协程

  • 调用  xxxScope.launch...  启动一个协程块, launch方法启动的协程不会将结果返回给调用方。任何被视为“一劳永逸”的工作都可以使用 launch来启动。
  • 在  xxxScope ... 中调用  async... 创建一个子协程, async会返回一个 Deferred对象,随后可以调用 Deferred对象的 await()方法来启动该协程。
  • withContext() 一个 suspend方法,在给定的上下文中执行并返回结果,它的目的不在于启动子协程,主要用于 线程切换,将长耗时操作从UI线程切走,完事再切回来。用它执行的挂起块中的上下文是当前协程的上下文和由它执行的上下文的合并结果。 
  • coroutineScope 一个 suspend方法,创建一个新的作用域,并在该作用域内执行指定代码块,它并不启动协程。其存在的目的是进行符合结构化并发的并行分解。
  • runBlocking 创建一个协程,并阻塞当前线程,直到协程执行完毕。 
通常,应该在 普通函数中使用  Scope. launch,而在 协程块内挂起函数内使用 async,因为常规函数中无法调用 await()。 launch其实是 CoroutineScope的一个扩展方法:
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job 
    ...省略
所以,原则上 只要是在协程作用域范围内的任意地方都可以调用launch方法: 如果不知道当前代码是否处在一个协程作用域内,AS编译器也会有所提示。

coroutineScope & supervisorScope

这两个就是2个挂起函数,分别表示协同作用域和主从作用域,因为是挂起函数所以也必须在协程块或挂起函数内调用:
private fun request() 
    lifecycleScope.launch 
        coroutineScope  // 协同作用域,抛出未捕获异常时会取消父协程
            launch  
        
        supervisorScope  // 主从作用域,抛出未捕获异常时不会取消父协程
            launch  
        
    

注意这两个函数的作用只是定义了2个作用域而已,如果想要启动新的子协程请在里面调用launch。如果需要异步请使用async。

二者的区别:
  • supervisorScope 表示 主从作用域,会继承父协程的上下文,它的特点就是子协程的异常不会影响父协程,内部的 子协程挂掉 不会影响外部的父协程和兄弟协程的继续运行,它就像一道防火墙,隔离了异常,保证程序健壮,但是如果外部协程挂掉还是可以取消子协程的,即 单向传播。它的设计应用场景多用 于子协程为独立对等的任务实体的时候,比如一个下载器,每一个子协程都是一个下载任务,当一个下载任务异常时,它不应该影响其他的下载任务。
  • coroutineScope  表示 协同作用域,  内部的协程 出现异常 会向外部传播,子协程未捕获的异常会向上传递给父协程,  子协程 可以挂掉外部协程 外部协程挂掉也会挂掉子协程,即 双向传播 。 任何一个子协程异常退出,会导致整体的退出。

还可以进行一些简单的封装,比如我们可以定义一个 suspend 方法,内部返回一个 coroutineScope 作用域对象来执行一个传入的协程代码块: 

private suspend fun saveLocal(coroutineBlock: (suspend CoroutineScope.() -> String)? = null): String? 
        return coroutineScope 
           // 以下几种写法等价,都是执行block代码块
           // coroutineBlock!!.invoke(this)
           // coroutineBlock?.invoke(this)
           // if (coroutineBlock != null) 
           //     coroutineBlock.invoke(this)
           // 
            coroutineBlock?.let  block ->
                block()
            
        
    
 那么在使用我们这一个函数的时候就可以这么使用:
MainScope().launch     
    println("执行在一个协程中...")
    val result = saveLocal 
        async(Dispatchers.IO) 
            "123456"
        .await()
     
    println("一个协程执行完毕... result:$result")

并行分解

并行分解就是将长耗时任务拆分为并发的多个短耗时任务,并等待所有并发任务完成后再返回。      借助 Kotlin 中的 结构化并发机制,我们可以定义用于启动一个或多个协程的  coroutineScope。然后,您可以使用  await()(针对单个协程)或  awaitAll()(针对多个协程)保证这些协程在从函数返回结果之前完成。            await()调用会等待 async...中的代码块(包括挂起函数)执行完毕后,得到返回结果,再继续往下运行,它的执行流程如下:

 例如,假设我们定义一个用于异步获取两个文档的 coroutineScope。通过对每个延迟引用调用 await(),我们可以保证这两项 async 操作在返回值之前完成:

suspend fun fetchTwoDocs() = coroutineScope 
        val deferredOne = async  fetchDoc(1) 
        val deferredTwo = async  fetchDoc(2) 
        deferredOne.await()
        deferredTwo.await()
这里需要注意的一点是, 两个async块中的代码是并发执行的(默认是调度在线程池上执行),并且跟是否调用 await没有直接关系,上面代码中即使将await都注释掉,两个 async块仍然是并发执行的,而 coroutineScope会等待两个async完毕返回才结束。只不过调用await能保证 async一定执行在await之前。 如下图中,红色框之内的是并发的,它们的顺序是无法保证按照代码顺序的,但是红色框一定执行在蓝色框之前。  

 假如像上面这样直接使用coroutineScope,那么async执行完成,coroutineScope中排在async之后的代码有可能被调度到某个子线程中执行,即上面的红色部分执行完后,蓝色部分可能运行在某个子线程中。如下图:

所以在Android中,最好是在lifecycleScopeviewModelScope中去使用async, 这样能保证async之后的代码仍然执行在主线程上。但是此时在lifecycleScopeviewModelScope中调用的async中的代码也会执行在主线程(虽然是异步的,但既然是主线程就会有IO太长阻塞主线程的风险),也就是说async默认跟父协程的调度器是一样的,因此,如果有需要,此时可以为async指定线程调度器。如下:

除了单独调用每个await方法,还可以对集合使用 awaitAll(),如以下示例所示:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope 
        val deferreds = listOf(     // fetch two docs at the same time
            async  fetchDoc(1) ,  // async returns a result for the first doc
            async  fetchDoc(2)    // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    

虽然 fetchTwoDocs() 使用 async 启动新协程,但该函数使用 awaitAll() 等待启动的协程完成后才会返回结果。 

此外,coroutineScope 会捕获协程抛出的所有异常,并将其传送回调用方。

MainScope & GlobalScope

都是全局的作用域,但是他们有区别。如果不做处理他们都是运行在全局无法取消的,但是 GlobalScope是无法取消的,MainScope是可以取消的。      GlobalScope 的源码如下:
public object GlobalScope : CoroutineScope 
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
可以看到它的上下文对象是  EmptyCoroutineContext 对象,并 没有Job对象,所以我们 无法通过 Job 对象去 cancel 此协程。所以他是无法取消的进程级别的协程。除非有特殊的需求,我们都不推荐使用此协程。     MianScope 的源码如下:
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
可以看到它的上下文对象是  SupervisorJob + 主线程调度器构成的。所以我们说它是一个可以取消的全局主线程协程。MianScope是一个全局函数,意味着你可以任何地方调用它(包括Activity、Fragment、Dialog、ViewModel等),但是需要注意在页面销毁的时候记得取消它。(因此相比在能使用lifecycleScope或viewModelScope的地方,使用它并不方便) 如何取消 MianScope
var mainScope= MainScope()

mainScope.launch 
    println("执行在一个协程中...")
    val result = saveLocal 
            async(Dispatchers.IO) 
                "123456"
            .await()
        
    println("一个协程执行完毕... result:$result")

...
override fun onDestroy() 
    super.onDestroy()
     mainScope.cancel()

ViewModelScope

viewModelScope是一个 CloseableCoroutineScope,它的上下文由 SupervisorJob() + Dispatchers.Main.immediate构成。源码如下:
private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"

val ViewModel.viewModelScope: CoroutineScope
        get() 
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) 
                return scope
            
            return setTagIfAbsent(JOB_KEY, CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope 
    override val coroutineContext: CoroutineContext = context

    override fun close() 
        coroutineContext.cancel()
    
可以看到 viewModelScopeViewModel类的 扩展属性,假如这个  ViewModel 是 Activity 的,那么在 Activity 退出的时候,ViewModel 的  clear() 方法就会被调用,而  clear() 方法中会扫描当前 ViewModel 的成员  mBagsOfTags(一个Map对象)中保存的所有的  Closeable 的  object 对象(也就是上面的 CloseableCoroutineScope),并调用其  close() 方法。(大概流程是在 ComponentActivity中会通过 Lifecycle注册 观察者接口,当页面销毁时,该回调接口中会调用 ViewModelStore.clear() -> ViewModel.clear() -> (Closeable)obj.close())  

lifecycleScope

lifecycleScope的实例是 LifecycleCoroutineScopeImpl,它的上下文也是由 SupervisorJob() + Dispatchers.Main.immediate构成。
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
    get() = lifecycle.coroutineScope

public val Lifecycle.coroutineScope: LifecycleCoroutineScope
    get() 
        while (true) 
            val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
            if (existing != null) 
                return existing
            
            val newScope = LifecycleCoroutineScopeImpl(
                this,
                SupervisorJob() + Dispatchers.Main.immediate
            )
            if (mInternalScopeRef.compareAndSet(null, newScope)) 
                newScope.register()
                return newScope
            
        
    
lifecycleScope是LifecycleOwner的扩展属性,因此它只能在Activity、Fragment中使用,会绑定Activity和Fragment的生命周期。 它的基本使用和 viewModelScope 是一样的。但是它多了生命周期的的一些感知。 比如提供在Activity的onCreate/onStart/onResumed时候才执行的方法:
public fun launchWhenCreated(block: suspend CoroutineScope.() -> Unit): Job = launch 
    lifecycle.whenCreated(block)

public fun launchWhenStarted(block: suspend CoroutineScope.() -> Unit): Job = launch 
    lifecycle.whenStarted(block)

public fun launchWhenResumed(block: suspend CoroutineScope.() -> Unit): Job = launch  
    lifecycle.whenResumed(block)
它也是通过  LifecycleController 中为 Lifecycle注册 观察者接口, 来感知 onResume的状态,然后进行调用的。

自定义CoroutineScope

除了这些自带的Scope以外,如果您需要创建自己的 CoroutineScope 以控制协程在应用的特定层中的生命周期,则可以创建一个如下所示的 CoroutineScope:
class ExampleClass 

    // Job and Dispatcher are combined into a CoroutineContext which will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() 
        // Starts a new coroutine within the scope
        scope.launch 
            fetchDocs()  // New coroutine that can call suspend functions
        
    

    fun cleanUp() 
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    
注意: 已取消的作用域无法再创建协程。因此,仅当控制其生命周期的类被销毁时,才应调用  scope.cancel()。使用  viewModelScope 时, ViewModel 类会在 ViewModel 的  onCleared() 方法中自动为您取消作用域。        还可以通过继承  CoroutineScope 在一些自定义 Dialog, PopupWindow场景的时候使用:
class CancelJobDialog() : DialogFragment(), CoroutineScope by MainScope() 

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? 
        dialog?.requestWindowFeature(Window.FEATURE_NO_TITLE)
        dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
        return inflater.inflate(R.layout.dialog_cancel_job, container, false)
    

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) 
        super.onViewCreated(view, savedInstanceState)
        val mNoTv = view.findViewById<TextView>(R.id.btn_n)
        val mYesTv = view.findViewById<TextView>(R.id.btn_p)
        mNoTv.click  dismiss() 
        mYesTv.click  doSth() 
    

    private fun doSth() 
       launch
           println("执行在另一个协程中...")
           delay(1000L)
           println("另一个协程执行完毕...")
        
        dismiss()
   

    override fun onDismiss(dialog: DialogInterface) 
        cancel()
        super.onDismiss(dialog)
    
这里我们让Dialog实现一个  CoroutineScope 作用域接口,然后使用委托属性  by 将 MainScope 的实现给它。这样这个 Dialog 就是一个 MainScope的作用域了。在内部就能 launch N多个子协程了。当我们在  onDismiss 的时候,会把 MainScope取消掉,按照协程作用域的原则, 父协程被取消,则所有子协程均被取消,那么它内部 launch 的N个子协程就能一起取消了。       再比如我想封装一个带协程的PopupWindow,可以这样封装一个基类:
/**
* 自定义带协程作用域的弹窗
*/
abstract class CoroutineScopeCenterPopup(activity: FragmentActivity) : CenterPopupView(activity), CoroutineScope 

    private lateinit var job: Job

    private val exceptionHandler = CoroutineExceptionHandler  coroutineContext, throwable ->
        println(throwable.message ?: "Unkown Error")
    

    // 此协程作用域的自定义 CoroutineContext
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job + CoroutineName("CenterPopupScope") + exceptionHandler

    override fun onCreate() 
        job = Job()
        super.onCreate()
    

    override fun onDismiss() 
        job.cancel()  // 关闭弹窗后,结束所有协程任务
        println("关闭弹窗后,结束所有协程任务")
        super.onDismiss()
    
那么就可以直接使用这个基类的实现了:
class InterviewAcceptPopup(private val mActivity: FragmentActivity) : CoroutineScopeCenterPopup(mActivity) 

    override fun getImplLayoutId(): Int 
        return R.layout.dialog_interview_accept
    

    override fun onCreate() 
        super.onCreate()
        val btnYes = findViewById<TextView>(R.id.btn_y)
        btnYes.click 
            doSth()
        
    

    private fun doSth() 
        launch 
            println("执行在协程中...")
            delay(1000L)
            println("执行完毕...")
            dismiss()
        
    
 使用Job控制协程的生命周期实际开发中如果是涉及到 Android 页面的一些生命周期的,我们可以使用viewModelScope、lifecycleScope 。如果是其他的页面比如 View 或者 Dialog 或者干脆不涉及到页面的一些地方,我们就可以使用以上的几种方法来实现自定义的协程作用域。

runBlocking

runBlocking是一个顶层函数,注意它不是一个挂起函数,但是它会运行一个新的协程,并且会阻塞当前调用runBlocking的线程,直到 runBlocking 中的协程体完成。不应该在协程中使用此函数。它的设计目的是将常规阻塞代码与以挂起风格编写的库连接起来,以便在主函数和测试中使用。
fun main() 
    runBlocking 
        println("coroutine1 start")
        delay(1000) //模拟耗时
        println("coroutine1 end")
    
    runBlocking 
        println("coroutine2 start")
        delay(2000) //模拟耗时
        println("coroutine2 end")
    
    println("process end")
上面代码执行结果会按顺序输出: 上面代码如果改成使用GlobalScope.launch就不会按顺序输出:
suspend fun main() 
    GlobalScope.launch 
        println("coroutine1 start")
        delay(1000) //模拟耗时
        println("coroutine1 end")
    
    GlobalScope.launch 
        println("coroutine2 start")
        delay(2000) //模拟耗时
        println("coroutine2 end")
    
    println("process end")

在suspend的main函数中执行结果如下:

如果在不带suspend的main函数中执行,则只会输出process end。因此使用runBlocking的效果就是按顺序阻塞式的调用,后面的协程体会等runBlocking执行完。runBlocking更适合用在测试中,比如希望main函数等待它当中的协程能够完成里面的功能后再退出。不过,类似的功能也可以使用 Job.join()或者 Deferred.await()来实现。

CoroutineStart - 协程启动模式

 
  • CoroutineStart.DEFAULT: 协程创建后,立即开始调度,但 有可能在执行前被取消。在调度前如果协程被取消,其将直接进入取消响应的状态。 
  • CoroutineStart.ATOMIC 协程创建后,立即开始调度协程执行到第一个挂起点之前不响应取消。其将调度和执行两个步骤合二为一,就像它的名字一样,其保证调度和执行是原子操作,因此协程也 一定会执行
  • CoroutineStart.LAZY 只要协程被需要时(主动调用该协程的 startjoinawait等函数时 才会开始调度,如果调度前就被取消,协程将直接进入异常结束状态。
  • CoroutineStart.UNDISPATCHED 协程创建后,立即在当前线程中执行,直到遇到第一个真正挂起的点。是立即执行,因此协程 一定会执行
 注意: UNDISPATCHED是立即在 当前线程执行,而  DEFAULT和 ATOMIC则不一定(取决于配置的调度器)。 UNDISPATCHED 如果遇到挂起点,就切回主流程了,后面的协程体继续执行在单独的调度器。      要彻底搞清楚这几个模式的效果 ,我们需要先搞清楚  立即调度 和 立即执行 的区别 。立即调度表示协程的调度器会立即接收调度指令,但具体执行的时机以及在哪个线程上执行,还需要根据调度器的具体情况而定,也就是说立即调度到立即执行之间通常会有一段时间。  

这些启动模式的设计主要是为了应对某些特殊的场景,业务开发实践中通常使用  DEFAULT 和  LAZY 这两个启动模式就足够了。

协程调度器

对于调度器的实现机制我们已经非常清楚了(拦截器),官方框架中预置了 4 个调度器,我们可以通过  Dispatchers 对象访问它们。
  • Default: 默认调度器 ,适合处理后台计算,其是一个  CPU 密集型任务调度器
  • IO: IO 调度器,适合执行 IO 相关操作,其是  IO 密集型任务调度器
  • Main: UI 调度器,根据平台不同会被初始化为对应的 UI 线程的调度器, 例如在Android 平台上它会将协程调度到 UI 事件循环中执行,即通常在 主线程上执行。
  • Unconfined:“无所谓“调度器,不要求协程执行在特定线程上。协程的调度器如果是 Unconfined, 那么它在挂起点恢复执行时会在恢复所在的线程上直接执行,当然, 如果嵌套创建以它为调度器的协程,那么这些协程会在启动时被调度到协程框架内部的事件循环上,以避免出现 StackOverflow。
如果创建 Coroutine的时候未指定调度器,或者使用未指定的调度器的上下文的 Scope通过 launchasync启动一个协程,则默认是使用 Dispatchers.Default调度器     由于 子协程会默认继承 父协程context上下文,所以一般我们可以直接为 父协程context上设置一个 Dispatcher,这样所有的子协程就自动使用这个 Dispatcher,当某个子协程有特殊需要的时候再其指定特定的 Dispatcher。      如果当前协程会访问 UI 资源,那么使用 Main 为确保对 UI 读写的并发安全性,我们需要确保相关协程 UI 线程上执行 ,因此需要指定调度器为 Main。        如果是只包含单纯的计算任务的协程,则通常其存续时间较短,比较适合使用 Default 度器; 如果是包含 IO 操作的协程,则通常其存续时间较长,且无须连续占据 CPU 资源,因此适合使用   IO 作为其调度器。          如果大家仔细阅读  Default 和  IO 这两个调度器的实现 ,就会发现它们背后实际上是 同一个线程池。 那么,为什么二者在使用上会存在差异呢?由于 IO 任务通常会阻塞实际执行任务的线程,在阻塞过程中线程虽然不占用 CPU,  但却占用了大堂内存,这段时间内被 IO 任务占据线程实际上是资源使用不合理的表现,因此 IO 调度器对于 IO 任务的并发做了限制, 避免过多的 IO 任务并发占用过多的系统资源,同时在调度时为任务打上 PROBABLY BLOCKING 标签,以方便线程池在执行任务调度时对阻塞任务和非阻塞任务区别对待。            Default和IO线程 的区别:IO内部多了一个队列的维护

自定义调度器

如果内置的调度器无法满足需求,我们也可以自行定义调度器,只需要实现 CoroutineDispatcher 接口即可 如代码清单 6-3 所示。  

 不过自己定义调度器的情况不多见,更常见的是将我们自己定义好的线程池转成调度器 ,如代码清单 6-4 所示。

使用扩展函数 asCoroutineDispatcher 就可以将 Executor 转为调度器,不过这个调度器需要在使用完毕后主动关闭,以免造成线程泄露。本例中,我们使用 use 在协程执行完成后主动关闭这个调度器。

再比如Android中我们想要运行在 HandleThread 线程,我们可以这样做:
    private var mHandlerThread: HandlerThread? = HandlerThread("handle_thread")
    private var mHandler: Handler? = mHandlerThread?.run 
        start()
        Handler(this.looper)
    
    ...
    GlobalScope.launch(mHandler.asCoroutineDispatcher("handle_thread")) 
        println("执行在协程中...")
        delay(1000L)
        println("执行完毕...")
    

withContext 

官方框架还为我们提供了一个很好用的 API  withContext 我们可以使用它来简化前面的例子,如代码清单 6-5 所示。 withContext 会将参数中的 Lambda 表达式调度到对应的调度器上,它自己本身就是一个挂起函数,返回值为 Lambda 表达式的值,由此可见它的作用几乎等价于  async ... . await()。      提示:与  async ... .await()相比,  withContext 的内存开销更低,因此对于使用 async 后立即调用 await 情况,应当优先使用  with Context。      在Android中,一般通过 withContext(Dispatchers.IO)将网络请求、文件读写等阻塞IO操作移出主线程:
class LoginRepository(...) 
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> 
        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) 
            // 网络请求阻塞代码
        
    
在以下示例中,协程是在 LoginViewModel 中创建的。由于 makeLoginRequest 将执行操作移出主线程,login 函数中的协程现在可以在主线程中执行:
class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() 
    fun login(username: String, token: String) 
        // Create a new coroutine on the UI thread
        viewModelScope.launch 
            val jsonBody = " username: \\"$username\\", token: \\"$token\\""
            // 调用挂起函数请求网络接口
            val result = loginRepository.makeLoginRequest(jsonBody)
            // 显示请求结果
            when (result) 
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            
        
    

withContext() 的效用

与基于回调的等效实现相比, withContext() 不会增加额外的开销。此外,在某些情况下,还可以优化 withContext() 调用,使其超越基于回调的等效实现。例如,如果某个函数对一个网络进行十次调用,您可以使用外部 withContext() 让 Kotlin 只切换一次线程。这样,即使网络库多次使用 withContext(),它也会留在同一调度程序上,并避免切换线程。此外,Kotlin 还优化了 Dispatchers.Default 与 Dispatchers.IO 之间的切换,以尽可能避免线程切换。

捕获协程中的异常

未处理协程中抛出的异常可能会导致应用崩溃。如果可能会发生异常,请在使用  viewModelScope 或  lifecycleScope 创建的任何协程主体中捕获相应异常。
class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() 
    fun login(username: String, token: String) 
        viewModelScope.launch 
            try 
                loginRepository.login(username, token)
                // Notify view user logged in successfully
             catch (exception: IOException) 
                // Notify view login attempt failed
            
        
    
注意:如需启用协程取消流程, 请不要捕获 CancellationException 类型的异常(或在被发现时总是重新抛出)。首选捕获特定类型的异常(如  IOException),而不是 Exception 或 Throwable 等一般类型。

协程的全局异常处理器

官方协程框架还支待全局的异常处理器, 在根协程未设置异常处理器时,未捕获异常会优 先传递给全局异常处理器处理,之后再交给所在线程的  UncaughtExceptionHandler。     由此可见,全局异常处理器可以 获取所有协程未处理的未捕获异常,不过它并 不能 对异常进行捕获。虽然不能阻止程序崩溃,全局异常处理器在程序调试和异常上报等场景中仍然有非常大的用处。      定义全局异常处理器本身与定义普通异常处理器没有什么区别 ,具体如代码清单 6-6 所示。   关键之处在千我们需要在 classpath 下面创建 META-INF/services 目录,并在其中创建个名为 kotlinx.coroutines.CoroutineExceptionHandler 的文件,文件的内容就是我们的全局异常处理器的全类名。本例中该文件的内容为: com.my.kotlin.coroutine.exceptionhandler.GlobalCoroutineExceptionHandler 接下来测试一下它的效果,如代码清单 6-7 所示  

如果大家在 Android 设备上尝试运行该程序,部分机型可能只能看到全局异常处理器输出的异常信息。换言之,如果我们不配置登全局异常处理器,在 Default 或者 IO 调度器上

遇到未捕获的异常时极有可能发生程序闪退却没有任何异常信息的情况,此时全局异常处理器的配置就显得格外有用了。(提示:全局异常处理器不适用于 javascript 和 Native 平台)

协程的取消检查

我们已知道挂起函数可以通过  suspendCancellableCoroutine 来响应所在协程的取消状态,我们在设计异步任务时,异步任务的取消响应点可能就在这些挂起点处。         如果没有挂起点呢?例如在协程中实现一个文件复制的函数,如使用 Java BIO,完成则不需要调用挂起函数,如代码清单 6-8 所示  

 这是标准库提供的扩展函数,可以实现流复制。

将这段程序直接放入协程中之后,你就会发现协程的取消状态对它没有丝毫影响。要解决这个问题,我们首先可以想到的是在  while 循环处添加一个对所在协程的  isActive 的判断。这个思路没有问题, 我们可以通过 全局属性  coroutineContext 获取所在协程的  Job 实例来做到这一点,如代码清单 6-9 所示。

 如果 job 为空 ,那么说明所在的协程是个简单协程,这种情况不存在取消逻辑;job 不为空时,如果 isActive 也不为 true, 则说明当前协程被取消了,抛出它对应的取消异常即可。

目的达成,不过这样做看上去似乎有些烦琐,如果对协程的内部逻辑了解不多的话很容易出错。有没有更好的办法呢? 那我们就要看看官方协程框架还提供了哪些对逻辑没有响的挂起函数,这其中最合适的就是  yield 函数 ,如代码清单 -10 所示。

 yield 函数的作用主要是检查所在协程状态, 如果已经取消,则抛出取消异常予以响应。此外,它还会尝试出让线程的执行权,给其他协程提供执行机会。

出让调度方面,线程和协程的  yield 的设计思路基本一致,不过线程的  yield 不会抛出中断异常,因而我们知道它不会检查线程的中断状态,这是线程的  yield 与协程的  yield之间一个较大的差异。

协程的超时取消

我们发送网络请求,通常会设置一个超时来应对网络不佳的情况,所有的网络框架(如 OkHttp)都会提供这样的参数。如果有一个特定的需求,用户等不了太久,比如要求 5s 以内没有响应就要取消,这种情况下就要单独修改网络库的超时配置,但这样做不太方便。为了解决这个问题,我们可以这样做,如代码清单 6-1 所示。

 这看上去没什么问题,只是不够简洁,甚至还有些令人迷惑。幸运的是,官方框架提供了一个可以设定超时的 API withTimeout ,我们可以用这个 API 来优化上面的代码,如代码清单 6-12 所示。

 withTimeout  这个 AP 可以设定一个超时,如果它的第二个参数 block 运行超时,那么就会被取消,取消后 withTimeout 直接抛出取消异常。如果不希望在超时的情况下抛出取消异常,也可以使用 withTimeoutOrNull, 它的效果是在超时的情况下返回 null。

隔离协程之间的异常传播

协程出现异常后都会根据所在作用域来尝试将异常向上传递。

 

 根据前面协程作用域异常传播的结论,子协程产生的未捕获异常会传播给它的父协程,然后父协程会取消所有的子协程、取消自己、将异常继续向上传递。如下图:

但这种情况有时并不是我们想要的,我们更希望 一个协程在产生异常时,不影响其他协程的执行。 为了解决上述问题,有以下几种方案:

使用SupervisorJob

第一种方案是可以使用 SupervisorJob替代 JobSupervisorJobJob基本类似,区别在于不会被子协程的异常所影响( 主从作用域)。 先看一下使用普通Job的效果:
 private fun testSupervisorJob() 
        val context = Job() + Dispatchers.IO
        lifecycleScope.launch 
            launch(context) 
                delay(1000)
                println("子协程1")
            
            launch(context) 
                delay(2000)
                println("子协程2")
                9 / 0 // 此处会抛出ArithmeticException异常
            
            launch(context) 
                delay(3000)
                println("子协程3")
            
            launch(context) 
                delay(4000)
                println("子协程4")
            
            delay(5000)
            println("父协程")
        
    
上面在 lifecycleScope中启动了4个子协程,并且为这4个子协程的上下文指定为Job()对象,而在第2个子协程中会抛出运行时异常。 输出如下:  运行之后直接崩溃了, 后面2个子协程没有被执行,这是因为第2个子协程中发生了未捕获的异常,将它传递给了父协程,而父协程发现也不能处理这个异常,于是交给系统处理,默认处理就是终止应用程序。同时父协程又取消了所有的子协程。假如为子协程指定异常处理器,则不会导致崩溃,如下:
private fun testSupervisorJob() 
    val context = Job() + Dispatchers.IO + CoroutineExceptionHandler  context, throwable ->
        println("$context[CoroutineName] 发生了异常: $throwable")
    
    lifecycleScope.launch 
        launch(context + CoroutineName("子协程1")) 
            delay(1000)
            println("子协程1")
        
        launch(context + CoroutineName("子协程2"))  
            delay(2000)
            println("子协程2")
            9 / 0 // 此处会抛出ArithmeticException异常
        
        launch(context + CoroutineName("子协程3")) 
            delay(3000)
            println("子协程3")
        
        launch(context + CoroutineName("子协程4")) 
            delay(4000)
            println("子协程4")
        
        delay(5000)
        println("父协程")
    

 此时主线程不会崩溃了,但是由于第二个协程发生了异常,传递给了父协程,导致父协程取消了其他子协程,因此看不到子协程3和4的输出。此时我们将Job替换成SupervisorJob:

private fun testSupervisorJob() 
    val context = SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler  context, throwable ->
        println("$context[CoroutineName] 发生了异常: $throwable")
    
    lifecycleScope.launch 
        launch(context + CoroutineName("子协程1")) 
            delay(1000)
            println("子协程1")
        
        launch(context + CoroutineName("子协程2")) 
            delay(2000)
            println("子协程2")
            9 / 0 // 此处会抛出ArithmeticException异常
        
        launch(context + CoroutineName("子协程3")) 
            delay(3000)
            println("子协程3")
        
        launch(context + CoroutineName("子协程4")) 
            delay(4000)
            println("子协程4")
        
        delay(5000)
        println("父协程")
    

运行之后输出:

 可以看到子协程3和4这两个子协程不会因为子协程2发生了异常而被取消了,也就是说4个子协程都可以独立运行,互不影响

所以 SupervisorJob的一个重要作用就是定义隔离协程作用域之间的异常传播。

使用上下文自带SupervisorJob的Scope对象

如果只是针对上面的例子,可以不用这么麻烦,因为我们已经知道Android中 lifecycleScope、viewModelScope、MainScope这几种作用域的上下文构成已经包含了  SupervisorJob(),官方之所以什么设计这几种Scope的上下文,其实是已经为我们考虑好了使用场景,所以第二种方案就是直接使用上面几种scope来launch子协程即可。例如:
private fun testSupervisorJob() 
    val exceptionHandler = CoroutineExceptionHandler  context, throwable ->
        println("$context[CoroutineName] 发生了异常: $throwable")
    
    lifecycleScope.launch 
        lifecycleScope.launch 
            delay(1000)
            println("子协程1")
        
        lifecycleScope.launch(exceptionHandler) 
            delay(2000)
            println("子协程2")
            9 / 0 // 此处会抛出ArithmeticException异常
        
        lifecycleScope.launch 
            delay(3000)
            println("子协程3")
        
        lifecycleScope.launch 
            delay(4000)
            println("子协程4")
        
        delay(5000)
        println("父协程")
    

这样同样可以使得子协程之间的异常互不影响。

使用supervisorScope

另外一种解决方案是可以使用前面协程作用域中提到的  supervisorScope 这个挂起函数,它的作用就是定义一个 主从作用域,因此可以用来隔离:
private fun testSupervisorJob() 
    val exceptionHandler = CoroutineExceptionHandler  context, throwable ->
        println("$context[CoroutineName] 发生了异常: $throwable")
    
    lifecycleScope.launch 
        supervisorScope 
            launch 
                delay(1000)
                println("子协程1")
            
            launch(exceptionHandler) 
                delay(2000)
                println("子协程2")
                9 / 0 // 此处会抛出ArithmeticException异常
            
            launch 
                delay(3000)
                println("子协程3")
            
            launch 
                delay(4000)
                println("子协程4")
            
        
        delay(5000)
        println("父协程")
    

相比之下,SupervisorJob可能更适合用于自定义Scope的场景中(例如在ViewModel或Service中),除了官方库自带的几种Scope,我们有时可以通过继承或组合的方式来使用CoroutineScope来创建自己的Scope对象,例如:

class MyViewModel : ViewModel() 
    private val exceptionHandler = CoroutineExceptionHandler  _, throwable ->
        println("发生了异常: $throwable")
    
    private val scopeWithNoEffect = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)

    fun doBusiness() 
        viewModelScope.launch 
            scopeWithNoEffect.launch 
                // ... 业务1
            
            launch 
                // ... 业务2
            
        
    

    override fun onCleared() 
        super.onCleared()
        scopeWithNoEffect.cancel()
    

这样业务1挂掉了不会影响业务2的执行。

问题:既然 lifecycleScope是使用的 SupervisorJob,而协程上下文是可以继承的,那么 lifecycleScope启动的子协程不应该是自动继承 SupervisorJob吗?为什么还需要为子协程显示指定 SupervisorJob? 也就是说下面的代码,子协程中即使抛出异常,也不会影响其他兄弟协程才对,因为继承了父协程的SupervisorJob。
private fun testSupervisorJob() 
    lifecycleScope.launch 
        launch 
            throw Exception()
        
        launch  
    

而实际上这个代码中的子协程的异常依然传递给了父协程导致崩溃,所以说子协程并没有从父协程继承SupervisorJob。

这个问题的答案是: 对于在作用域内创建的新协程, 系统始终 会为每个新的子协程创建一个新的Job实例。  

 


以下部分来

以上是关于深入理解Kotlin协程协程作用域启动模式调度器异常和取消使用篇的主要内容,如果未能解决你的问题,请参考以下文章

Kotlin 协程协程启动 ⑤ ( 协程作用域构建器 | runBlocking 函数 | coroutineScope 函数 | supervisorScope 函数 )

深入理解Kotlin协程协程调度器Dispatchers源码追踪扒皮

深入理解Kotlin协程协程的创建启动挂起函数理论篇

深入理解Kotlin协程协程的分类与线程的区别

Kotlin 协程协程启动 ① ( 协程构建器 | launch 构建器 | async 构建器 | runBlocking 函数 | Deferred 类 )

Kotlin 协程协程启动 ① ( 协程构建器 | launch 构建器 | async 构建器 | runBlocking 函数 | Deferred 类 )