End

Kotlin 朱涛-13 协程 思维模型 非阻塞 coroutine 线程

本文地址


目录

13 | 协程思维模型

为什么协程如此重要

协程是 Kotlin 对比 Java 的最大优势

Java 也在计划着实现自己的协程 Loom,不过目前还处于相当初级的阶段。

Kotlin 协程目前在业界的普及率并不高:

  • 协程是一种颠覆性的技术,因此,刚开始时的学习难度比较大
  • 协程的语法表面上看很简单,但其行为模式却让新手难以捉摸
  • 协程是一个典型的易学难精的框架,如果理解不透彻,使用时一定会遇到各种各样的问题

学习协程,相当于一次编程思维的升级。协程思维与线程思维迥然不同,当我们能够用协程的思维来分析问题以后,线程当中某些棘手的问题在协程面前都会变成小菜一碟。

学习协程,相当于为我们打开了一扇新世界的大门。当我们对 Kotlin 协程有了透彻的认识以后,再去看 C#PythonDartJS、Golang、Rust、C++20、Java Loom 中的类协程概念,就会觉得无比亲切。

什么是协程

从广义上来讲,协程就代表了 互相协作的程序。这个标准,几乎适用于所有语言的协程。

协程案例:yield

注意,协程没有直接集成在标准库中,需要手动依赖:

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

fun main() = runBlocking {
    val sequence: Sequence<Int> = getSequence()
    val iterator: Iterator<Int> = sequence.iterator()
    println("Get ${iterator.next()}")
    println("Get ${iterator.next()}")
    println("Get ${iterator.next()}")
}

fun getSequence() = sequence {
    print("Add 1 - ").also { yield(1) } // yield 代表【产出】一个值,并【让步】
    print("Add 2 - ").also { yield(2) }
    print("Add 3 - ").also { yield(3) }
}
Add 1 - Get 1
Add 2 - Get 2
Add 3 - Get 3

从程序的运行结果会发现,main() 与 getSequence() 这两个函数,是交替执行的。这样的运行模式,就好像两个函数在协作一样。

  • 普通程序在被调用以后,只会在末尾的地方返回,并且只会返回一次
  • 协程可以每次只 yield(产出) 一个值,并在任意 yield(让步) 的地方挂起(suspend),让出执行权,然后等到合适的时机再恢复(resume)

协程案例:Channel

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel: ReceiveChannel<Int> = getProducer(this) // Receiver's interface to Channel
    delay(100).also { println("Receive ${channel.receive()}") }
    delay(100).also { println("Receive ${channel.receive()}") }
    delay(100).also { println("Receive ${channel.receive()}") }
}

fun getProducer(scope: CoroutineScope) = scope.produce {
    print("Send 1 - ").also { send(1) }
    print("Send 2 - ").also { send(2) }
    print("Send 3 - ").also { send(3) }
}
Send 1 - Receive 1
Send 2 - Receive 2
Send 3 - Receive 3

以上代码中的 main() 和 getProducer() 之间,也是交替执行的。

协程的发展历史

Kotlin 协程很年轻:

  • 2017 年初,Kotlin 1.1 版本中,协程以实验性被加入进来
  • 2018 年底,Kotlin 1.3 版本中,协程正式成为 Kotlin 的特性
  • 2019 年,Kotlin 协程推出 Flow 相关的 API

但协程这个概念本身并不年轻:

  • 早在 1967 年的 Simula 语言中,就已经出现了协程
  • 但是在之后的几十年里,协程并没有被推广开
  • 直到 2012 年,C# 重新拾起了协程这个特性,实现了 async、await、yield
  • 之后,JavaScript、Python、Kotlin 等语言也跟进实现了对应的协程

Kotlin 中的协程

  • 协程,可以想象成是更加轻量的线程,成千上万个协程可以同时运行在一个线程中
  • 协程,可以理解为是运行在线程中的、更加轻量的 Task
  • 协程,不会与特定的线程绑定,它可以在不同的线程之间灵活切换
  • 协程跟线程的关系,有点像线程与进程的关系,因为协程不可能脱离线程运行

业界一直有个说法:Kotlin 协程其实就是一个封装的线程框架,其本质是对线程池的进一步封装。

线程中可以运行多个协程

PS:执行下面的代码前,要先配置特殊的 VM 参数:-Dkotlinx.coroutines.debug

这样一来,Thread.currentThread().name 就能会包含协程的名字 @coroutine#1

案例一

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("1-" + Thread.currentThread().name)     // 先打印 1-main @coroutine#1
    launch { // launch 是创建协程
        println("2-" + Thread.currentThread().name) // 后打印 2-main @coroutine#2
    }
    delay(10L)
}

