换种方式理解协程

前言

日常开发中。不知道有没有人像我一样,第一次学习协程时,搜索文章发现大多数都是列举协程与线程的优势,然后是一堆Kotlin API的使用,接着是一堆概念,比如非阻塞挂起、挂起、恢复,以及保存恢复现场等等;在这样稀里糊涂的情况下,我学习并使用了协程。如果你现在也有类似困惑,那么看完这篇文章,你就能明白这些东西,真正理解协程到底是什么。 对于我们来说,要真正理解协程,就必须先能够理解它的本质。协程并不是一种新技术,它是一种概念,一种设计模式,可以在任何语言中实现,比如Kotlin、Java或者C++。它的本质是异步编程,针对复杂的异步任务,它可以帮助我们构建出高可扩展的程序。 协程是一种异步程序的设计模式。当然它不止设计模式这么简单。

"同步写法" VS "异步写法"

在Android开发中,最常用到异步编程的地方便是网络请求,下面便是一个通过网络请求获取新闻数据并刷新ui的例子。下面我给出两种写法。这里我分别称之为"异步写法 "和"同步写法"。

kotlin 复制代码
## 异步写法。
fun getNews() {
        val getNewsRequest: Request = Request.Builder()
            .url("<http://www.apinews.com/api/news>")
            .get()
            .build()
        okhttpClient.newCall(getNewsRequest).enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                //// notify ui changed.
                findViewById<TextView>(R.id.tvContent).text = response.body.string()
            }

            override fun onFailure(call: Call, e: okio.IOException) {

            }
        })
    }
kotlin 复制代码
## 同步写法。
fun getNews() {
        val getNewsRequest: Request = Request.Builder()
            .url("<http://www.apinews.com/api/news>")
            .get()
            .build()
        val response = okhttpClient.newCall(getNewsRequest).execute()
        findViewById<TextView>(R.id.tvContent).text = response.body.string()
    }

在阅读性上,能明显看出同步写法可阅读性更高。但是为什么同步写法的阅读性更高呢?

为什么"同步写法"的阅读性更高?

我把"异步写法"和"同步写法"在代码上表达的逻辑意思作成如下逻辑图。

同步写法

异步写法

可以发现"同步写法"在逻辑上更加线性,没有什么分支。你可以按顺序从头到尾阅读代码,而不需要跳转到回调函数或其他代码块。上述场景示例,还不够明显表现出"同步写法"的优势。我再举一个更复杂场景的例子,假设有个更换头像的场景,则需要先上传图片到服务器,获取上传到服务器位置的连接。然后再发起头像变更请求,把前面拿到头像图片链接做为请求参数。这样就是两层嵌套。看如下代码。

kotlin 复制代码
    # 异步写法
    val uploadPhotoRequest: Request = Request.Builder()
        .url("<http://www.apinews.com/api/uploadPhoto>")
        .post(uploadImgBody)
        .build()
    /// 上传头像图片
    okhttpClient.newCall(uploadPhotoRequest).enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            /// 修改头像
            val avatarJsonBody = ....
            val modifyAvatarRequest: Request = Builder()
                .url("<http://www.apinews.com/api/editProfile>")
                .post(avatarJsonBody)
                .build()
            okhttpClient.newCall(modifyAvatarRequest).enqueue(object : Callback {

                override fun onResponse(call: Call, response: Response) {
                    // notify ui changed.
                }

                override fun onFailure(call: Call, e: IOException) {
                    // process exception
                }

            })
        }

        override fun onFailure(call: Call, e: okio.IOException) {

        }
    })
kotlin 复制代码
    /// 上传头像图片
    val uploadPhotoRequest: Request = Request.Builder()
        .url("<http://www.apinews.com/api/uploadPhoto>")
        .post(uploadImgBody)
        .build()
    okhttpClient.newCall(uploadPhotoRequest).execute()

    /// 修改头像
    val avatarJsonBody = ....
    val modifyAvatarRequest: Request = Builder()
              .url("<http://www.apinews.com/api/editProfile>")
              .post(avatarJsonBody)
              .build()
    okhttpClient.newCall(modifyAvatarRequest).execute()

