Kotlin 协程 - 延迟,它是如何工作的?

Posted

技术标签:

【中文标题】Kotlin 协程 - 延迟,它是如何工作的?【英文标题】:Kotlin coroutines - delay, how does it work? 【发布时间】:2020-09-15 17:37:15 【问题描述】:

我很习惯使用 RX 来处理并发,但是,在我目前的工作中,我们混合了 AsyncTask、Executors + Handlers、线程和一些 LiveData。现在我们正在考虑转向使用 Kotlin Coroutines (并且实际上已经开始在代码库的某些地方使用它)。

因此,我需要开始研究协程,最好利用我现有的并发工具知识来加快进程。

我已经尝试为他们遵循 Google 代码实验室,虽然它让我有点理解,但它也提出了许多未解决的问题,所以我尝试通过编写一些代码、调试和查看日志输出来弄脏自己的手。

据我了解,协程由 2 个主要构建块组成;挂起函数是你工作的地方,协程上下文是你执行挂起函数的地方,这样你就可以掌握协程将在哪些调度程序上运行。

我在下面有一些代码,其行为与我预期的一样。我已经使用 Dispatchers.Main 设置了协程上下文。因此,正如预期的那样,当我启动协程getResources 时,由于Thread.sleep(5000),它最终阻塞了 UI 线程 5 秒:

private const val TAG = "Coroutines"

class MainActivity : AppCompatActivity(), CoroutineScope 
    override val coroutineContext: CoroutineContext = Job() + Dispatchers.Main

    override fun onCreate(savedInstanceState: Bundle?) 
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        log("onCreate", "launching coroutine")
        launch 
            val resource = getResource()
            log("onCreate", "resource fetched: $resource")
            findViewById<TextView>(R.id.textView).text = resource.toString()
        
        log("onCreate", "coroutine launched")
    

    private suspend fun getResource() : Int 
        log("getResource", "about to sleep for 5000ms")
        Thread.sleep(5000)
        log("getResource", "finished fetching resource")
        return 1
    

    private fun log(methodName: String, toLog: String) 
        Log.d(TAG,"$methodName: $toLog: $Thread.currentThread().name")
    

当我运行此代码时,我会看到以下日志:

2020-05-28 11:42:44.364 9819-9819/? D/Coroutines: onCreate: launching coroutine: main
2020-05-28 11:42:44.376 9819-9819/? D/Coroutines: onCreate: coroutine launched: main
2020-05-28 11:42:44.469 9819-9819/? D/Coroutines: getResource: about to sleep for 5000ms: main
2020-05-28 11:42:49.471 9819-9819/com.example.coroutines D/Coroutines: getResource: finished fetching resource: main
2020-05-28 11:42:49.472 9819-9819/com.example.coroutines D/Coroutines: onCreate: resource fetched: 1: main

如您所见,所有日志均源自主线程,Thread.sleep(5000) 前后的日志之间有 5 秒的间隔。在这 5 秒的间隙中,UI 线程被阻塞了,我可以通过查看模拟器来确认这一点;它不会呈现任何 UI,因为 onCreate 已被阻止。

现在,如果我更新getResources 函数以使用暂停乐趣delay(5000) 而不是像这样使用Thread.sleep(5000)

private suspend fun getResource() : Int 
    log("getResource", "about to sleep for 5000ms")
    delay(5000)
    log("getResource", "finished fetching resource")
    return 1

然后我最终看到的东西让我感到困惑。我知道delayThread.sleep 不同,但是因为我在由Dispatchers.Main 支持的协程上下文中运行它,所以我希望看到与使用Thread.sleep 相同的结果。

相反,我看到的是 UI 线程在 5 秒延迟发生时没有被阻塞,并且日志看起来像:

2020-05-28 11:54:19.099 10038-10038/com.example.coroutines D/Coroutines: onCreate: launching coroutine: main
2020-05-28 11:54:19.111 10038-10038/com.example.coroutines D/Coroutines: onCreate: coroutine launched: main
2020-05-28 11:54:19.152 10038-10038/com.example.coroutines D/Coroutines: getResource: about to sleep for 5000ms: main
2020-05-28 11:54:24.167 10038-10038/com.example.coroutines D/Coroutines: getResource: finished fetching resource: main
2020-05-28 11:54:24.168 10038-10038/com.example.coroutines D/Coroutines: onCreate: resource fetched: 1: main

我可以看到在这种情况下 UI 线程没有被阻塞,因为 UI 在延迟发生时呈现,然后文本视图在 5 秒后更新。

所以,我的问题是,在这种情况下,延迟如何不阻塞 UI 线程(即使我的挂起函数中的日志仍然表明该函数正在主线程上运行......)

【问题讨论】:

