kotlin协程[8]:再说作用域

CoroutineScope:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

定义新协程的范围。每个协程构建器都是CoroutineScope的扩展,并继承其coroutineContext以自动传播上下文元素和取消。

获取范围的独立实例的最佳方法是CoroutineScope()和MainScope()工厂函数。可以使用plus运算符将其他上下文元素附加到作用域。

建议不要手动实现此接口,应优先考虑通过委派实现。按照惯例,作用域的上下文应包含作业实例以强制执行结构化并发。

每个协同程序构建器(如launch,async等)和每个作用域函数(如coroutineScope,withContext等)都会将自己的作用域实例提供给它运行的内部代码块。按照惯例,它们都会等待块内的所有协同程序在完成自己之前完成,从而强制执行结构化并发规则。

CoroutineScope应该在具有明确定义的生命周期的实体上实现(或用作字段),这些实体负责启动子协同程序

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/index.html

CoroutineScope是必须的么?其实不是的。当协程还是实验性质的时候Kotlin 1.1时,我们启动协程是可以这样写的:

fun requestSomeData() {
    launch {
        updateUI(performRequest())
    }
}

这里我们在UI上下文中启动一个新的协同程序launch(UI),调用挂起函数performRequest对后端进行异步调用而不阻塞主UI线程,然后用结果更新UI。每个requestSomeData调用创建自己的协程,它很好,不是吗?

但这是一个问题。如果网络或后端出现问题,这些异步操作可能需要很长时间才能完成。而且,这些操作通常在某些UI元素(如窗口或页面)的范围内执行。如果操作需要很长时间才能完成,则典型用户会关闭相应的UI元素并执行其他操作,或者更糟糕的是,重新打开此UI并一次又一次地尝试操作。但是我们之前的操作仍然在后台运行,当用户关闭相应的UI元素时,我们需要一些机制来取消它。

一个简单的launch { … }易于编写,但它不是你应该写的

协同程序始终与应用程序中的某些本地作用域相关,这是一个生命周期有限的实体,如UI元素。因此,对于结构化并发,我们现在要求在CoroutineScope中调用启动,CoroutineScope是由您的终身受限对象(如UI元素或其对应的视图模型)实现的接口。

对于更新UI操作CoroutineScope提供专门的实现,在这里可以看到

对于那些需要全局协程,其生命周期受应用程序生命周期限制的极少数情况,我们现在提供GlobalScope对象,因此之前为全局协程启动launch{...},现在变为GlobalScope.launch {...},这个协同程序的全局特性在代码中变得明确。GlobalScope在之前的几章中经常用到的。

emmm............加入CoroutineScope就只是解决了这个异步操作的问题么?

再看下面示例:

suspend fun loadAndCombine(name1: String, name2: String): Image { 
    val deferred1 = async { loadImage(name1) }
    val deferred2 = async { loadImage(name2) }
    return combineImages(deferred1.await(), deferred2.await())
}

这个例子看起来不错,这个suspend函数最终会在某个协程内部调用,异步下载2张图片然后合并成一张,但是还是有很多微妙的错误,如果这个协程取消怎么办?然后加载两个图片的异步任务仍然没有受到影响,这不是一个可靠的代码。

那在父协程取消的时候把子协程都取消不就可以了,改成这样async(coroutineContext) { … }

它仍然还是有问题,比如下载第一张图片失败了,则deferred1.await()抛出了相应的异常,但是加载第二张图片的协程仍然在后台工作,解决这个问题就更加复杂了。

一个简单async { … }易于编写,但它不是你应该写的

使用结构化并发async协同程序构建器CoroutineScope就像是一样成为扩展launch。你不能简单地写async { … },你必须提供范围。一个适当的并行分解的例子变成:

suspend fun loadAndCombine(name1: String, name2: String): Image =
    coroutineScope { 
        val deferred1 = async { loadImage(name1) }
        val deferred2 = async { loadImage(name2) }
        combineImages(deferred1.await(), deferred2.await())
}

你必须将代码包装到coroutineScope { … }块中,以建立操作的边界及其范围。所有async协同程序都成为此范围的子代,如果范围因异常而失败或被取消,则所有子代也将被取消。

协程的团队在引入了结构化并发(Structured concurrency)之后,他们就改变了协程构建器功能launch()async()顶级更改为使用CoroutineScope接收器的扩展

coroutineScope方法

为了更加理解coroutineScope,看下下面示例:

  @Test
    fun main() {
        runBlocking {
            try {
                coroutineScope {
                    launch { // “1”
                        println("a")
                    }
                    launch {// “2”
                        println("b")
                        launch {// “3”
                            delay(1000)
                            println("c")
                            throw ArithmeticException("Hey!!")
                        }
                    }
                    val job = launch {// “4”
                        println("d")
                        delay(2000)
                        println("e")
                    }
                    job.join()
                    println("f")
                }
 
            } catch (e: Exception) {
                println("g")
            }
            println("h")
        }
    }

