标签搜索

Kotlin 协程

Pop.Kite
2021-12-19 / 0 评论 / 95 阅读 / 正在检测是否收录...

在介绍协程是什么之前,我突然想起来前些天刚入职的时候,我问一个已经入职较久的同事作为一个资深员工都应该掌握什么技术。前辈说Java基础,一些设计模式,还有Android的基本知识都要掌握,比如四大组件还有一些IPC的方式等,这些东西让我心里有了一个学习的方向。然后我问我司项目开发上使用Kotlin么,然后前辈说不太常用,但是掌握了没坏处;我又问协程需要学吗,前辈表示应该是我看错了,没有协程,只有线程,线程还是要学习的...最后还提了一嘴Jetpack的一些组件,前辈说这些东西出问题的话难以定位,所以尽量不要使用...

我感觉虽然我们平时工作使用到的技术可能不多,但是我们对于新兴的技术至少要了解一些,而且要以拥抱的姿态面对新技术而不是抗拒的姿态。更何况Kotlin已经被Google钦定为安卓首选的开发语言,Compose也是Google大力推广的UI框架,好了我们言归正传吧。

协程是什么

线程大家都应该很熟悉,在Android开发中的一些耗时操作(比如网络请求)应该放在子线程中执行防止ANR。这里我自己复习一下Java中线程的五种状态:

  • 新建
Thread thread = new Thread()
  • 就绪
已经新建完毕的线程开启后,在队列中等待CPU资源的阶段
//进入就绪状态
thread.start()
  • 运行
已经就绪的线程获得CPU资源,开始执行 run() 函数中的方法时
  • 堵塞
正在运行的线程暂停执行并让出CPU资源
//休眠定长的时间,改时间结束后进入就绪状态,注意是就绪态而非运行态
sleep(100)
//等待,可用于等待某一资源,使用notify()恢复到就绪态
wait()
//阻塞,被其他优先级高的线程抢占了CPU,使用resume()恢复到就绪态
suspend()
  • 结束

正常情况下当线程执行完run()函数内的所有方法后即凋亡

异常情况是当前线程被其他线程杀死,比如被调用了 stop() 方法就会终止指定的线程

线程的状态转换的一个简单示意图

image-20211222145257911

线程被阻塞后,该线程便停止运行,等待当前线程运行结束或被唤醒,而后再进入就绪队列等待被CPU临幸

线程是系统级的资源,当Java程序创建线程时,虚拟机本身没有能力创建线程,而是向操作系统请求创建一个线程。而线程又是昂贵的系统资源,创建、切换、停止等线程属性都是重量级的系统操作,这些操作本身就会耗费时间,那么CPU留给线程执行的时间就会减少,这种问题在进行多次线程切换的时候尤为明显,所以在Java程序中创建线程时需要深思熟虑,是否造成系统资源的浪费或者其他的性能问题。


回顾完线程我们来看一下协程是什么。

协程听起来和线程差不多,但其实还是有很大不同的(以下为本人拙见,如有还请指正)。首先协程是轻量的,对于协程的操作,如创建、运行等都是用户级而非系统级的操作。而后协程是基于线程的,就像一个进程由多个线程组成,一个协程可以运行于多个线程,而且,一个线程还可以运行多个协程,听到这里可能会有点奇怪,用一张动图能够更直观的体现协程与线程的关系:

img

在进程A中存在线程1、2,在这两个线程上存在多个协程,协程能够运行在不同线程以提高利用率。

按照我个人的理解,Kotlin中的协程从宏观上是一种JVM线程调度的框架,Kotlin通过协程在语言层面上调度函数在不同的线程上运行,避免了线程切换等类似的重量级操作。

读到这我们只需要明白一件事,就是在Kotlin中的协程和线程不是同级别的东西,协程基于线程,或者用官方的说法,Kotlin的协程是轻量的线程。有多轻呢?轻到我们可以请求10万个协程,如果是线程的话,应该早就OOM啦 。


相对于线程,更轻更灵活是协程的一个特点,当初我也是因为这一点开始学习使用协程的。甲方公司有一个Demo,需要进行数十万次的网络请求拉取数据到本地然后通过TTS转换再将音频文件上传。乍一听是个很简单的需求,只要在子线程中发起网络请求就可以了。但是数十万条数据如果使用同步请求,就算一条拉取一条数据只需要1秒钟,那十万条数据也得半个月才能请求完毕。而同步请求要开启多个线程,我总不能开启10万个线程吧(说实话这个测试机就算开10个线程我都感觉要卡死了),所以我就想起来了Kotlin的协程。

但真正让我爱上协程的,还是其所谓的用同步的写法完成异步的操作