可以发现,场景变复杂后,"异步写法"带来更多层级的嵌套。而更多层级的嵌套会带来几个问题:

  1. 作用域混淆:
  • 在多层嵌套的代码中,相同的变量名可能会出现在不同的作用域中。这可能导致开发者难以确定在特定点上哪个作用域的变量正在被访问或修改。

举个例子:

kotlin 复制代码
    import okhttp3.*

    fun main() {
        val client = OkHttpClient()

        // 第一个请求:获取用户信息
        val request = Request.Builder()
            .url("<https://api.example.com/users/123>")
            .build()

        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                e.printStackTrace()
            }

            override fun onResponse(call: Call, response: Response) {
                val result = response.body?.string() ?: return
                val userId = parseUserId(result)  // 假设 parseUserId 是一个解析用户 ID 的函数

                // 第二个请求:使用用户 ID 获取订单列表
                val request = Request.Builder()
                    .url("<https://api.example.com/users/$userId/orders>")
                    .build()

                client.newCall(request).enqueue(object : Callback {
                    override fun onFailure(call: Call, e: IOException) {
                        e.printStackTrace()
                    }

                    override fun onResponse(call: Call, response: Response) {
                        val result = response.body?.string() ?: return

                        // 在此处,我们有三个名为"result"的变量,它们存在于不同的作用域中。
                        // 这可能会使代码阅读和维护变得困难,并可能导致作用域混淆。
                        processOrders(result)  // 假设 processOrders 是一个处理订单数据的函数
                    }
                })
            }
        })
    }

    // ... 其他函数定义,例如 parseUserId 和 processOrders

在这个示例中,我们有三个名为 result 的变量:一个在第一个请求的 onResponse 回调中,一个在第二个请求的 onResponse 回调中,以及 request 变量也被重复使用。这种多层嵌套和相同变量名的使用可能会导致作用域混淆,使得在阅读或修改代码时可能不清楚哪个 result 变量正在被引用,以及每个 result 变量的确切作用域是什么。这样的代码结构可能会增加出错的可能性,特别是在更复杂的代码和多个嵌套级别的情况下。

  1. 逻辑分散
  • 嵌套结构可能会导致代码逻辑分散在多个层级中。这使得跟踪代码的逻辑流程变得困难,特别是当嵌套层数较多时。
  1. 缩进和格式化
  • 嵌套结构需要额外的缩进和格式化,这可能会影响代码的视觉清晰度。过多的缩进可能会导致代码难以阅读,特别是在窄屏幕或小字体的情况下。
  1. 代码修改困难
  • 嵌套结构使得在不影响其他代码的情况下修改或扩展代码变得困难。每当需要修改嵌套结构中的一部分代码时,都可能需要理解和修改多个层级的逻辑。

所以如果需要高的阅读性,应该选择"同步写法"。但是同步写法的话,因为网络请求是耗时操作,所以会阻塞主线程。所以能不能有一种同步非阻塞的形式呢?

非阻塞挂起

设想能不能有一种这样的形式,"同步写法"中发起"网络请求"这个操作自动调度到子线程去,执行成功完后,恢复调度到主线程继续执行呢。就像下面这种形式。通过注解标识方法是由哪个线程调度执行。这样就不需要嵌套了。在执行 getNews 时,getNews 方法都由子线程调度执行,执行完后,回到 main 方法的findViewById这一行,由主线程继续执行刷新UI内容变化这一系列操作。

设想通过给耗时高的代码(比如网络IO、文件IO等)标记为子线程进行,而UI代码标记为主线程执行。像如下通过注解进行标注。

kotlin 复制代码
data class News(val date: String, val title: String, val content: String)

@Dispatchers.NewThread
fun getNews(): News {
    /// .... 耗时操作
    return News(date = "2023-2-11", title = "Good News", content = "content")
}

@Dispatchers.MainThread
overrie fun onCreate(savedInstanceState: Bundle?) {
    val news = await getNews ()
    /// 通用ui变化,如Image.setResource
    findViewById<TextView>(R.id.tvContent).text = news.content()
}

