一文帮你快速理解协程使用模型

news2024/11/15 15:54:42

本文同步发表在个人博客上 0xforee’s blog

前言

A coroutine is an instance of suspendable computation.

It is conceptually similar to a thread, in the sense that it takes a block of code to run that works concurrently with the rest of the code.

However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one.

以上这段话出自官网。介绍协程的基本概念和一些特性。
其中提到了可挂起,计算实例,线程相似性,代码块,并行,不绑定特定线程,在一个线程挂起在另一个线程恢复等关键字。

我们今天就围绕这些基本概念和关键字来探索一下协程的使用模型

请放心,本文没有晦涩难懂的源码解析,也没有深奥的概念,只有大量的类比帮你快速理解协程。

正文

简单示例

这大概是最简单的协程示例了。

GlobalScope.launch{   
    print("hello coroutine") 
}
案例 1

你可以将它加入到任意的 kotlin 代码中运行看看。

这段代码将 print 传递给了 Scope 的 launch 方法,标志着启动了一个协程来运行。那为什么使用协程要通过这样的调用方式呢?

要解答以上这个问题,就涉及到我们要讲的第一个概念 Scope

什么是 Scope?

Scope 是用来区分协程和非协程的作用域。

那为什么要加 Scope 呢?不加不行吗?就像我们平常写代码那样,就不需要加任何东西呀。

那是因为我们平常写的代码,都是跑在线程上的,不用加任何作用域限定,也会跑在一个特殊的线程(主线程)中。线程是操作系统层级的概念,任意的代码函数都会运行在线程上,所以我们可以不用加任何限定作用域。

而协程不行,协程是语言层级的概念,无法做到像线程那样成为所有函数的运行基础,所以我们需要在语言层面标识哪些是跑在协程的,哪些不是。

而 Scope 的作用就是用来标识和限定哪些代码跑在协程中。

协程框架提供了一些方式,可以快速的帮我们创建好 Scope,来包裹我们要跑在协程上的那些代码块。官方称之为 Scope Builder

常用的 Scope Builder 有这些

runBlocking{} 
GlobalScope{} 
coroutineScope{} 
withContext{}

如果你在代码遇到这些关键字,就可以知道这里有一个协程了。

注意,默认情况下,定义好一个 Scope 后,协程框架会启动协程来执行代码块,你可以通过参数控制这种默认行为。

另外,IDE 也会在有 Scope 的地方提示你:

在这里插入图片描述

图中的那个灰色的提示: this:CoroutineScope 标识了当前有个 Scope 存在。

上边我们也提到,协程并没有突破线程这一束缚,还是运行在线程上的,但同时协程却又被称为轻量级线程,这又是为什么呢?

我们先来看看操作系统是怎么定义和描述线程的。

内核线程 or 用户线程

操作系统为了系统安全,将程序运行的位置简单的分为了内核空间和用户空间,内核程序在内核空间中运行,用户程序在用户空间中运行,二者互不影响,通过系统调用来切换空间。

内核线程:指的是内核空间内的线程实体,是内核可以感知,操作系统调度的基本单位
用户线程:指的是用户空间内对应内核线程的实体,内核无法感知,用户程序可以通过这些实体来间接控制内核线程。

操作系统关于线程实现历史中,涌现了三种模型:

  1. 1:1(一个用户线程对应一个内核线程)
  2. M:1(多个用户线程对应一个内核线程)
  3. M:N(多个用户线程对应多个内核线程)

在这里插入图片描述

线程实现模型 1:1 示意图(来源网络)

在这里插入图片描述

线程实现模型 M:N 示意图(来源网络)

之所以出现这几种调度模型,是因为内核线程的使用是有一定成本的,每次切换线程都要切换线程上下文和保存寄存器状态,切换 CPU 上下文等。如果我们实现了在用户空间切换线程,可以省略和缩短 CPU 和内核空间切换上下文的开销。

既然有这种好处,为什么我们不使用这种模型呢?因为越是灵活的调度对应的是越复杂的实现。Linux 线程的实现模型已经踩过这个坑了,从最开始的 1:1 模型切换到 M:N 模型,最后又切换到 1:1 模型,就是因为实现太过复杂,导致效率反而下降。