协程的一些概念

学习协程之前,先普及一些Kotlin协程几个概念,为了行文方便,一下Kotlin协程简称为协程。

  • 作用域

    所有的协程相关内容必须运行在一个名为协程作用域的范围内,如 GlobalScope就是一个顶级的协程作用域。我们可以通过协程作用域取消该作用域内启动的所有协程避免内存泄漏。

  • 调度器

    • Dispatchers.Main

      主线程,用于处理UI交互

    • Dispatchers.IO

      IO线程,用于进行IO操作

    • Dispatchers.Default

      默认线程,用于密集的CPU计算,如数据解析等

  • 开启协程

    • launch
    • async
  • 挂起和恢复

    挂起和恢复在协程作用域内可用

    • suspend

      挂起关键字,该关键字声明的方法会暂停当前协程并等待方法执行完毕

    • resumex

      恢复协程运行,已经被挂起的协程可以通过该方法恢复运行

  • 协程嵌套

    所谓上下文可以用父子来类比,如果我们想在父协程内开启一个子协程,我们可以继承父协程。如果我们不想继承子协程,比如父协程是在Dispatchers.Main中运行,而我们想进行一个网络请求,我们可以使用withContext(Dispatchers.IO)切换到一个新的协程。

  • 协程上下文

同步方式编写的异步代码

Code

假使我们需要通过Retrofit2框架进行网络请求,使用回调这种方式进行耗时操作的时候,由于我们不知道耗时操作何时结束,因此只能在操作结束的时候让它调用回调函数以更新数据或UI。如果简单的加载少量数据我们的代码看起来获取不多,但是如新闻类的APP,我们在需要加载大量的数据,如图片标题作者时间等,而每条新闻点击后又会加载一批数据、广告类的东西...如此一番我们需要编写大量的回调函数,所谓回调地狱,写到最后会发现:我是谁?我在哪?


更高深的RxJava还没有学习,暂时就不讲了,下面看看协程是怎么处理的:

//除去我们都需要编写的Retrofit接口,我们可以这么编写网络请求
fun getFile(){
  //在Activity中开启协程
  mianScope.launch{
    //此处挂起协程开始请求数据,待请求完毕后将数据赋给fileBean
    val fileBean = NetServiceCreator.create<NetService>().downloadFile(1).await()
    //更新UI
    updateUI(fileBean)
  }
}

浅析

上面的代码看起来是不是就像同步方式执行的代码呢?这就是所谓的用同步方式编写的异步代码

每行代码的意思可以参见注释,这里讲一下为什么能够挂起协程开始请求数据,待请求完毕后赋值,而可以不用回调。

当我们使用suspend修饰的方法挂起协程的时候,注意这里是挂起协程而不是阻塞,当前协程会暂停运行,而后在子协程中执行挂起函数中的代码,等到挂起函数中的代码执行完毕,我们可以通过resume将执行结果传递回父协程并且恢复协程运行,此时协程继续执行下一句代码,也即是更新UI的操作。

在上述的代码中,await()是被suspens修饰的一个挂起函数,它会将当前协程挂起,待执行完毕函数体后恢复协程。

挂起和阻塞

协程的挂起操作听起来和线程的阻塞有点像,但是二者其实是完全不同的东西。

举个例子,假如你去网吧上网。因为平时你都是用2号机器拿超神四杀五杀的,所以今天你也点了2号机器。但是不巧2号机器今天有人,这个人的下机时间还有两个小时,接下来你有两种方式度过这两个小时:

  1. 阻塞:这种情况下你就在网吧等,等两个小时这个人下机。
  2. 挂起:你在网吧登记了一下,等到2号机下机你再来上网。

so,应该可以看出来哪一种效率更高,那必然是挂起。

再举一个Code例子,比如我们点击按钮加载图片。这张图片非常的大,我们需要加载一分钟。

  1. 阻塞:我们用Thread.sleep(6000)阻塞一分钟模拟在线程中加载图片,那么当我们按下按钮的时候,我们可以看到按钮是不会弹出的,因为当前线程被阻塞了,如果我们此时继续点击按钮,就会出现我们喜闻乐见的ANR
  2. 挂起:我们在协程作用域中用delay(6000)挂起一分钟模拟在协程中加载图片,再按下按钮的时候按钮会立即弹出,我们多次点击按钮也不会出现ANR,因为Kotlin已经在新开辟的协程中运行耗时操作了而不会阻塞线程。等到一分钟后我们点击的结果也会陆续出现。

挂起相比阻塞更轻量、更高效。

Loading...

参考文章

线程和协程的区别的通俗说明

0

评论 (0)

取消