你可以把协程想象成语法糖,通过嵌套回调编写一系列事件。使用延迟调用挂起函数就像告诉执行程序在后台线程上执行 Thread.sleep,然后在延迟调用后发送到 Main 处理程序的回调中运行延迟调用下面的所有代码。 是的,我开始了解@Tenfour04 的那个位,但延迟本身就是一个挂起函数,在这种情况下,它在由 Dispatchers.Main 支持的协程上下文中调用,因此实际上应该挂起在主线程右边(即应该阻塞主线程)? 或者delay 是否真的打开了另一个由Dispatchers.Default 支持的协程上下文? 每当你调用一个挂起函数时,如果不查看它的源代码,你就无法知道它是委托给不同的调度程序还是使用其他机制在后台执行某些操作,然后再恢复协程的“续”。正确编写的挂起函数绝不能阻塞调用它的调度程序的线程,因为它可能是主调度程序。 如果它被标记为suspend,你知道它不会阻塞你调用它的主线程,只要那个suspend函数的作者没有出错。对于编写挂起函数时可能犯的某些错误,有一些编译器警告。 【参考方案1】:

将挂起函数视为一种使用接受回调的函数的方法,但不需要您将回调传递给它。相反,回调代码是挂起函数调用下的所有内容。

这段代码:

lifecycleScope.launch 
    myTextView.text = "Starting"
    delay(1000L)
    myTextView.text = "Processing"
    delay(2000L)
    myTextView.text = "Done"

有点像:

myTextView.text = "Starting"
handler.postDelayed(1000L) 
    myTextView.text = "Processing"
    handler.postDelayed(2000L) 
        myTextView.text = "Done"
    

不应期望暂停功能会阻塞。如果是这样,则它们的组成不正确。挂起函数中的任何阻塞代码都应该包含在它的背景中,例如withContextsuspendCancellableCoroutine(这是较低级别的,因为它直接与协程延续一起工作)。

如果你尝试这样写一个挂起函数:

suspend fun myDelay(length: Long) 
    Thread.sleep(length)

您将收到“不适当的阻塞方法调用”的编译器警告。如果将其推送到后台调度程序,则不会收到警告:

suspend fun myDelay(length: Long) = withContext(Dispatchers.IO) 
    Thread.sleep(length)

如果您尝试将其发送到Dispatchers.Main,您将再次收到警告,因为编译器认为主线程上的任何阻塞代码都不正确。

这应该让您了解挂起函数应该如何操作,但请记住,编译器并不总是将方法调用识别为阻塞。

【讨论】:

我开始了解它,非常感谢您抽出时间帮助我理解。我深入研究了delay 的源代码,发现它最终在HandlerContext 上调用了scheduleResumeAfterDelay,它确实使用了对handler.postDelayed(block, timeMillis) 的调用。那么我认为线程被进一步使用以实际执行延迟是否正确? IE。在某些时候,某处有一个线程被启动并休眠以进行延迟?还是有比协程实现中使用的线程更低级别的机制? 我还没有深入了解 Handler 的排队消息是否依赖于某些后台线程休眠,或者 Java 是否提供了更直接的方法来做到这一点。如果您使用 withContext 挂起您的函数,则 Kotlin 的调度程序正在管理线程池中的线程。如果您使用suspendCancellableCoroutine,则您将控制执行背景工作,但是您认为合适,直到您恢复协程的继续。 suspendCancellableCoroutine 常用于将基于回调的代码转换为挂起函数。 所以看起来delay 使用不同的方式发布延迟,具体取决于它所在的调度程序。请注意,主要调度程序在不同平台上的定义不同。 android 的 Dispatcher.Main 与 Handlers 和 Loopers 一起管理其线程池。 Swing 和 Javafx 有不同的 Dispatcher.Main 实现。 我明白了。我想我现在已经钻得足够深,可以开始用协程做一些富有成效的事情了。我认为我不需要知道它的基本实现细节来使用它们,但它确实有助于粗略地了解这些东西是如何工作的(无论如何对我来说)。【参考方案2】:

将您现有的直觉与协程世界联系起来的最佳方式是进行这种心理映射:而在经典世界中,操作系统将线程调度到 CPU 内核(根据需要抢先挂起它们),调度程序将协程调度到线程.协程不能被抢先挂起,这就是协程并发的协作性发挥作用的地方。

考虑到这一点:

因为我在由Dispatchers.Main 支持的协程上下文中运行它,所以我希望看到与使用Thread.sleep 相同的结果。

delay(delayTime) 只是暂停协程并安排其稍后恢复delayTime。因此,您应该期望看到与 Thread.sleep 截然不同的结果,后者从不暂停协程并一直占用其线程,这种情况类似于 Thread.sleep() 不允许 CPU 内核运行其他东西,但会忙等待。

【讨论】:

以上是关于Kotlin 协程 - 延迟,它是如何工作的?的主要内容,如果未能解决你的问题,请参考以下文章

深入kotlin- 协程

如何在主函数中恢复协程的执行?

如何在 Kotlin Multiplatform(纯 kotlin)中进行延迟

Kotlin Multiplatform Mobile:Ktor - 如何在 Kotlin Native(iOS)中取消活动协程(网络请求、后台工作)?

深潜Koltin协程:挂起是如何工作的?

Kotlin协程之flow工作原理