协程粉碎计划 | 协程到底是什么

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第25天,点击查看活动详情

本系列专栏 juejin.cn/column/7090…

前言

最近在做项目优化的部分,其中有一部分是Flow和协程的优化,想了好久都难以下手,究其原因就是不熟悉协程和Flow,只是简单地使用API,所以要下定决心一定要搞懂协程。

因为协程地东西比较多,所以创建了一个协程专栏来记录相关的文章,同时文章的知识点部分来源于 time.geekbang.org/column/intr… 大家有兴趣的可以看看原篇文章。

正文

说起来很奇怪,在Android开发领域,协程的使用率极低,一方面是协程原理和概念比较复杂,容易出错;另一方面主要原因是大部分Android程序员认为没有必要使用协程,因为线程完全可以解决业务问题。

而作为Kotlin和协程的使用者来说,协程还是非常香的,我是大力支持使用协程;同时协程的工作模式很有颠覆性,使用协程可以极大的帮助开发者,所以学习其设计理念和原理非常有必要。

协程的重要性

协程是Kotlin对比Java的最大优势,Kotlin的协程可以极大地简化异步、并发编程和优化软件架构。但是Kotlin的协程的行为模式却难以捉摸,比如同样的5行代码,普通程序一般执行顺序是1、2、3、4、5,而协程可以能是1、4、3、2、5这种错乱的,所以必须要搞清楚协程的思维模型和设计理念。

所以学习协程,不仅仅是多了一个解决并发问题的方法,更重要的是要站在Kotlin协程的创建者角度上,来解析其设计理念,来构建一套完整的知识体系,建立一个具体的协程思维模型,提高我们的架构思维高度。

什么是协程

首先协程是一个非常早的概念,而且在其他很多语言中都有,Kotlin也是最近几年才支持的,所以我们先从广义上说,使用简单的语言来描述协程就是:互相协作的程序

可以看出这里和普通程序不同的点就是可以互相协作,那怎么互相协作呢 我们来举个例子看一下。

协程和普通程序差异

这里举个例子来说明普通的程序(Routine)和协程(Coroutine)之间的差异,比如下面代码:

fun main() {
    val list = getList()
    printList(list)
}

fun getList(): List<Int> {
    val list = mutableListOf<Int>()
    println("Add 1")
    list.add(1)
    println("Add 2")
    list.add(2)
    println("Add 3")
    list.add(3)
    println("Add 4")
    list.add(4)
    return list
}

fun printList(list: List<Int>) {
    val i = list[0]
    println("Get$i")
    val j = list[1]
    println("Get$j")
    val k = list[2]
    println("Get$k")
    val m = list[3]
    println("Get$m")
}
复制代码

这里先调用getList()方法返回一个list,再调用printList()方法打印其中的值,根据Java运行在JVM中规则来说,这里会连续创建栈帧,然后调用完出栈,所以这里的打印肯定是顺序的,结果打印如下:

image.png

这就是一个普通的程序,那下面我们看一下协程的例子,代码如下:

fun main() = runBlocking {
    val sequence = getSequence()
    printSequence(sequence)
}

fun getSequence() = sequence {
    println("Add 1")
    yield(1)
    println("Add 2")
    yield(2)
    println("Add 3")
    yield(3)
    println("Add 4")
    yield(4)
}

fun printSequence(sequence: Sequence<Int>) {
    val iterator = sequence.iterator()
    val i = iterator.next()
    println("Get$i")
    val j = iterator.next()
    println("Get$j")
    val k = iterator.next()
    println("Get$k")
    val m = iterator.next()
    println("Get$m")
}
复制代码

这段代码中,返回的是一个Sequence,再按照之前的程序思维,我们会认为还是4个Add打印完,再打印Get吗 我们来看一下打印结果:

image.png

会发现这里是交替执行的,每当在sequence中Add一个元素,printSequence就会打印一个元素,即getSequence()方法和printSequence()方法是交替执行的,而这种模式,就像是俩个程序在协作一样。

协作特点