有兴趣的可以参考这些文章:

https://www.linuxidc.com/Linux/2016-01/127559.htm

https://zh.wikipedia.org/wiki/Native_POSIX_Thread_Library

回过头来,协程其实和这种用户线程的实现理念是一样的。目的就是为了减少内核级上下文的切换导致的开销,提升效率。当然,协程还是跑在线程上的,完全不需要系统内核的参与调度,所以它比用户级线程还要更轻量一点,这也是它被称作轻量级线程的原因。

接下来我们就来看看协程是如何运行在线程上的。

协程运行在线程上

协程跑在线程上,我们可以类比为 Runnable 运行在线程上。

我们之前说过,协程类似于线程用户态模型, 代码块的调度都是发生在用户态的,是通过程序实现的。
在 Android 中有一个很著名的线程通信框架,也是通过程序实现代码块的调度的。

Handler

没错,就是我们耳熟能详的 Handler

Handler 内部实现了一个消息队列 MessageQueue,Handler 会不断的从这个 MessageQueue 中获取消息,然后在对应的线程上执行。

而协程执行的过程,其实和消息队列差不多。

比如以下的协程执行

GlobalScope.launch{   
    print("hello coroutine") 
}

可以类比到 Handler 中

// 发送普通 Handler 消息 
handler.post{ 
  print("hello coroutine")
}
案例 2

所以我们也可以将协程称之为一个任务调度框架。

但协程和 Handler 的不同点就在于协程底层并不是运行在某个线程上的,而是可能在多个线程上。
既然可能是多个线程,那么协程怎么知道我哪些代码要运行在 A 线程,哪些代码要运行在 B 线程呢?
换句话说,他是怎么调度的呢?什么时候会调度呢?

这就涉及到协程的另一个关键字 suspend

Suspend 做了什么?

我们将案例 2 扩展一下,思考这么一段代码

// 发送普通Handler 的延迟消息
handler.post{
  print("hello coroutine")
}
handler.postDelayed(
  {
    print("hello world")
  },300
)

然后我们看看协程的实现:

// 等价于协程
GlobalScope.launch{
  print("hello coroutine")
  delay(300)
  print("hello world")
}
案例 3

其中的 delay 是一个 suspend 函数。源码如下:

在这里插入图片描述

其中 suspend 是表示挂起的意思。那么这个挂起函数起到了什么作用呢?

我们知道,指定某个线程执行一段代码块,会有两个结果:

  1. 代码块中包含普通的代码,代码内容被全部执行完成,线程结束。
  2. 代码块中包含阻塞线程的代码,线程在这个阻塞点上挂起,直到线程被重新唤醒继续执行,线程结束。在线程阻塞期间,线程无法再执行其他代码。

而协程中并没有提供阻塞能力,取而代之的是挂起。挂起是指在某个线程上执行的协程被停止调度,协程框架转而去调度其他协程到这个线程上执行,这样就避免了线程的切换,从而提升当前线程的使用率。

那么,有 suspend 就一定会被挂起吗?
当然不是,带 suspend 关键字的函数被称为挂起函数。挂起函数仅仅标识当前函数内可能有挂起点,协程真正会挂起的位置被称为挂起点。

我们将协程可以执行的代码块分为两种:普通函数(普通代码块)和挂起函数(有挂起点的代码块)
普通函数:就是指一段串行的代码,如果开始执行,就一定会被在当前线程上顺序执行完毕。
挂起函数:包含了普通代码块和可能穿插的挂起点。普通代码块如上规则,而挂起点就是指以下关键字

delay(time), await 等

遇到挂起点,协程就会在这个点上挂起,这时其他满足条件的协程就会被调度到这个协程底层的线程上继续执行。直到当前协程恢复的条件满足,才会被继续调度执行。

我们用一个实际的例子来看看,协程和线程中阻塞和挂起的区别

线程:

Thread{
  println("${Date().time}: ${Thread.currentThread().name} hello")
  Thread.sleep(1400)
  println("${Date().time}: ${Thread.currentThread().name} world")
  println("${Date().time}: ${Thread.currentThread().name} an ")
  println("${Date().time}: ${Thread.currentThread().name} another")
}.start()

