深入理解协程的运作机制 —— 调度、挂起与性能

launchwithContext 的抉择

对于线程切换的场景,我们之前使用的工具是 launch

launch 的特性是启动一个并发的新协程,例如:

kotlin 复制代码
CoroutineScope(context = Dispatchers.Main).launch {
    println("1. outer launch - Thread: ${Thread.currentThread().name}")
    launch(context = Dispatchers.IO) {
        // 模拟一个阻塞线程的耗时操作
        Thread.sleep(2000)
        println("2. inner launch - Thread: ${Thread.currentThread().name}")
    }
    println("3. outer launch - Thread: ${Thread.currentThread().name}")
}

以上代码的运行结果将会是:

less 复制代码
1. outer launch - Thread: main
3. outer launch - Thread: main
2. inner launch - Thread: DefaultDispatcher-worker-1

可以看到:先打印的 3,后打印的 2。

launch 会创建一个新的子协程去执行任务,但它并不会挂起当前父协程的执行。也就是说,父协程会立刻继续往下执行,不会等待子协程完成。

我们使用 Thread.sleep() 来模拟阻塞线程的耗时操作,因为它确实会让线程阻塞。而 delay() 是非阻塞的挂起函数,只会暂停当前协程,让当前协程脱离所在线程,而不会阻塞线程。

如果我们需要在后台完成任务,然后等待它的结果再继续执行 ,就需要用到另一个线程切换的工具------withContext

kotlin 复制代码
CoroutineScope(context = Dispatchers.Main).launch {
    println("1. launch block.. - Thread: ${Thread.currentThread().name}")
    withContext(Dispatchers.IO) {
        Thread.sleep(2000) // 模拟耗时操作
        println("2. withContext block... - Thread: ${Thread.currentThread().name}")
    }
    println("3. launch block.. - Thread: ${Thread.currentThread().name}")
}

上述代码的运行结果将会是:

less 复制代码
1. launch block.. - Thread: main
2. withContext block... - Thread: DefaultDispatcher-worker-1
3. launch block.. - Thread: main

withContext 能实现这种串行流程,是因为它是一个挂起函数。它挂起了当前协程,在指定的调度器(其他线程)中执行代码。等到代码块执行完成后,会自动切回原来的线程,恢复之前被挂起的协程,所以代码才能这样一步步顺序执行。

挂起函数的介绍在前一篇博客中:初识协程: 为什么需要它以及如何启动第一个协程

withContext 不仅能保证串行执行,还能返回结果,让我们告别回调地狱。

比如:

kotlin 复制代码
CoroutineScope(Dispatchers.Main).launch {
    val userData = withContext(Dispatchers.IO) {
        Thread.sleep(2000) // 模拟网络请求
        "User-ID: 12345, Name: Jack" // 要返回的结果
    }

    // 更新 UI
    println("data is: $userData")
}

获取数据时,并不需要回调,这就是 withContext 的强大之处。

自定义挂起函数

withContext 的逻辑变得复杂时,我们可以将其封装为挂起函数。

suspend 关键字的含义

给一个普通函数加上 suspend 关键字,那么这个函数就变为了挂起函数。

那么,我们在什么时候会需要定义一个挂起函数?

其实,我们并不会主动去定义。只有在函数中需要调用挂起函数时,我们才不得不给这个函数加上 suspend 关键字,让它也变为挂起函数,从而获得调用其他挂起函数的能力。

suspend 的代价

把函数定义为挂起函数,也限制了它的使用范围,让它只能在协程或是另一个挂起函数中被调用。所以,当你为一个函数增加了 suspend 关键字,而函数内部却并没有调用任何挂起函数,IDE 会提示我们移除这个多余的关键字。

实践

封装前的代码:

kotlin 复制代码
lifecycleScope.launch {
    val data = withContext(Dispatchers.IO) {
        // 从网络获取数据
        Thread.sleep(2000)
        "number"
    }

    val processedData = withContext(Dispatchers.Default) {
        // 处理数据
        data.random() + data + data.random()
    }

    // 在主线程使用数据
    println("used data is $processedData")
}