可以看到,main 函数中出现了两个协程。

案例二

fun main() = runBlocking() {
    val time = System.currentTimeMillis()
    printInfo(time, 0)
    launch {
        delay(200L)
        printInfo(time, 1)
    }
    launch {
        delay(100L)
        printInfo(time, 2)
    }
    printInfo(time, 3)
    delay(500L)
    printInfo(time, 4)
}


fun printInfo(time: Long, text: Any) = println("$text -" + Thread.currentThread().name + " - " + (System.currentTimeMillis() - time))
0 -main @coroutine#1 - 1
3 -main @coroutine#1 - 6
2 -main @coroutine#3 - 114
1 -main @coroutine#2 - 226
4 -main @coroutine#1 - 520

案例三

fun main() = runBlocking() {
    val time = System.currentTimeMillis()
    printInfo(time, 0)
    for (i in 1..98) {
        launch {
            delay(i.toLong())
            printInfo(time, "1-$i")
        }
    }
    printInfo(time, 2)
    delay(500L)
    printInfo(time, 3)
}

fun printInfo(time: Long, text: Any) =
    println("$text -" + Thread.currentThread().name + " - " + (System.currentTimeMillis() - time))
0 -main @coroutine#1 - 1
2 -main @coroutine#1 - 7
1-2 -main @coroutine#2 - 15
1-3 -main @coroutine#3 - 23    // 为什么有大量重复的值?
1-4 -main @coroutine#4 - 23    // 为什么有大量重复的值?
1-5 -main @coroutine#5 - 23    // 为什么有大量重复的值?
1-6 -main @coroutine#6 - 23    // 为什么有大量重复的值?
1-7 -main @coroutine#7 - 23    // 为什么有大量重复的值?
1-8 -main @coroutine#8 - 23    // 为什么有大量重复的值?
//...
1-97 -main @coroutine#97 - 119
1-98 -main @coroutine#98 - 119
3 -main @coroutine#1 - 518

有无数个协程运行在一个线程上。

协程是非常轻量的

在下面的代码中,我们尝试启动 10 亿个线程,这样的代码,在大部分的机器上运行时,都会因为内存不足等原因而异常退出。

import kotlin.concurrent.thread

fun main() {
    repeat(1000_000_000) { // repeat 是重复,这里是重复启动 10 亿个线程
        thread { // OutOfMemoryError: unable to create new native thread
            Thread.sleep(1000000L)
        }
    }
}

下面改用协程来实现:

import kotlinx.coroutines.*

fun main() = runBlocking {
    var count = 0L
    repeat(1000_000_000) { //启动 10 亿个协程
        println(++count)
        launch {
            delay(1000000L)
        }
    }
}

由于协程是非常轻量的,所以代码不会因为内存不足而异常退出。

协程不会和线程绑定

协程虽然运行在线程之上,但协程并不会和某个线程绑定,协程是可以在不同的线程之间切换的

import kotlinx.coroutines.*

fun main() = runBlocking(Dispatchers.IO) {
    repeat(3) {
        launch {
            repeat(3) {
                println(Thread.currentThread().name)
                delay(100L)
            }
        }
    }
}
// 代码运行的结果是随机的
DefaultDispatcher-worker-3 @coroutine#2 // 协程 @coroutine#2 第一次在 worker-3 线程执行
DefaultDispatcher-worker-2 @coroutine#3
DefaultDispatcher-worker-4 @coroutine#4
DefaultDispatcher-worker-1 @coroutine#2 // 协程 @coroutine#2 第二次在 worker-1 线程执行
DefaultDispatcher-worker-4 @coroutine#4
DefaultDispatcher-worker-2 @coroutine#3
DefaultDispatcher-worker-2 @coroutine#2 // 协程 @coroutine#2 第三次在 worker-2 线程执行
DefaultDispatcher-worker-1 @coroutine#4
DefaultDispatcher-worker-4 @coroutine#3

可以看到,coroutine#2 的三次执行,每一次都在不同的线程上。

Kotlin 协程的非阻塞

协程对比线程还有一个特点,那就是非阻塞(Non Blocking),而线程则往往是阻塞式的。

  • Kotlin 协程的非阻塞只是语言层面
  • 当调用 JVM 层面的 Thread.sleep() 的时候,它仍然会变成阻塞式
  • 在协程中应该尽量避免出现阻塞式的行为,即尽量使用 delay,而不是 sleep

阻塞案例:线程 + sleep

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

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

由于线程的 sleep() 方法是阻塞式的,所以程序的执行流程是线性的。也就是说,Print-1 会连续输出三次,然后 Print-2 会连续输出三次。即使 Print-2 休眠的时间更短。