从前面2段代码我们能很明显地看出协程代码运作的特别之处,而这就是和普通程序的最大差异。上面代码的差异可以总结为:

  1. 普通程序在被调用后,只会在末尾的地方返回,并且只会返回一次,比如前面的getList()方法;而协程不受限制,协程的代码可以在任意yield的地方挂起(Suspend)让出执行权,然后等到合适的时机再恢复(Resume),比如前面getSequence()方法和printSequence()方法可以在方法执行中进行挂起和恢复。在这个情况下,**yield是代表了"让步"**的意思。
  2. 普通程序需要一次性收集完所有的值,然后统一返回,比如前面的getList()方法;而协程可以每次返回(yield)一个值,这里的yield不仅有"让步"的意思,还有"产出"的意思,比如前面的代码中yield(1)就表示产出的值为1。

所以,从广义上来说,协程就是互相协作的程序

Kotlin协程

前面说了广义的协程概念,现在来看看Kotlin中的协程。

协程和协程框架

这里要注意一下,这2个东西不是一样的,其中协程表示的是程序中被创建的协程,而协程框架则是一个整体的框架

和Kotlin的反射库类似,为了减小标准库的体积,协程库需要单独依赖:

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
复制代码

只有加了依赖,Kotlin协程才可以正常使用。

理解Kotlin协程

关于Kotlin的协程,我相信很多开发者都看过扔物线的博客,里面让我记忆犹新的话就是Kotlin协程就是一个封装的线程框架

从使用者角度来说,这个说法是正确的,因为协程就是将线程池进行一步封装,对开发者暴露了统一的协程API,但是我觉得这种不利于理解协程框架的思维,我们暂时不要拘泥于其底层是线程还是啥,我们要先理解为什么协程要这样设计

这个就类比于Java或者Kotlin中的泛型是伪泛型一样,你不能说泛型会在运行时擦除,就说伪泛型是没用的,实际上泛型的用处很大。

线程和协程

我的理解是通过线程和协程的比较,来理解协程是什么,毕竟线程是真实存在的,操作系统通过线程来进行CPU的分时复用,很容易理解,那协程又是什么呢

为了我们的代码能够打印出协程,可以对Android Studio做如下配置:

image.png

image.png

image.png

通过上面配置,我们就可以打印出协程名以及Debug调试协程了。

话不多说,我们先看个启动线程的例子:

fun main() {
    println(Thread.currentThread().name)
    thread {
        println(Thread.currentThread().name)
        Thread.sleep(100)
    }
    Thread.sleep(1000L)
}
复制代码

这里我们创建了一个子线程,所以打印如下:

image.png

然后我们启动协程来看一下:

fun main() = runBlocking {
    println(Thread.currentThread().name)

    launch {
        println(Thread.currentThread().name)
        delay(100L)
    }

    Thread.sleep(1000L)
}
复制代码

上面代码我们启动了2个协程,我们来看一下打印:

image.png

可以发现协程和线程有点类似,这里2个协程都是运行在主线程上,但是这里还是有点迷惑,所以这里就要对协程建立一个思维模型来辅助我们理解。

思维模型

从上面的线程和协程对比可以看出,我们可以把协程看成是一个"更加轻量的线程",注意这里是看成,而不是协程真的就是线程,既然有了这种理解,我们可以绘制出下面的结构:

image.png

一个系统中,有多个进程,而一个进程有多个线程,根据前面的关系,协程可以理解为运行在线程当中的、更加轻量的Task。

协程的轻量

说道这里,或许就有人反对了,说协程运行在线程上,怎么可能会轻量呢 这里还是要跳出底层是线程实现的思维陷阱,要理解协程的设计模型,我们还是来看看例子。

比如下面代码我们创建10亿个线程:

fun main() {
    repeat(1000_000_000) {
        thread {
            Thread.sleep(1000000)
        }
    }

    Thread.sleep(10000L)
}
复制代码

这个代码在大部分机器上都会因为内存不足而退出,运行如下:

image.png

那如果创建10亿个协程呢:

fun main() = runBlocking {
    repeat(1000_000_000) {
        launch {
            delay(1000000)
        }
    }

    delay(10000L)
}
复制代码

这个代码是不会异常退出的,从这个简单的例子我们可以印证协程是运行在线程上更轻量的Task。

注意,这里协程的运行不会和某个线程绑定,在某些情况下,协程可以在不同的线程之间切换的,比如下面代码:

