Kotlin-协程(一)理解协程

1,465 阅读7分钟

一、什么是协程?

如果要用简单的语言来描述协程的话,我们可以将其称为:“互相协作的程序”。

举个简单的例子,同样是 5 行代码,普通的程序,这 5 行代码的运行顺序一般会是 1、2、3、4、5;但对于协程来说,代码执行顺序可能会是 1、4、5、3、2 这样错乱的。

看下面的代码:

//调用
val sequence = getSequence()
printSequence(sequence)

fun getSequence() = sequence {
    Log.d("TAG", "ADD 1")
    yield(1)
    Log.d("TAG", "ADD 2")
    yield(2)
}

fun printSequence(sequence: Sequence<Int>) {
    val iterator = sequence.iterator()
    val i = iterator.next()
    Log.d("TAG", "printSequence:$i")
    val j = iterator.next()
    Log.d("TAG", "printSequence:$j")
}

//日志
 D/TAG: ADD 1
 D/TAG: printSequence:1
 D/TAG: ADD 2
 D/TAG: printSequence:2

我们看下yield的描述: Yields a value to the Iterator being built and suspends until the next value is requested. 翻译成中文就是:生成一个值给正在构建的迭代器,并挂起直到请求下一个值。

也就是说yield(1)返回了一个值1并挂起(Suspend)了协程函数,等待这个值被从迭代器取出后恢复(Resume)协程函数。所以我们理解协程不能按普通程序的执行顺序来理解。

二、调试Kotlin协程程序

1、打印协程名称

1)方式一:设置 VM 参数

① 在test部分(其他地方也可以)创建一个kt文件

image.png

② 编写一个Main函数随便写点协程的代码,并点击左侧的运行

image.png

③ 设置 VM 参数

这时候会发现锤子旁边变成了我们运行的KT文件

image.png

点击进入编辑

image.png

VM options填入配置-Dkotlinx.coroutines.debug=on-ea,点击确定完成配置。

image.png

再次运行就可以看到协程和线程名了

image.png

2)方式二:一行代码配置

放在输出协程名前以完成设置,或者配置到Application中也可以。Android工程中打开协程debug模式:
System.setProperty("kotlinx.coroutines.debug", "on" )

image.png

2、调试协程程序

后面的版本已经跟调试正常程序没啥差别了。Google在23年2月5号有放出一个新版的协程调试,但IDE肯定要升级到最新版,链接在此。多了一个debug的协程Coroutines窗口:

image.png

三、如何理解 Kotlin 的协程?

看一下下面的例子:

runBlocking {  //协程一
     Log.d("TAG", "first:${Thread.currentThread().name}")
     launch {  //协程二
         Log.d("TAG", "second:${Thread.currentThread().name}")
         delay(100)
     }
    //线程Sleep了一秒
     Thread.sleep(1000)
 }

输出日志

2023-02-09 14:51:17.606 18149-18149/com.example.testkotlin D/TAG: first:main @coroutine#1
2023-02-09 14:51:18.609 18149-18149/com.example.testkotlin D/TAG: second:main @coroutine#2

可以看到

  • 主线程中有二个协程@coroutine#1@coroutine#2;
  • 看输出二条日志的时间,Sleep会导致线程中的协程不能执行;
  • 协程一中开启协程二,虽然协程二开启的代码在协程一中部分代码之前,但协程二的执行在协程一之后,这是因为协程二的调度在协程一之后,不能简单的按代码顺序来理解。

用一张图表达就是

image.png

协程跟线程的关系,有点像线程与进程的关系,毕竟协程不可能脱离线程运行。所以,协程可以理解为运行在线程当中的、更加轻量的 Task。

四、协程的轻量

如果尝试启动 10 亿个线程,这样的代码运行在大部分的机器上都是会因为内存不足等原因而异常退出的。而如果我们将代码改用协程来实现的话,结果会怎样呢?

runBlocking {
    Log.d("TAG", "runBlocking:${Thread.currentThread().name}")
    repeat(1_0000_0000){
        launch(Dispatchers.IO) {
            Log.d("TAG", "launch:${Thread.currentThread().name}")
        }
    }
}

结果是可以正常运行的。

另外,协程虽然运行在线程之上,但协程并不会和某个线程绑定,在某些情况下,协程是可以在不同的线程之间切换的。我们可以来看看下面的代码:

runBlocking(Dispatchers.IO) {
    repeat(2) {
        launch {
            log("before")
            delay(100)
            log("after")
        }
    }
    delay(2000)
}