协程:

这段代码是启动了三个协程,并让他们都在同一个线程上调度

注意:这里将调度器指向了一个单线程池。

val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
runBlocking {
  CoroutineScope(dispatcher).launch{
    println("${Date().time}: ${Thread.currentThread().name} hello")
    delay(1400)
    println("${Date().time}: ${Thread.currentThread().name} world")
  }

  CoroutineScope(dispatcher).launch{
    println("${Date().time}: ${Thread.currentThread().name} an ")
  }

  CoroutineScope(dispatcher).launch{
    println("${Date().time}: ${Thread.currentThread().name} another")
  }
}

我们停下来先思考一下他们的输出分别是什么?

线程输出结果:

1690337539395: Thread-0 hello
1690337540800: Thread-0 world
1690337540800: Thread-0 an 
1690337540800: Thread-0 another

协程的输出结果:

VM options 设置-Dkotlinx.coroutines.debug=on 即可开启协程名称

1690337474719: pool-1-thread-1 @coroutine#2 hello
1690337474725: pool-1-thread-1 @coroutine#3 an 
1690337474725: pool-1-thread-1 @coroutine#4 another
1690337476132: pool-1-thread-1 @coroutine#2 world

从线程的输出结果中,我们可以看到,整个线程 Thread-0 被 Thread.sleep() 这行代码阻塞,无法继续往下执行,直到1.4s 后被唤醒了才继续往下执行

而协程就不一样了,2 号协程因为 delay 而挂起,但是并没有阻塞他所在的 Thread-1,协程框架会继续调度其他可执行的协程跑在 Thread-1 上,所以我们可以看到紧接着 3,4 号协程被调度执行。delay 1.4s 后,2 号协程被唤醒,继续往下执行。

在这里插入图片描述

动图展示

有些人可能还会有一个疑问,挂起操作会导致协程先执行到后边的代码吗?
比如上边的代码中,协程 2 号挂起之后,“world”的输出会被调度到其他线程上,从而比 “an” 或者 ”another” 先输出吗?

答案是不会的。

同一个协程内会遵循 先行发生原则

学过线程(Thread)的肯定了解这个概念。其中有一条:

程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。

简单来说就是单个线程内部代码是串行的,控制流前边的代码会先行发生于后边的代码。
而协程也有这样的规则,同一个协程内会遵循先行发生原则,即输出 world 之前,协程 2 号会被 delay 挂起暂停执行。
上边的例子我们解释了协程挂起这个特性究竟是怎么回事,下边我们再看看协程在面对多线程调度时的另一个特性。

我们将协程代码稍微修改一下,并且让他调度在多线程池上呢?代码执行结果会有什么不同吗?

val dispatcher = Dispatchers.IO // 替换为多线程调度器
runBlocking {
  CoroutineScope(dispatcher).launch{
    println("${Date().time}: ${Thread.currentThread().name} hello")
    delay(1400)
    println("${Date().time}: ${Thread.currentThread().name} world")
  }

  CoroutineScope(dispatcher).launch{
    delay(300) // 注意这里也有一个 delay 挂起
    println("${Date().time}: ${Thread.currentThread().name} an")
    println("${Date().time}: ${Thread.currentThread().name} another")
  }
}

我们运行以上代码,输出 1:

1690338849816: DefaultDispatcher-worker-2 @coroutine#2 hello
1690338850124: DefaultDispatcher-worker-1 @coroutine#3 an
1690338850124: DefaultDispatcher-worker-1 @coroutine#3 another
1690338851224: DefaultDispatcher-worker-1 @coroutine#2 world

我们再运行一遍呢

输出 2:

1690338877016: DefaultDispatcher-worker-1 @coroutine#2 hello
1690338877327: DefaultDispatcher-worker-1 @coroutine#3 an
1690338877327: DefaultDispatcher-worker-1 @coroutine#3 another
1690338878425: DefaultDispatcher-worker-1 @coroutine#2 world

我们会发现字符串输出顺序一致,但是指向的线程却不一样了。