封装后:

kotlin 复制代码
// 获取数据
suspend fun fetchData(): String = withContext(Dispatchers.IO) {
    // 从网络获取数据
    Thread.sleep(2000)
    "number"
}

// 处理数据
suspend fun processData(data: String): String = withContext(Dispatchers.Default) {
    // 处理数据
    data.random() + data + data.random()
}


lifecycleScope.launch {
    val rawData = fetchData()
    val processedData = processData(rawData)
    // 使用数据
    println("used data is $processedData")
}

挂起函数的设计

在上述的封装操作中,会有一个问题:withContext 调度器需不需要被封装进来?

其实是需要的,我们应该将耗时操作和它对应的 withContext 调度器一起被封装进挂起函数中。

这是因为一个操作的类型是固定的,换句话说,一个耗时操作是计算密集型还是IO密集型,是内在的属性,调用者改变不了。数据处理就应该放在 Dispatchers.Default 中,网络请求、文件处理等就应该放在 Dispatchers.IO 中,并不随着调用处的不同而改变。

这里并不用担心线程切换的性能损耗:如果当前已经是目标调度器,withContext 会进行优化,跳过线程切换,直接在当前线程执行代码。

这种封装模式,本质上是一种责任的转移

它将保证性能问题的责任从函数的使用者转移到了函数的作者身上,这就从根源避免了因在错误的线程调用函数而导致的性能问题。

而在传统的线程模型中,函数的作者只能通过文档注释来提醒使用者:该方法为耗时操作,需要在后台线程执行。

挂起函数的背后

可以先看我的这篇博客:Android 多线程:理解 Handler 与 Looper 机制,以了解 Android 框架的主线程。

为什么挂起函数不会阻塞调用它的主线程?

kotlin 复制代码
lifecycleScope.launch {
    val data = fetchData() // fetchData 是挂起函数
    myTextView.text = data
}

其实,上述看似同步的协程代码,在编译时会被 Kotlin 编译器改造:以 suspend 函数的调用点为界,将函数分为多个部分,然后重组成一个状态机。

状态机的执行流程类似于回调结构:

java 复制代码
// 伪代码
dispatcher.switchToBackground(() -> {
    // fetchData() 挂起函数内部的阻塞性工作
    String data = fetchDataBlocking();

    // 挂起点之后恢复执行的代码
    mainThreadHandler.post(() -> {
        myTextView.text = data;
    });
});

为什么挂起函数不卡主线程,是因为:

  • 挂起 Suspend :协程在主线程启动,将耗时操作提交给后台线程池后,当前协程就会暂停并返回。主线程会得到释放,可以继续无限循环,保持界面响应。

  • 恢复 Resume:当耗时操作完成后,协程会将挂起点之后的代码作为任务,提交到主线程的任务队列中。之后,主线程会取出这个任务并执行,UI得到更新。

所以,主线程根本没有等待挂起函数的执行。在协程被挂起的瞬间,主线程就离开了,后续需要在主线程更新UI时,只是提交了一个新任务到主线程的执行队列中而已。

相关推荐
沐怡旸3 小时前
【Android】Android系统体系结构
android
namehu3 小时前
React Native 应用性能分析与优化不完全指南
android·react native·ios
xqlily4 小时前
Kotlin:现代编程语言的革新者
android·开发语言·kotlin
HelloBan4 小时前
如何正确去掉SeekBar的Thumb和按压效果
android
木易 士心4 小时前
Android EventBus 源码解析:设计模式、原理与实现
android
ClassOps4 小时前
源码阅读 LeakCanary
android
用户2018792831674 小时前
为啥现在 Android App 不用手动搞 MultiDex 了?
android
fouryears_234175 小时前
如何将Vue 项目转换为 Android App(使用Capacitor)
android·前端·vue.js
消失的旧时光-19435 小时前
人脸跟随 ( Channel 实现(缓存5条数据 + 2度过滤 + 平滑移动))
android·java·开发语言·kotlin