而代码运行的时候,onCreate 方法在执行到耗时方法时,让其到子线程运行,此时 onCreate 方法中调用 getNews 的后续部分相当于 getNews 成功后主线程需执行的回调,就是 Callback 。可以通过将这个回调作为一个函数对象,插入到主线程循环事件(Android 中是 Handler )中执行,这样就解决了阻塞了这个问题。而这就是协程所干的事之一。所以非阻塞挂起 算不上协程优势,因为这东西就是基于线程和主线程循环事件出来的,不是新玩意。而协程这种同步写法而不阻塞当前线程的特性才是它的优势所在。

将这段代码改用成Kotlin协程的形式,如下。

kotlin 复制代码
    package com.example.myapplication

    import androidx.appcompat.app.AppCompatActivity
    import android.os.Bundle
    import android.widget.TextView
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.withContext
    import androidx.lifecycle.lifecycleScope
    import kotlinx.coroutines.launch

    class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            lifecycleScope.launch {
                val news = getNews()
                // 通用ui变化,如Image.setResource
                findViewById<TextView>(R.id.tvContent).text = news.content
            }
        }
    }

    data class News(val date: String, val title: String, val content: String)

    suspend fun getNews(): News = withContext(Dispatchers.IO) {
        /// .... 耗时操作
        News(date = "2023-2-11", title = "Good News", content = "content")
    }

这里 getNews 方法通过 withContext (Dispatchers.IO )将方法调度到IO线程上执行。而执行完后到 main 方法中则由原本调用 main 方法的线程执行,也就是主线程。而 main 方法这里在调用 getNews 时,而这里等待 getNews 执行完成,并不会阻塞住主线程。所以又称作"非阻塞挂起"。"挂起"的是 main 方法,等待 getNews 执行完后,进行后续操作。这个过程又称之为"保存恢复现场"。"保存"的是 main 方法恢复后要执行的位置,也就是 findViewById 那段。

线程开销

一个进程可以有多个线程。而CPU同时能运行多少个线程取决于CPU的核心数。而线程的数量是可以大于CPU核心数。而线程数大于CPU核心数时,CPU执行当前线程一会时,停止执行该线程,然后会运行其他线程一会儿,然后又重新执行之前停止的线程。这种轮转执行的机制为时间片机制。不清楚可以先行百度了解下。当CPU从一个线程切换到另一个线程时,它需要保存当前线程的上下文,以便下次能正确恢复该线程恢复执行。

那需要保存的上下文信息有哪些呢?

假设线程执行一个"两数之和"方法,执行到for循环迭代第二次的时候。线程被停止执行,方法的后续执行等待线程下次被恢复执行的时候,此时如果线程要被正确恢复执行时需要哪些信息呢?

ini 复制代码
class Solution {
    public int[] twoSum(int[] nums, int target) {
        Map<Integer, Integer> hashtable = new HashMap<Integer, Integer>();
        for (int i = 0; i < nums.length; ++i) {
            if (hashtable.containsKey(target - nums[i])) {
                return new int[]{hashtable.get(target - nums[i]), i};
            }
            hashtable.put(nums[i], i);
        }
        return new int[0];
    }
}

1、当前方法执行的位置。即 程序计数器

2、迭代循环中i变量的值,hashTable的值。以及方法入参numstarget 这些信息。

总结下就是:

  1. 程序计数器:确实,为了正确恢复线程的执行,我们需要知道它在哪里停止。这通常由程序计数器或指令指针保存。
  2. 局部变量ihashtable 是此方法的局部变量。它们的状态需要被保存,以便线程在恢复时能够正确地从中断处继续执行。
  3. 方法参数numstarget 作为方法参数,其值在方法的整个执行过程中都不会发生改变。但是,为了保持方法执行的上下文,这些参数的当前值也需要被保存。
  4. 栈帧:当方法被调用时,会在调用堆栈上创建一个新的栈帧。这个栈帧包含了方法的局部变量、参数和返回地址。为了正确恢复线程的执行,保存和恢复栈帧是必要的。

而这些都是线程需要保存的上下文信息。在下次恢复执行的时候加载这些上下文信息来恢复线程的执行。

而这就带来了上下文切换的开销。

上下文切换的开销,可以比喻为你正在工作室里专心致志地绘画一幅画作。当你深入其中,完全沉浸在创作的过程中,突然电话响起,告诉你需要紧急处理一个突发事件。为了处理这个突发事件,你必须放下画笔,先记住你当前画到哪一部分,然后走到另一个房间去处理紧急情况。