输出 1:2 号协程在 2 号线程上输出 hello 之后挂起,然后在 1 号线程恢复执行,输出 world
输出 2:2 号协程在 1 号线程上输出 hello 之后挂起,然后在 1 号线程恢复执行,输出 world

这说明:同一个协程并不一定总是会被调度在同一个线程上的。但它依然会保证协程内部的先行发生原则。

那么官网中提到的协程可能会在一个线程上挂起,在另一个线上上恢复的特性我们也就清楚了。
了解了挂起这个神奇的操作之后,我们再来拓展一下,看看挂起函数和普通函数的一些使用说明。

挂起函数不能被普通函数调用

挂起函数是指包含挂起点,拥有挂起能力的函数。但是挂起这个特性属于是 kt 新增的黑魔法,普通的函数无法正确被协程框架所识别和使用挂起能力。所以挂起函数不能被普通函数调用,不然遇到挂起点,就无能无力了。

所以挂起函数只能在协程作用域中使用,也就是我们上边提到的 Scope 中。在这个 Scope 中,可以识别和理解协程的挂起能力,挂起函数就可以按照我们预期的正常工作。

挂起函数只能被挂起函数调用

上边我们说过,挂起函数只能在协程作用域中使用,因为函数嵌套的特点,自然可以推导出挂起函数只能被挂起函数调用了。

在这里插入图片描述

我们如果误在普通函数中调用挂起函数的话,AndroidStudio 会贴心的提示我们以上内容。

挂起函数可以调用普通函数

这个也很好理解,因为我们上边说了,挂起函数其实是包含了普通代码块和一些穿插的挂起点。普通函数就是包裹普通代码块的函数,挂起函数自然是可以调用普通函数了。

挂起函数可以只包含普通代码,不包含挂起点

可以,但没必要,编辑器会提示我们 suspend 是多余的。

suspend fun test(){
  print("hello coroutine")
}

如上,编辑器会提示我们

在这里插入图片描述

总结一下,suspend(挂起) 是一个协程的机制,同时也是一个关键字。
作为机制来说,它指明的是和线程阻塞相区别的挂起能力。
作为关键字来说,它用来标识那些可以被挂起的函数,那些有挂起点的函数。换句话说,是标识那些只能在协程作用域内调用的函数。

这个 suspend 关键字以方便提示我们这些是协程的作用域,一方面也是告诉框架这些代码是要被运行在协程上的。这就是 suspend 的作用。

接下来我们看下协程另一个之前已经在示例代码中出现过的关键字 Dispatcher。

Dispatcher

Dispatcher 中文翻译过来就是调度器, 它是协程框架提供的让我们指定协程运行在哪个(或者哪些)线程上的指示类。

我们上边已经用到过两个调度器。其中 Dispatcher.IO 就是线程框架默认提供给我们的四个调度器之一。
协程框架总共提供了四个默认的调度器给我们使用:

Default:协程框架默认使用的调度器。如果调用协程时未指定任何调度器就会使用这个。内部使用一个公共共享的线程池,官方推荐我们可以用这个调度器来做一些耗费 CPU 的,计算密集的任务。
IO:从名称我们就能看出来,这是是用来处理 IO(例如文件 IO 和阻塞式 socketIO)的一个调度器。内部使用一个按需创建的线程池。
Main:主线程,在不同平台上都会指代主线程,用来操作一些 UI 相关的内容
UnConfined:这个调度器和上边的不太一样,它启动执行的线程和 suspend 回来之后的线程是不一样的。这个调度器会在当前的线程启动执行,直到遇到第一个挂起点。,然后这个挂起会在任意的不会特别指定的(UnConfined)线程上恢复。官方不推荐我们用在正常的场景中,除非你真的明白你需要这个特性。

除此之外呢,当然也可以自定义调度器,比如我们上边案例中使用到的单线程调度器,就是 Executor 转过来的。
你可以使用 ExecutorService 中的 asCoroutineDispathcer 这个拓展函数来将任意的 Executor 转义过来,非常方便

在这里插入图片描述

