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<Job>()
                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<Unit> {
        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来启动一个新的协程,而不在函数签名中明确地宣布它。协程是与您的其余代码同时进行的一项工作,其启动必须是明确的.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容