非阻塞案例:协程 + delay

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {            // 创建一个协程
        repeat(3) {     // 重复打印三次
            delay(100L) // delay-1
            println("Print-1: ${Thread.currentThread().name}")
        }
    }

    launch {
        repeat(3) {
            delay(90L)  // delay-2
            println("Print-2: ${Thread.currentThread().name}")
        }
    }
    delay(10L)
}

注意:上面代码中的两个 delay 的大小关系,是会影响输出结果的:

  • delay-1 < delay-2 时(比如 delay-2 = 110),Print-1 肯定全部在 Print-2 之前输出
  • delay-1 > delay-2 时(比如 delay-2 = 90), Print-2 和 Print-1 可能交替输出
  • delay-1 >> delay-2 时(比如 delay-2 = 10),Print-2 可能全部在 Print-1 之前输出
Print-2: main @coroutine#3
Print-1: main @coroutine#2
Print-2: main @coroutine#3
Print-1: main @coroutine#2
Print-2: main @coroutine#3
Print-1: main @coroutine#2

可以看到,Print-2 和 Print-1 是交替输出的,coroutine#2coroutine#3 这两个协程是并行的(Concurrent)。

同时,由于协程的 delay() 方法是非阻塞的,所以,即使会先执行 delay(100L),但它也并不会阻塞 delay(90L) 的运行。

阻塞案例:协程 + sleep

如果我们将上面代码中的 delay 修改成 sleep,程序的运行结果就不一样了。

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {                   // 创建一个协程
        repeat(3) {            // 重复打印三次
            Thread.sleep(100L) // delay 是非阻塞的,而 sleep 是阻塞的
            println("Print-1: ${Thread.currentThread().name}")
        }
    }

    launch {
        repeat(3) {
            Thread.sleep(90L)
            println("Print-2: ${Thread.currentThread().name}")
        }
    }
    delay(10L)
}
// 上面代码中的两个 delay 的大小关系,不会影响输出结果
Print-1: main @coroutine#2
Print-1: main @coroutine#2
Print-1: main @coroutine#2
Print-2: main @coroutine#3
Print-2: main @coroutine#3
Print-2: main @coroutine#3

由此可见,Kotlin 协程的非阻塞其实只是语言层面的,当我们调用 JVM 层面的 Thread.sleep() 的时候,它仍然会变成阻塞式的。

所以,在协程当中应该尽量避免出现阻塞式的行为。即尽量使用 delay,而不是 sleep。

挂起和恢复:抓手、挂钩、hook

该如何理解 Kotlin 协程的非阻塞呢?答案是:挂起和恢复

站在 CPU 的角度上看,对于执行在普通线程中的程序来说,它会以类似这样的方式执行:

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

那么,协程是如何通过挂起和恢复来实现非阻塞的呢?

  • 大部分语言中都存在一个类似调度中心的东西,它用来实现对 Task 任务的执行和调度
  • 协程除了拥有调度中心以外,对于每个协程的 Task,还会多出一个类似 抓手、挂钩、hook 的东西,通过这个东西,我们可以方便对它进行挂起和恢复

协程任务的总体执行流程,大致会像下图描述的这样:

  • 线程的 sleep 之所以是阻塞式的,是因为它会阻挡后续 Task 的执行
  • 协程之所以是非阻塞式的,是因为它支持挂起和恢复,当 Task 由于某种原因被挂起后,后续的 Task 并不会因此被阻塞

小结

  • 广义的协程,可以理解为互相协作的程序,也就是 Cooperative-routine
  • 协程框架,封装了 Java 的线程,对开发者暴露了协程的 API
  • 程序当中运行的协程,可以理解为轻量的线程
  • 一个线程当中,可以运行成千上万个协程
  • 协程,也可以理解为运行在线程当中的非阻塞的 Task
  • 协程,通过挂起和恢复的能力,实现了非阻塞
  • 协程不会与特定的线程绑定,它可以在不同的线程之间灵活切换,这其实也是通过挂起和恢复来实现的

疑问:一个线程中能运行成千上万的协程,那么操作系统和 CPU 是如何调度的呢?毕竟操作系统和 CPU 对协程是无感知的。

猜测:协程本质上只是封装线程的框架,底层还是线程,效率并没有超越线程,只是让我们程序员使用的得更方便而已。
所以:协程只是比乱用线程要高效,但是和合理使用线程池的效率是一致的。

2016-06-21

posted @ 2016-06-21 16:35  白乾涛  阅读(7267)  评论(0编辑  收藏  举报