fun main() = runBlocking(Dispatchers.IO) {
    repeat(3) {
        launch {
            repeat(3) {
                println(Thread.currentThread().name)
                delay(100)
            }
        }
    }

    delay(5000L)
}
复制代码

这里我们会开启3个协程,然后每个协程打印3次,我们来看一下打印:

image.png

会发现协程#2运行的线程发生了切换,这也就验证了,协程不会和某个线程绑定。

思维模型2.0

这样的话,我们上面那个思维模型就可以优化一下了,协程依旧是运行在线程上更轻量级的Task,但是可以在不同线程间切换

d89e8744663d45635a5125829a9037a9.gif

就比如上图一样,协程可以在不同线程上运行。

现在可以做个小节:

  1. 协程,可以理解为更加轻量的线程,成千上万的协程可以同时运行在一个线程中。
  2. 协程,其实就是运行在线程当中的Task
  3. 协程,不会和特定的线程绑定,它可以在不同的线程之间灵活切换。

非阻塞

说起协程,就不得不说其大名鼎鼎的非阻塞的特性了,对于线程我们非常熟悉,比如通用的线程生命周期模型中,线程就有休眠这个状态,或者在Java线程生命周期模型中,线程可以通过sleep进行阻塞或者在等待锁的条件变量时进行阻塞,当线程阻塞时,则说明任务无法继续执行;在Android中尤为明显,当主线程阻塞时,应用程序会ANR。

我们先来看个线程休眠的例子:

fun main() {
    repeat(3) {
        Thread.sleep(1000L)
        println("Print-1:${Thread.currentThread().name}")
    }

    repeat(3) {
        Thread.sleep(900L)
        println("Print-2:${Thread.currentThread().name}")
    }
}
复制代码

这里会让线程休眠,因为sleep()方法是阻塞的,当调用sleep()方法时,线程将无法继续执行,所以打印结果如下图:

image.png

上面是串行的,那我们来看看协程有什么不一样,比如下面代码:

fun main() = runBlocking {
    launch {
        repeat(3) {
            delay(1000L)
            println("Print-1:${Thread.currentThread().name}")
        }
    }

    launch {
        repeat(3) {
            delay(900L)
            println("Print-2:${Thread.currentThread().name}")
        }
    }
    delay(3000L)
}
复制代码

这里协程的代码执行结果如下:

image.png

会发现coroutine#2和coroutine#3是交替执行的,但是他们都是在主线程上,这是由于delay方法是非阻塞的,即当第一个协程执行delay时,第二个协程依旧可以运行,所以会出现交替打印的结果。

但是如果把这里的delay换成sleep的话,依旧会阻塞,这就说明Kotlin协程的非阻塞只是语言层面的,这也意味着我们在协程中要尽量使用delay而不是sleep。

挂起和恢复

那如何理解Kotlin的非阻塞呢,答案就是挂起和恢复,同时这个能力也是协程才有的,普通程序不具备

这里的做法还是建立思维模型,比如对于普通的程序,我们在CPU的角度来看类似于下图:

11.gif

当某个任务发生阻塞行为时,比如sleep,当前的Task就会阻塞后面所有任务的执行,如下图:

22.gif

那协程是如何通过挂起和恢复来实现非阻塞呢 这里就会存在一个类似调度中心的东西,它会来实现Task任务的执行和调度,如下图:

33.webp

而协程除了有调度中心外,每个协程的Task还会多个抓手、挂钩的东西,可以方便我们对他进行挂起和恢复,所以流程会如下图:

44.gif

通过对比可以看出,这里Task会被挂起,它不会阻塞后面的Task的正常执行。看了这个图之后,再想想前面非阻塞的代码,就很好理解了。

总结

协程非常重要,学习协程的过程也非常有意思,而这里主要的方法就是建立对应的思维模型。还是总结一下本篇内容:

  1. 广义的协程可以理解为互相协作的程序。
  2. 程序当中运行的协程可以理解为轻量的线程,可以看出是运行在线程中非阻塞的Task。
  3. 一个线程中可以运行多个协程。
  4. 通过挂起和恢复来实现非阻塞。
  5. 协程不会和特定的线程绑定,可以在线程之间切换。

只有理解了这些概念-,才能更好的理解后面所说的挂起、调度中心等核心实现。

猜你喜欢

转载自juejin.im/post/7090542208763297800