输出结果:

a
b
d
c
g
h

会发现e,f没有输出

原因:coroutineScope 是继承外部 Job 的上下文创建作用域,在其内部的取消操作是双向传播的,子协程未捕获的异常也会向上传递给父协程。它更适合一系列对等的协程并发的完成一项工作,任何一个子协程异常退出,那么整体都将退出,简单来说就是”一损俱损“。这也是协程内部再启动子协程的默认作用域。

coroutineSocpe启动了3个协程,“2”协程又启动了子协程“3”,子协程“3”因为抛出异常取消了。因为coroutineSocpe异常时双向的所以“3”会通知其父协程“2”取消,2会根据其作用域通知coroutineSocpe取消,这是一个自下而上的过程,coroutineSocpe取消会通知“4”取消,这是一个自上而下的过程。

其中join()delay()是支持取消的,所以这两处就被取消了e,f就没有被打出来了。

这里有一个小细节我们可以对coroutineSocpe内部协程中的异常直接try...catch...捕获掉表明协程把异步的异常处理到同步代码逻辑当中。

supervisorScope

再说一个和coroutineSocpe类似的supervisorScope

  @Test
    fun main() {
        runBlocking {
            try {
                supervisorScope{
                    launch { // “1”
                        println("a")
                    }
                    launch {// “2”
                        println("b")
                        launch {// “3”
                            delay(1000)
                            println("c")
                            throw ArithmeticException("Hey!!")
                        }
                    }
                    val job = launch {// “4”
                        println("d")
                        delay(2000)
                        println("e")
                    }
                    job.join()
                    println("f")
                }
 
            } catch (e: Exception) {
                println("g")
            }
            println("h")
        }
    }

输出:

a
b
d
c
Exception in thread "main @coroutine#5" java.lang.ArithmeticException: Hey!!
    ...
e
f
h

会发现g没有输出

原因:supervisorScope 同样继承外部作用域的上下文,但其内部的取消操作是单向传播的,父协程向子协程传播,反过来则不然,这意味着子协程出了异常并不会影响父协程以及其他兄弟协程。它更适合一些独立不相干的任务,任何一个任务出问题,并不会影响其他任务的工作,简单来说就是”自作自受“,例如 UI,我点击一个按钮出了异常,其实并不会影响手机状态栏的刷新。需要注意的是,supervisorScope 内部启动的子协程内部再启动子协程,如无明确指出,则遵守默认作用域规则,也即 supervisorScope 只作用域其直接子协程。

supervisorScope启动了3个协程,“2”协程又启动了子协程“3”,子协程“3”因为抛出异常取消了。但是因为supervisorScope的取消操作是单向的即父协程向子协程传播的,所以“3”协程并不会影响“2”协程

  @Test
    fun main() {
        val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
            println("${coroutineContext[CoroutineName]} $throwable")
        }
        runBlocking {
            try {
                supervisorScope {
                    launch {
                        // "1"
                        println("a")
                    }
                    launch(exceptionHandler + CoroutineName("\"2\"")) {
                        // "2"
                        println("b")
                        launch(exceptionHandler + CoroutineName("\"3\"")) {
                            //"3"
                            launch (exceptionHandler + CoroutineName("\"5\"")){// "5"
                                delay(1000)
                                println("c-")
                            }
                            println("c")
                            throw ArithmeticException("Hey!!")
                        }
                    }
                    val job = launch {
                        //"4"
                        println("d")
                        delay(2000)
                        println("e")
                    }
                    job.join()
                    println("f")
                }
 
            } catch (e: Exception) {
                println("g")
            }
            println("h")
        }
    }

仔细看下输出:

a
b
d
c
CoroutineName("2") java.lang.ArithmeticException: Hey!!
e
f
h

异常竟然是协程“2”打出来的而且c-和g没有打出来。

其实并不意外,supervisorScope 内部启动的子协程内部再启动子协程,如无明确指出,则遵守默认作用域规则,也即 supervisorScope 只作用于其直接子协程。默认作用域规则就是coroutineScope,子协程未捕获的异常也会向上传递给父协程。

GlobeScope

看一个示例:

  fun work(i: Int) {
        Thread.sleep(1000)
        println("Work $i done")
    }
 
    @Test
    fun main() {
        val time = measureTimeMillis {
            runBlocking {
                for (i in 1..2) {
                    launch {
                        work(i)
                    }
                }
            }
        }
        println("Done in $time ms")
    }

输出的结果:

Work 1 done
Work 2 done
Done in 2095 ms