当然官方也非常贴心的提供了两个默认的从 Executor 转来的调度器,你可以通过 newSingleThreadContextnewFixedThreadPoolContext 这两个关键字来调用。

接下来,我们来看协程最后的一个关键特性,结构化协程

Structured Coroutine

协程内是可以启动无数个协程的,就像线程内可以启动无数个线程一样。不同点在与,线程内启动的线程和原有的线程并没有什么关联关系,他们是平级的。而协程多了一个结构化的概念(structured coroutine),协程内启动的协程被称为子协程。

区分了父子关系后,可以有以下好处:

  • 父协程会等所有的子协程结束才会结束。
  • 父协程可以管理所有的子协程,可以启动和取消子协程

(这么看,更像一个任务调度器了)

结束语

探索完以上的内容,我们对协程的关键特性都已经有了一个初步的概念。我们发现,各种特性其实并不陌生,都和我们其他的概念有或多多少的关联,我们也会发现其实: 协程就是一个有语言关键字支持的运行在线程池上的任务调度框架。 这个调度框架可以让你更加便捷的进行多线程编程,避免进行复杂的线程切换和调度管理,从而专注于业务开发。仅此而已。

当然,如果你想要使用好协程,仅仅了解这些基础的概念还是不够的,我们还需要更多的练习,协程的使用才能游刃有余。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/823715.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

d3dcompiler_47.dll缺失怎么修复,分享几种快速修复方法

当我们打开电脑软件或许游戏时候,如果电脑计算机中丢失了d3dcompiler_47.dll就会报错,丢失d3dcompiler_47.dll“”或许找不到d3dcompiler_47.dll等等提示。它主要用于编写和编译Direct3D 11的着色器程序,是Direct3D 11中非常重要的组成部分。…

FFmpeg下载安装及Windows开发环境设置

1 FFmpeg简介 FFmpeg:FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。项目的名称来自MPEG视频编码标准,前面的"FF"代表…

Java中集合容器详解:简单使用与案例分析

目录 一、概览 1.1 Collection 1. Set 2. List 3. Queue 1.2 Map 二、容器中的设计模式 迭代器模式 适配器模式 三、源码分析 ArrayList 1. 概览 2. 扩容 3. 删除元素 4. 序列化 5. Fail-Fast Vector 1. 同步 2. 扩容 3. 与 ArrayList 的比较 4. 替代方案…

AutoSAR系列讲解(实践篇)10.8-实验:模式管理

首先备份上一次的工程,养成好习惯(最好还是用Git,这次最后再安利一下Git这个神器)。今天的实验异常的简单,基本都是自动配置好了,只需 要勾选上就行,但是大家最好结合前面讲解的内容,把里面的选项点开看看,稍微理解一下。如此,假如配置出错了,才不至于慌乱了手脚 一…

【C语言】通讯录3.0 (文件存储版)

前言 通讯录是一种记录联系人信息的工具,包括姓名、电话号码、电子邮件地址、住址等。 文章的一二三章均于上一篇相同,可以直接看第四章改造内容。 此通讯录是基于通讯录2.0(动态增长版)的基础上进行增加文件操作功能&#xff0c…

jq——页面滚动到显示区域,再执行动画——基础积累

今天郑大东同事向我显摆了一个他做的动画,效果如下: 使用场景 当页面滚动到相应区域时,再执行里面的动画,也就是下图中右侧的一层层的显示动画,无论是向上滚动页面还是向下滚动页面。 下面直接上代码: …

mybatis更新时记录变更的字段日志java反射获取字段值转成string

1.先写个注解,加在entity的字段上,标记要记录这个字段的更新记录,再加个变更记录表(你们可以用自己的日志表) Retention(RetentionPolicy.RUNTIME) Target({java.lang.annotation.ElementType.FIELD}) Documented public interface ModifyAw…

iPhone 8透明屏的透明度高吗?

iPhone 8是苹果公司于2017年推出的一款智能手机,它采用了全新的设计和技术,其中一个亮点就是透明屏。 透明屏是指屏幕具有透明度,可以透过屏幕看到背后的物体。 iPhone 8的透明屏采用了最新的OLED技术,这种技术可以实现更高的对比…

