协程:runBlocking vs coroutineScope。

111

我正在阅读协程基础,试图理解和学习它。

其中有一部分代码如下:

fun main() = runBlocking { // this: CoroutineScope
    launch { 
        delay(200L)
        println("Task from runBlocking")
    }

    coroutineScope { // Creates a new coroutine scope
        launch {
            delay(900L) 
            println("Task from nested launch")
        }

        delay(100L)
        println("Task from coroutine scope") // This line will be printed before nested launch
    }

    println("Coroutine scope is over") // This line is not printed until nested launch completes
}

输出结果如下:

Task from coroutine scope
Task from runBlocking
Task from nested launch
Coroutine scope is over

我的问题是为什么这行代码:

 println("Coroutine scope is over") // This line is not printed until nested launch completes

总是被称为“最后”吗?

难道不应该被称为"自从那时起"吗?

coroutineScope { // Creates a new coroutine scope
    ....
}

被暂停了吗?

这里还有一个注释:

runBlocking和coroutineScope之间的主要区别在于,后者在等待所有子协程完成时不会阻止当前线程。

我不明白coroutineScope和runBlocking在这里有什么不同? coroutineScope看起来像是阻塞的,因为它只有在完成后才能到达最后一行。

有人能解释一下吗?


你可能会发现有关 coroutineScope 的文档很有启发性。 - Roland
19
应该更新文档以更好地解释这一点,因为不止你一个人对其进行了这样的解释。 - Johann
1
Kotlin的文档非常糟糕,总是突然抛出概念,解释也很令人困惑。 - FraK
7个回答