它打印Work 1 done和Work 2 done,但它需要两秒钟才能完成。并发在哪里?launch已经继承了从引进范围协程调度runBlocking协同程序生成器,该组合限制住执行到单个线程,所以这两个任务在主线程中执行顺序。

要并发换成这样就行了:

launch(Dispatchers.Default) {
    work(i)
}

这样就能在1s中完成了。

如果我换成GlobalScope启动协同程序会发生什么?它应该是相同的,因为它在后台线程Dispatchers.Default中执行协程。

    @Test
    fun main() {
        val time = measureTimeMillis {
            runBlocking {
                for (i in 1..2) {
                   GlobalScope.launch {
                        work(i)
                    }
                }
            }
        }
        println("Done in $time ms")
    }

输出结果:

Done in 97 ms

并没有打印Work x done,直接打印了Done in 97 ms。为什么?

原因:通过 GlobeScope 启动的协程单独启动一个协程作用域,内部的子协程遵从默认的作用域规则。通过 GlobeScope 启动的协程“自成一派”。

GlobeScope.launch{...}launch(Dispatchers.Default){...}的区别就出来了。启动(Dispatchers.Default)runBlocking范围内创建子协程,因此runBlocking会自动等待它们的完成。但是,GlobalScope.launch创建了全局协程。

我们可以通过以下手段控制来达到和launch(Dispatchers.Default){...}同样的效果:

    @Test
    fun main() {
        val time = measureTimeMillis {
            runBlocking {
                val jobs = mutableListOf()
                for (i in 1..2) {
                    jobs += GlobalScope.launch {
                        work(i)
                    }
                }
                jobs.forEach { it.join() }
            }
        }
        println("Done in $time ms")
    }

现在输出:

Work 1 done
Work 2 done
Done in 1102 ms

现在这个例子与GlobalScope代码的工作方式类似launch(Dispatchers.Default),但需要付出更多努力,为什么还要编写更多代码?几乎没有理由GlobalScope在基于Kotlin协同程序的应用程序中使用。

对于上面的操作还可以这样:

  suspend fun work(i: Int) = withContext(Dispatchers.Default) {
        Thread.sleep(1000)
        println("Work $i done")
    }

tips:

  • 对于没有协程作用域,但需要启动协程的时候,适合用 GlobalScope

  • 对于已经有协程作用域的情况(例如通过 GlobalScope 启动的协程体内),直接用协程启动器启动

  • 对于明确要求子协程之间相互独立不干扰时,使用 supervisorScope

  • 对于通过标准库 API 创建的协程,这样的协程比较底层,没有 Job、作用域等概念的支撑,例如我们前面提到过 suspend main 就是这种情况,对于这种情况优先考虑通过 coroutineScope 创建作用域;更进一步,大家尽量不要直接使用标准库 API,除非你对 Kotlin 的协程机制非常熟悉

launch

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    // ...
): Job

它被定义为CoroutineScope上的扩展函数,并将CoroutineContext作为参数,因此它实际上需要两个协程上下文(因为范围只是对上下文的引用)。
它与它们有什么关系?它使用plus运算符合并它们,生成其元素的集合,以便context参数中的元素优先于作用域中的元素。生成的上下文用于启动新的协程,但它不是新协程的上下文而是新协程的父上下文。新的协程创建自己的子Job实例(使用此上下文中的job作为其父)并将其子上下文定义为父上下文plus其job:

图片来自于:Coroutine Context and Scope

a,按照惯例,CoroutineScope中的上下文包含一个Job,它将成为新的coroutine的父级(GlobalScope除外,你应该避免)。

b,启动时的CoroutineContext参数是提供额外的上下文元素来覆盖否则将从父作用域继承的元素。

c,按照惯例,我们通常不会在上下文参数中传递Job来启动,因为这会破坏父子关系,除非我们明确想要使用NonCancellable作业来打破它。

d,按照惯例,所有协程构建器作用域的coroutineContext属性与在此block内运行的协同程序的上下文相同。

 @Test
    fun main() = runBlocking {
        launch { scopeCheck(this) }
    }
 
    suspend fun scopeCheck(scope: CoroutineScope) {
        println(scope.coroutineContext === coroutineContext)
    }

输出为:true

e,由于上下文和范围在本质上是相同的,我们可以在没有访问范围的情况下启动协程,而不使用GlobalScope只需将当前coroutineContext包装到CoroutineScope的实例中,如以下函数所示:

suspend fun doNotDoThis() {
    CoroutineScope(coroutineContext).launch {
        println("I'm confused")
    }
}

不要这样做!它使协程的启动范围变得不透明和隐含,捕获一些外部Job来启动一个新的协程,而不在函数签名中明确地宣布它。协程是与您的其余代码同时进行的一项工作,其启动必须是明确的.

你可能感兴趣的:(kotlin协程[8]:再说作用域)