fun log(msg: String) {
    Log.d("TAG", "${Thread.currentThread().name}:$msg")
}

//输出日志
2023-02-09 17:29:54.072 28862-28912 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:before
2023-02-09 17:29:54.073 28862-28910 D/TAG: DefaultDispatcher-worker-2 @coroutine#3:before
2023-02-09 17:29:54.175 28862-28912 D/TAG: DefaultDispatcher-worker-3 @coroutine#3:after  //@coroutine#3切换了线程
2023-02-09 17:29:54.175 28862-28909 D/TAG: DefaultDispatcher-worker-1 @coroutine#2:after  //@coroutine#2切换了线程

可以看到@coroutine#2开始和结束的线程并不一致,说明协程是可以在不同的线程之间切换的

五、协程的“非阻塞”

协程对比线程还有一个特点,那就是非阻塞(Non Blocking),而线程则往往是阻塞式的。比如线程的sleep会导致线程阻塞,而协程的delayyield等只会让协程挂起,等待合适的时机恢复。比如下面的例子:

//线程阻塞
runBlocking(Dispatchers.IO) {
    repeat(2) {
        log("2")
        Thread.sleep(1000L)
        log("3")
    }
}
//日志
17:56:21.673 30716-30764 D/TAG: DefaultDispatcher-worker-1 @coroutine#1:2
17:56:22.674 30716-30764 D/TAG: DefaultDispatcher-worker-1 @coroutine#1:3
17:56:22.674 30716-30764 D/TAG: DefaultDispatcher-worker-1 @coroutine#1:2
17:56:23.674 30716-30764 D/TAG: DefaultDispatcher-worker-1 @coroutine#1:3


//协程挂起
runBlocking(Dispatchers.IO) {
    repeat(2) {
        launch {    //开启协程
            log("0")
            delay(1000L)  //挂起
            log("1")
        }
    }
}
//日志
18:05:11.148 31883-31940 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:0  //挂起coroutine#2,不会阻塞线程
18:05:11.149 31883-31938 D/TAG: DefaultDispatcher-worker-1 @coroutine#3:0  //挂起coroutine#3
18:05:12.152 31883-31940 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:1  //挂起时间到,恢复coroutine#2
18:05:12.152 31883-31938 D/TAG: DefaultDispatcher-worker-1 @coroutine#3:1  //挂起时间到,恢复coroutine#3

注意当线程阻塞时会阻塞在线程中运行的协程,协程并不会因为线程阻塞而自行切换线程继续执行任务,比如下面的例子:

runBlocking(Dispatchers.IO) {
    launch {
        for (i in 0..5) {
            if (i == 2) {
                Thread.sleep(2000L)
            } else {
                log("$i")
            }
        }
    }
}

//日志
18:23:16.189 32312-32362 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:0
18:23:16.189 32312-32362 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:1
18:23:18.190 32312-32362 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:3  //过了2秒在同一线程继续打印
18:23:18.190 32312-32362 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:4
18:23:18.190 32312-32362 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:5

我们用图来理解线程的阻塞与协程的非阻塞。

首先是线程,当某个任务发生了阻塞行为的时候,比如 sleep,当前执行的 Task 就会阻塞后面所有任务的执行。就像下面这张图所展示的一样: image.png

而协程会存在一个类似“调度中心”的东西,它会来实现 Task 任务的执行和调度。除了拥有“调度中心”以外,对于每个协程的 Task,还会多出一个类似“抓手”“挂钩”的东西,可以方便我们对它进行“挂起和恢复”。协程任务的总体执行流程,大致会像下图描述的这样:

image.png

六、总结

  • 广义的协程,可以理解为“互相协作的程序”,也就是“Cooperative-routine”。

  • 协程框架,是独立于 Kotlin 标准库的一套框架,它封装了 Java 的线程,对开发者暴露了协程的 API。

  • 程序当中运行的“协程”,可以理解为轻量的线程;

  • 一个线程当中,可以运行成千上万个协程;

  • 协程,也可以理解为运行在线程当中的非阻塞的 Task;

  • 协程,通过挂起和恢复的能力,实现了“非阻塞”;

  • 协程不会与特定的线程绑定,它可以在不同的线程之间灵活切换,而这其实也是通过“挂起

  • 和恢复”来实现的。

参考了以下内容

什么是“协程思维模型”
Kotlin 协程到底运行在哪个线程里
教程 - 使用 IntelliJ IDEA 调试协程

个人学习笔记