协程(1) | 协程思维

2,283 阅读12分钟

前言

协程作为Kotlin最大的特性,以及在MVVM架构中的广泛应用,我们不得不对其要有深刻的理解。

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

正文

对于很多开发者来说,都听过一句话:"Kotlin的协程,只是Java线程的框架",但是今天站在学习和理解Kotlin思维的角度来看,我们要彻底忘记这句话

Kotlin作为一门更现代化的语言,我们可以说协程是它在语法层面相对于Java的新特性,虽然只是新特性,但是我们必须建立起协程的思维。理解协程中的"挂起和恢复"、"结构化并发"等等概念,这些即是Kotlin语言语法层面的语法糖,也是很多语言通用的概念,更是我们开发的效率利器。

作为框架来说,其底层实现确实是基于线程的,但是这个却不利于我们深刻理解协程的设计初衷和思想,容易固化我们的思维,但是基于线程的观点容易让我们理解协程的实现原理,所以我们要客观看待新事物。

对于新特性、新框架,我们理解其设计理念,就比如协程可以大大简化开发者的并发工作;而对于其原理,我们可以通过Java对比,来理解其实现原理,从而加深理解,做到心里有底

协程的重要性

协程是Kotlin对比Java的最大优势,Kotlin的协程可以极大地简化并发编程和优化软件架构。这里的简化异步编程主要就体现在协程可以利用挂起函数实现使用同步的代码实现异步的操作(避免回调地狱),而优化软件架构则是可以利用协程的结构化并发特性和许多诸如Flow等API来简化复杂的逻辑。

所以学习协程,最直接的价值就是多了一个处理并发编程的方法,让之前用线程实现的逻辑改用协程;

但是在我看来,学习协程最重要的是理解这个框架的设计理念,比如在之前线程中不好处理的业务,使用协程如何优化,协程为什么要这样设计;所以要站在Kotlin协程的创建者角度上,来解析其设计理念,构建一套完整的知识体系,建立一个具体的协程思维模型,提高我们的架构思维高度。

什么是协程

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

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

协程(Coroutine)和普通程序(Routine)差异

这里举个例子来说明普通的程序(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

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

协作特点

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

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

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

这里所说的各种概念,比如"yield(让步、产出)"、"suspend(挂起)"和"resume(恢复)",都是非常重要的概念,我们需要站在新的视角,就认为这段程序是具有挂起、恢复等特性,而不必强行套用Java的线程思维来思考。

Kotlin协程

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

协程和协程框架

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

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

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'

只有加了依赖,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#2coroutine#3是交替执行的,但是他们都是在主线程上。

原因是为啥呢,这是由于delay方法是挂起函数,当在协程#2中调用delay()方法时,协程#2会让出执行权,等待1000ms后再继续执行后面操作,但是这里在让出执行权时,其他协程是可以工作的,所以协程#3会打印,关于挂起函数,后面文章会细说。

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

挂起和恢复

从前面的解释就可以看出协程的非阻塞的实现原理,答案就是挂起和恢复,同时这个能力也是协程才有的,普通程序不具备

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

11.gif

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

22.gif

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

33.webp

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

44.gif

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

总结

协程非常重要,学习协程的第一步是需要打破原来"线程框架"的思维,建立起新的协程思维。

首先就是把协程看成是一个新东西,比如我们可以把协程看成轻量的线程,也可以看成是运行在线程中的Task

既然是Task,所以一个协程它可以在不同的线程中运行,可以在线程中切换。

然后就是介绍协程的非阻塞特性,这里可以使用上面思维模型动图理解,即每个协程Task都有抓手,可以进行挂起和恢复

还是那句话,理解协程思维,至关重要。