Jmeter —— jmeter参数化实现

jmeter参数化 在实际的测试工作中,我们经常需要对多组不同的输入数据,进行同样的测试操作步骤,以验证我们的软件的功能。这种测试方式在业界称为数据驱动测试, 而在实际测试工作中,测试工具中实现不同数据输入的过程称…

替代LT8711龙讯替代RTD2172 CS5265中文规格书4K60HZ转接线 设计Type-C转HDMI2.0高清投屏方案

龙迅LT8711是一款Type-C/DP1.2 to HDMI2.0方案芯片,北京集睿致远(ASL)推出的CS5265可以完全代替LT8711UX,封装尺寸比LT8711UX小的同时,CS5265的芯片集成度高,内置MCU,内置lLDO等,CS5…

【外卖系统】菜品信息分页查询

需求分析 当菜品数据很多时,用分页的形式来展示列表数据 代码开发 页面发送ajax请求,将分页查询参数提交到服务端,获取分页数据页面发送请求,请求服务端进行图片下载,用于页面图片展示 构造分页 注意:…

Unity简单抽奖效果实现

实现效果如下: 实现效果为:外部传入数值,正常显示 lhj抽取效果 用户可自定义行为:1,抽取的显示;2,抽取的最低时间 实现代码如下,可自行改写: using System.Collection…

最新 23 届计算机校招薪资汇总

24 届的秋招提前批已经开始了,比如米哈游、oppoe、tplink 等公司都已经录取开启提前批。 像腾讯、字节、阿里等一线大厂的话,根据往年的情况,估计是 7月下-8 月初。 所以今年参加秋招的同学,要抓紧复习了。 提前批通常就持续不到…

Minio 部署

minio 官网:https://www.minio.org.cn/ 部署文档:https://www.minio.org.cn/docs/minio/container/operations/install-deploy-manage/deploy-minio-single-node-single-drive.html# 选择自己的部署环境: 我用的docker: docker pull qua…

命令模式——请求发送者与接收者解耦

1、简介 1.1、概述 在软件开发中,经常需要向某些对象发送请求(调用其中的某个或某些方法),但是并不知道请求的接收者是谁,也不知道被请求的操作是哪个。此时,特别希望能够以一种松耦合的方式来设计软件&a…

LaTex的下载与安装超详细windows版

1.LaTex的下载 (texlive下载TexStudio下载) (1)texlive下载: 这里清华镜像下载 (2)TexStudio下载: 点这里下载镜像 可以根据不同的系统选择不同的版本 2 .LaTex的安装 &#…

1分钟解决github push/pull报错443

1.打开https://www.ipaddress.com/ 2.复制如图IP地址 3.文件夹打开C:\Windows\System32\drivers\etc,复制hosts文件,粘贴到桌面 4.在桌面用记事本打开复制过来的hosts 5.在末尾加上一行,IP写刚才复制的 6.复制桌面的hosts,粘贴回C:\Window…

web开发中的安全和防御入门——csp (content-security-policy内容安全策略)

偶然碰到iframe跨域加载被拒绝的问题,原因是父页面默认不允许加载跨域的子页面,也就是的content-security-policy中没有设置允许跨域加载。 简单地说,content-security-policy能限制页面允许和不允许加载的所有资源,常见的包括&a…

3DEXPERIENCE用户角色 | Structural Performance Engineer 结构性能工程师

- 3DEXPERIENCE 用户角色 - 快速、真实和准确的仿真 推动产品工程创新并促进协作 通过 Structural Performance Engineer,您可以在基于云的 3DEXPERIENCE 平台上执行结构静态、频率、扭曲、模态动态响应,以及零件和装配体结构热仿真。 亮点 与 SOLIDW…

统一观测|借助 Prometheus 监控 ClickHouse 数据库

引言 ClickHouse 作为用于联机分析(OLAP)的列式数据库管理系统(DBMS), 最核心的特点是极致压缩率和极速查询性能。同时,ClickHouse 支持 SQL 查询,在基于大宽表的聚合分析查询场景下展现出优异的性能。因此,获得了广泛的应用。本文旨在分享阿…