处理完紧急情况后,你返回工作室。但在开始绘画前,你需要重新调整心态,回想起刚才中断时画到哪里,考虑接下来要如何进行。这个从专心绘画到处理紧急事件,再回到绘画的过程,就是一次"上下文切换"。这中间所花费的时间和精力,比如重新找回之前的创作状态、回忆上次的进度等,都是上下文切换的"开销"。

如何减少上下文开销呢?

使用线程池可以减少线程的创建和销毁,从而减少相关的开销和线程切换的次数。线程池通过复用已存在的线程,避免了频繁的线程创建。可以将这个过程比喻为去小吃店吃手抓饼的经历。当烤炉还未预热时,需要花费时间来加热。但一旦烤炉热了,就可以持续用来烤手抓饼,而不是每次烤完一个手抓饼就关火。这样,如果短时间内有其他顾客再次下单,店家就无需再次等待烤炉预热,直接烤饼即可。这与线程池中线程的复用原理相似。

而在Kotlin协程也利用了线程池。在Kotlin协程中默认的Scheduler就是线程池。

Kotlin的协程使用了 Dispatchers 来管理其执行线程。以下是Kotlin协程中常见的几种 Dispatchers

  1. Dispatchers.Default:它用于CPU密集型任务,并使用了固定大小的线程池。线程的数量默认等于机器的CPU数量。
  2. Dispatchers.IO:它用于I/O密集型任务,如文件、网络操作等,并使用了一个可变大小的线程池。
  3. Dispatchers.Main:它用于UI操作,并且通常与Android主线程相关联。
  4. Dispatchers.Unconfined :这是一个特殊的调度器,它执行在当前线程直到第一个 yield 或者挂起点。

这些调度器中的大部分都使用线程池来复用线程,从而避免频繁的线程创建和销毁,减少上下文切换开销。

而协程对比传统的线程池,协程提供了更高级的抽象。在使用协程时,开发者无需直接管理线程池的细节,如线程的创建、销毁或复用。相反,他们只需选择合适的调度器(如 Dispatchers.IODispatchers.Default),然后将协程的执行委托给这些调度器。Kotlin协程框架会自动处理与线程池相关的所有细节,确保资源的高效使用,同时为开发者提供简洁的编程模型。这不仅简化了并发编程,还提高了代码的可读性和可维护性。

总结

协程带来了"同步写法 "。而"同步写法 "对比"异步写法"代码的阅读性更高。同步写法让代码在逻辑上更加线性,可以按顺序从头到尾阅读代码,而不需要跳转到回调函数或其他代码块。减少了嵌套,从而改善作用域混淆,逻辑分散,代码修改困难的等问题。而协程让开发者能使用同步写法编写异步代码,而不阻塞当前线程。大大提升了编写异步程序代码的阅读性。

协程对比传统的线程池,协程提供了更高级的抽象。在使用协程时,开发者无需直接管理线程池的细节,如线程的创建、销毁或复用。

相关推荐
天空中的野鸟35 分钟前
Android音频采集
android·音视频
代码吐槽菌2 小时前
基于SSM的毕业论文管理系统【附源码】
java·开发语言·数据库·后端·ssm
豌豆花下猫2 小时前
Python 潮流周刊#78:async/await 是糟糕的设计(摘要)
后端·python·ai
小白也想学C2 小时前
Android 功耗分析(底层篇)
android·功耗
YMWM_2 小时前
第一章 Go语言简介
开发语言·后端·golang
曙曙学编程2 小时前
初级数据结构——树
android·java·数据结构
码蜂窝编程官方2 小时前
【含开题报告+文档+PPT+源码】基于SpringBoot+Vue的虎鲸旅游攻略网的设计与实现
java·vue.js·spring boot·后端·spring·旅游
hummhumm2 小时前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
J老熊3 小时前
JavaFX:简介、使用场景、常见问题及对比其他框架分析
java·开发语言·后端·面试·系统架构·软件工程
AuroraI'ncoding3 小时前
时间请求参数、响应
java·后端·spring