102
我不理解coroutineScope和runBlocking在这里有什么不同?coroutineScope看起来像是阻塞的,因为它只有在完成后才到达最后一行。
存在两个不同的世界:可暂停的世界(在协程中)和不可暂停的世界。一旦进入runBlocking的代码块,你就处于可暂停的世界中,在那里suspend fun会表现得像阻塞代码一样,直到suspend fun返回时才能执行下一行代码。 coroutineScope是一个suspend fun,只有在其中所有的协程都完成后才返回。因此,最后一行必须在结束时打印出来。
  • coroutineScope是一个suspend fun。如果您的协程挂起,coroutineScope函数也会被挂起。这使得顶层函数(一个创建协程的非挂起函数)可以在同一线程上继续执行。该线程已经“逃脱”了coroutineScope块并准备做其他工作。

  • 以您的特定示例为例:当您的coroutineScope挂起时,控制权将返回到runBlocking内部的实现代码中。此代码是驱动在其中启动的所有协程的事件循环。在您的情况下,将安排一些协程在延迟后运行。当时间到达时,它将恢复相应的协程,该协程将运行一段时间,挂起,然后控制权再次位于runBlocking内。


    虽然上面描述了概念上的相似之处,但它也应该向您展示,runBlockingcoroutineScope是完全不同的工具。

    • runBlocking是一个低级构造,仅在框架代码或像您这样的自包含示例中使用。它将现有线程转换为事件循环,并使用一个Dispatcher创建其协程,该调度程序将恢复协程并将其推送到事件循环的队列中。

    • coroutineScope 是一个用户可见的构造,用于分解并行任务的边界。您可以使用它方便地等待其内部发生的所有 async 工作,获取最终结果并在一个中心位置处理所有失败情况。


    仅仅是因为'runBlocking'的原因,导致最后一行在coroutineScope完成后才被调用吗? - Archie G. Quiñones
    28
    不,这两个不同的世界是可暂停的世界(在协程内部)和不可暂停的世界。一旦进入runBlocking的代码块,就进入了可暂停的世界,在这里suspend fun的行为就像阻塞代码一样,直到suspend fun返回后才能执行下一行代码。coroutineScope是一个suspend fun,只有当其中所有的协程都完成时,它才会返回。因此,最后一行必须在最后打印出来。 - Marko Topolnik
    4
    我终于明白了!非常感谢!我把 'CoroutineScope' 和 'coroutineScope' 搞混了。现在我明白它们是两个不同的东西了。 'CoroutineScope' 启动协程,而 'coroutineScope' 是一个挂起函数,可以连接协程的构建和挂起函数。非常感谢 :) - Archie G. Quiñones
    3
    CoroutineScope 只是一个 CoroutineContext 的容器。launchasync 和其他函数都是 CoroutineScope 的扩展函数,它们只是获取其上下文,将其与您在参数中另外提供的任何上下文结合,并将结果作为您正在构建的协程的上下文。 - Marko Topolnik
    好的回答!我想问一下关于这行代码:“'要继续在同一个线程上执行'”。必须在同一个线程上继续执行吗? - stdout
    1
    @stdout那个句子谈论的是底层机制。继续执行的是顶级事件循环代码而不是任何协程。然后该代码将选择在同一线程上分派的另一个协程并恢复它。 - Marko Topolnik

    50
    选择的答案不错,但没能涉及提供的示例代码中的其他重要方面。例如,launch是非阻塞的,应该立即执行。然而事实并非如此,launch本身会立即返回,但是在launch内部的代码似乎被放入队列中,只有当之前放入队列的所有其他launch完成时才会执行。
    下面是一段类似的示例代码,其中删除了所有的延迟,并增加了一个额外的launch。在查看下面的结果之前,请尝试预测数字打印的顺序。很可能你会失败:
    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        launch { 
            println("1")
        }
    
        coroutineScope {
            launch {
                println("2")
            }
    
            println("3") 
        }
    
        coroutineScope {
            launch {
                println("4")
            }
    
            println("5")
        }
    
        launch { 
            println("6")
        }
    
        for (i in 7..100) {
            println(i.toString())
        }
    
        println("101")
    }
    

    结果为:

    3
    1
    2
    5
    4
    7
    8
    9
    10
    ...
    99
    100
    101
    6
    
    即使执行了近100个println之后,数字6仍然被打印在最后的事实表明,在最后一个launch内部的代码在所有非阻塞代码完成之前不会得到执行。但这也不是真的,因为如果是这样,第一个launch就不应该在7到101号数字完成之前执行。底线是什么?混合使用launch和coroutineScope是高度不可预测的,如果您期望以特定顺序执行任务,则应避免混合使用它们。
    为了证明将launch内的代码放入队列并且只有在所有非阻塞代码完成后才执行,请运行以下代码(没有使用coroutineScope):
    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        launch { 
            println("1")
        }
    
        launch { 
            println("2")
        }
    
        launch { 
            println("3")
        }
    
        for (i in 4..100) {
            println(i.toString())
        }
    
        println("101")
    }
    

    这是您获得的结果:

    4
    5
    6
    ...
    101
    1
    2
    3
    

    添加CoroutineScope将破坏此行为。它会导致所有跟随CoroutineScope的非阻塞代码在CoroutineScope之前的所有代码完成之前都不会被执行。

    还应该注意,在此代码示例中,队列中的每个启动都按照添加到队列中的顺序依次执行,并且每个启动都仅在前一个启动执行后执行。这可能会使所有启动看起来共享一个公共线程。但实际上并不是这样,每个启动都有自己的线程。但是,如果启动内部的任何代码调用挂起函数,那么在执行挂起函数时立即启动队列中的下一个启动。老实说,这种行为非常奇怪。为什么不只异步运行队列中的所有启动?虽然我不知道这个队列中发生了什么内部机制,但我的猜测是,队列中的每个启动并没有得到它自己的线程,而是共享一个公共线程。只有遇到挂起函数时,才会为队列中的下一个启动创建新线程。这可能是为了节省资源而采取的措施。

    总之,执行顺序如下:

    1. 启动内的代码放置在队列中,并按添加顺序执行。
    2. 跟随启动的非阻塞代码会在队列中的任何内容之前立即执行。
    3. CoroutineScope将阻止其后的所有代码执行,但会在恢复到CoroutineScope后执行队列中的所有启动协程。

    4
    coroutineScope 之所以会“阻塞”其后的代码,是因为 coroutineScope 是一个挂起函数。coroutineScope 会一直挂起,直到所有协程/函数都返回为止。它基本上是一种“作用域”机制。 - Archie G. Quiñones
    非阻塞代码(launch)立即返回,因此紧随其后的代码会立即执行。 - Archie G. Quiñones
    @ArchieG.Quiñones 无论队列是否在内部使用并不重要。它确实像一个队列一样运作。为了测试这一点,只需连续运行两个启动程序,并让它们都执行大量的工作。第二个启动程序将不会开始执行其代码,直到第一个完成。添加第三个启动程序,它将不会开始,直到第二个完成。但是,正如已经提到的,一旦协程中的某些代码调用挂起函数,这种行为就会改变。 - Johann
    5
    第二次启动将不会开始执行其代码,直到第一个完成--这只是使用单线程协程调度程序的产物,并不属于协程的语义。语义是“并发执行在调用launch时立即开始”,其余部分取决于内部调度。 - Marko Topolnik
    2
    作为一个初学者,这真是令人费解.. :( - Balaji
    显示剩余6条评论

    29

    runBlocking 的作用是阻塞主线程。

    coroutineScope 的作用是阻塞 runBlocking。


    3
    哇,难道就这么简单吗! - AbdelHady

    12

    阅读了这里所有的答案,我发现没有一个答案回答问题超越了文档中片段的措辞。

    所以,我继续去别处寻找答案,并在这里找到了它。这实际上展示了coroutineScoperunBlocking之间行为差异的区别(即挂起和阻塞之间的区别)。


    1
    唯一的净效果就是coroutineScope可以被取消。就这样。 - undefined

    8
    runBlocking只会阻塞当前线程,直到内部的协程完成。这里,执行runBlocking的线程将被阻塞直到coroutineScope中的协程完成。

    首先,launch不允许线程执行runBlocking后面的指令,但允许继续执行紧接着该launch语句块之后的指令——这就是为什么Task from coroutine scopeTask from runBlocking更早打印出来的原因。

    但是,在runBlocking上下文中嵌套的coroutineScope不允许线程执行此coroutineScope代码块之后的指令,因为runBlocking会一直阻塞线程,直到coroutineScope中的协程全部完成。这就是为什么Coroutine scope is over总是在Task from nested launch之后打印的原因。


    3

    这篇精彩文章的最初回答来源于https://jivimberg.io/blog/2018/05/04/parallel-map-in-kotlin/

    。本文主要涉及IT技术相关内容,讲述了Kotlin中的并行映射操作。

    suspend fun <A, B> Iterable<A>.pmap(f: suspend (A) -> B): List<B> = coroutineScope {
        map { async { f(it) } }.awaitAll()
    }
    

    使用runBlocking时,我们没有使用结构化并发,因此f的调用可能会失败,而所有其他执行都将继续进行。此外,我们也没有与其余代码协作良好。通过使用runBlocking,我们强制阻塞线程直到整个pmap执行完成,而不是让调用者决定执行方式。"最初的回答"

    1
    关于为什么println("Coroutine scope is over")总是最后被调用的问题,与runBlocking和coroutineScope之间的区别无关。 被接受的答案只解释了runBlocking和coroutineScope之间的区别。我将尝试解释另一个问题。
    首先,我猜你误解了coroutineScopelaunch
    1. coroutineScope是一个挂起函数,它会挂起并且不会恢复执行,直到其中的所有代码都执行完毕。
    2. launch是一个普通函数,它以并发的方式运行挂起操作。
    因此,在coroutineScope内部的代码中,两个println将并行执行。
    coroutineScope { // Creates a new coroutine scope
        launch {
            delay(900L) 
            println("Task from nested launch")
        }
    
        delay(100L)
        println("Task from coroutine scope") // This line will be printed before nested launch
    }
    

    而且,通过延迟操作,第二个将会先执行。
    这就是困扰你的地方。为什么第一个println会执行,但最后一个却不会?
    fun main() = runBlocking { // this: CoroutineScope
        launch { 
            delay(200L)
            println("Task from runBlocking")
        }
    
        coroutineScope { // Creates a new coroutine scope
            launch {
                delay(900L) 
                println("Task from nested launch")
            }
    
            delay(100L)
            println("Task from coroutine scope") // This line will be printed before nested launch
        }
    
        println("Coroutine scope is over") // This line is not printed until nested launch completes
    }
    

    因为launch是一个普通函数,而coroutineScope是一个挂起函数。所以第一个println不会被阻塞。 如果你将launch改为coroutineScope,第二个coroutineScope将会被阻塞,直到第一个被恢复并返回。
    我认为 Kotlin 协程的难点在于:
    1. 有几个术语很难理解。
    2. 以 lambda 风格提供的 API 隐藏了许多细节,使用起来很方便,但容易丧失调用上下文的信息。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接