驽马十驾 驽马十驾

驽马十驾,功在不舍

目录
kotlin协程-1:基础概念和资料总结
/  

kotlin协程-1:基础概念和资料总结

开篇

本文是关于kotlin协程基础内容的学习总结,其中可能存在认知错误或者不够深入的地方,欢迎大佬们吐槽...

本篇文章包括如下内容:

  • 协程的描述
  • 协程的启动参数
  • 协程的创建
  • 协程的取消
  • 协程上下文切换
  • 协程的作用域
  • 其他部分

接下来应该还有协程如下资料:

  • 协程作用域
  • channel
  • select
  • 协程同步

2020年4月20日 发布第一版

什么是协程

协程和线程从中文发音来将,都是很类似的,但是他们是不同的。

  • Java中的线程是和系统的线程一一对应的,Java应用中的一个线程就是系统的一个线程。

  • 协程是运行在Java线程中的更加轻量级的调度单元

  • 协程的由来是因为线程的如下几个问题:

    • 线程的上下文切换,耗费资源多。
    • 线程在内存的资源占用上比较大。
    • 线程对资源的利用存在浪费。
  • 线程和协程功能很类似,在不让父线程停止工作的前提下,继续进行某项其他工作

    • 线程如果join调用sleep或者wait等函数,会让当前的线程阻塞,阻塞期间,这个线程就无法做其他事情了,白白浪费。
    • 协程在调用suspend方法的时候,不会阻塞,而是挂起,执行该协程的线程是可以继续进行其他操作的,从资源利用率来说,是更加的高校的。
  • 协程比线程更加可控,协程可以很简单的指定启动模式挂起恢复后的上下文

  • 协程可以让应用只是在等待响应而不是阻塞。

总结下,其特点:

  • 轻量级
  • 可控性
  • 语法糖

协程的3大参数

我们从一个典型的协程扩展方法launch来查看下这3大参数

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

如上所述分别是

  • CoroutineContext:协程上下文,其主要负责协程在那个线程被唤醒
  • CoroutineStart:协程代码启动方式,其主要负责协生命后,如何启动
  • block 也就是协程方法

协程上下文(协程分发器)

重点:协程被挂起后,当需要恢复的时候,是可以在指定的线程恢复的,那么协程上下文(协程分发器)起到的作用就是定义这个恢复的线程

在开发Android或者Swing的时候,用的特别多,因为渲染数据需要在UI线程操作。

在不同的平台下,有不同的内置的协程上下文,这里我主要描述AndroidJVM 后端

  • Default 就是一个内置的线程池,Java平台是ForkJoinPool,相比于IO更适合执行CPU密集型的任务。
  • IO 也是一个内置的线程池,其更适合执行IO操作。
  • Main 用在Android平台,Java后端代码不可用,当需要更新UI操作的时候,需要该上下文。
  • Unconfied 就是直接执行在当前线程。

除了这些内置的上下文,我们也可以定义自己需要的上下文。

  • 将线程池转为协程的上下文:asCoroutineDispatcher(),你需要注意的是关闭这个线程池
// 简单使用
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
//正规使用
    Executors.newSingleThreadExecutor().asCoroutineDispatcher().use {
        withContext(it){
            delay(1000)
            log("hello...")
        }
    }
  • 自己定义,Java后端用的少,Android应该用的比较多
class Activity : CoroutineScope by CoroutineScope(Dispatchers.Default) {

    fun destroy() {
        cancel() // Extension on CoroutineScope
    }
    // to be continued ...

    // class Activity continues
    fun doSomething() {
        // launch ten coroutines for a demo, each working for a different time
        repeat(10) { i ->
            launch {
                delay((i + 1) * 200L) // variable delay 200ms, 400ms, ... etc
                println("Coroutine $i is done")
            }
        }
    }
} // class Activity ends

协程启动模式

一个协程的代码定义好后,如何启动是可以有不同的定义的:

public enum class CoroutineStart {
  
    DEFAULT,

    LAZY,

    @ExperimentalCoroutinesApi
    ATOMIC,

    @ExperimentalCoroutinesApi
    UNDISPATCHED;
}

我们接下来就来看看这几个不同的区别

  • DEFAULT 是默认的 启动模式,父线程中的定义好协程后会马上启动,同时不会阻塞父线程的原有执行逻辑。说人话就是:父线程和子协程一同执行。

  • ATOMIC 在执行逻辑上,同DEFAULT,都是父线程和子协程一同执行,不过不同点在于:DEFAULT可以在子协程还没有进行第一次启动的时候就取消,AUTOMIC却不可以。

    请注意:协程在执行了第一次后,不管启动模式是DEFAULT还是ATOMIC都是可以取消的。

  • LAZY 如同字面意思一样,:协程定义好后,不会马上执行,而是在调用其join或者await后才开始执行。

  • UNDISPACHTERD 是比较特殊的启动模式,通过该参数启动的协程,会将父线程的运行权抢夺过来,所以此时的父线程是阻塞着的,直到协程被挂起,父线程才能继续往下运行。

其中只有DEFAULT和LAZY 是稳定的,其余2者都是实验性的API.

从JVM的服务器应用场景来看,目前只用到了DEFAULT

协程体

就是核心的执行代码。

如何启动协程

  • runBlocking:线程和协程的承上启下。
  • launch:启动一个不带返回值的Job
  • async: 启动一个带返回值的协程,返回值为Deferred,可以通过await获取结果

上面我们提到了协程的3大参数,那么我们在使用协程构建器的时候,必须清晰的认识到,使用如下这些协程构建器的时候,这3大参数分别是什么。后面分析!

runBlocking

通常会用到线程和协程的入口处,起到的承上启下作用。他的作用是阻塞线程的执行,直到runBlocking内部协程执行完毕为止。

这里需要强调一点的是:通过Globalscope.xxx的可不是内部协程

fun main()= runBlocking {
    launch {
        delay(2000)
        println("...2...")
    }
    println("...1...")
}

上述结果为如下所示,此时的launch{}没有指定上下文,默认用的是 runBlocking的上下文哦,不是默认的DEAULT

...1...
...2...

Process finished with exit code 0

但是如果我将launch()修改为GlobalScope.luanch{}:

fun main()= runBlocking{
  	//这里加了 GlobalScope
    GlobalScope.launch {
        delay(2000)
        println("...2...")
    }
    println("...1...")
}

那么结果如下可以看出:没有输出2。

原因如上所述,通过GlobalScope启动的协程已经不是runBlocking里面的协程了,而是顶级协程了,所以runBlocking 不会等待这个顶层的协程执行结束。

...1...

Process finished with exit code 0

这一点希望大家在使用的时候,特别注意了。

launch和async

  • launch启动的是一个不带返回值的协程,返回值为Job;

  • async刚好相反:返回的是一个带有返回值的表示未来会有结果的一个类型:Deferred<T>

JobDeferred的关系如下面源码所示:

public interface Deferred<out T> : Job {
    public suspend fun await(): T
}
  • Deferred是继承自Job的
  • Deferred比Job多了一个方法await来获取结果

我们这里来看下一个async的用法示例:

todo

其他:上下文

既然协程有上下文启动模式,那么上述的runBlockinglaunchAsync在不同情况下,对应的上下文分别是什么了?

我们这里可以分为2类来看看这 3 者源码的签名是什么样的!

第一类:async 和 launch

// 
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {}

//
public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> {}

第二类:runBlocking

public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {

不管是我分的第一类还是第二类,它们对于context这 3 者默认的上下文都是EmptyCoroutineContext,那么这个空的上下文是一个什么东西了?

在第一类,也就是asynclaunch中,源码注释中给出的EmptyContext说明:

 * The coroutine context is inherited from a [CoroutineScope]. Additional context elements can be specified with [context] argument.
 * If the context does not have any dispatcher nor any other [ContinuationInterceptor], then [Dispatchers.Default] is used.

简单翻译如下:

* 协程的上下文从[CoroutineScope]继承。你也可以通过[context]来指定特定的上下文。
* 当上下文中没有任何分发器或者[ContinuationInterceptor]的时候,上下文就是[Dispatchers.Default]

第一类asynclaunch的上下文规则如下:

  1. 如果启动协程的时候,有指定的协程上下文,那么协程就采用指定的这个上下文。
  2. 如果启动协程的时候,没有指定协程上下文,但是上下文存在协程上下文,那么就继承该协程上下文。
  3. 如果启动协程的时候,没有指定协程上下文,上下文也不存在协程上下文,那么就是Dispatchers.Default

第二类runBlocking不一样,其上下文规则是这样的:

  1. 如果启动协程的时候,有已经存在的上下文,那么就选择这个上下文
  2. 如果启动协程的收,还不存在上下文,那么就选择在这个线程做上下文。【不同于async 和 launch 的线程池上下文】

我们通过代码来深化理解:

import kotlinx.coroutines.*
import java.util.concurrent.Executors

fun main() {
    //没有指定,也没有协程上下文可以继承,那么就是 Dispatcher.DEFAULT
    GlobalScope.launch {
        println("1.${Thread.currentThread().name}") //第一类3:默认线程池
        delay(1000L)
        println("2.${Thread.currentThread().name}") //第一类3:默认线程池
        runBlocking {
            println("6.${Thread.currentThread().name}") //第二类1:默认线程池
        }
    }


    Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { ct->
        //指定了特定的上下文
        GlobalScope.async(ct) {
            println("3.${Thread.currentThread().name}") //第一类1:自定义现场
            "SUCCESS"
        }
    }

    //没有指定,同时没有
    runBlocking {
        println("4.${Thread.currentThread().name}") //当前的启动线程:main
        //没有指定,但是有继承自 runBlocking 的上下文 main
        launch {
            println("5.${Thread.currentThread().name}") //继承上下文:main
        }
        delay(3000)
    }
}

1.DefaultDispatcher-worker-1 @coroutine#1
3.pool-1-thread-1 @coroutine#2
4.main @coroutine#3
5.main @coroutine#4
2.DefaultDispatcher-worker-1 @coroutine#1
6.DefaultDispatcher-worker-1 @coroutine#5

如何取消协程

协程是可以被取消的,同时父协程取消也会让对应的子协程一同被取消,通过调用cancel()取消协程。

  • 协程在每次resume的时候,会判定一次是否能取消,比如下面代码

    val job = launch {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L) //resume了一次,此时判定
        }
    }
    delay(1300L) // 延迟一段时间
    println("main: I'm tired of waiting!")
    job.cancel() // 取消该作业
    job.join() // 等待作业执行结束
    println("main: Now I can quit.")
    
  • 那么对于计算密集型任务,中间不会有resume的时候,建议通过isActive这个状态来判定是否需要取消。

    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (isActive) { // 可以被取消的计算循环
            //下面代码没有发生协程切换的操作,所以在不加isActive的情况下,也不会被取消,因为没有机会判定
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // 等待一段时间
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消该作业并等待它结束
    println("main: Now I can quit.")
    

同时取消父协程会导致其下所有子协程的也被取消!

关于取消的其他相关知识,请参考地址:协程的取消

切换上下文

做界面开发,比如Android或者Swing的朋友,用的比较多。

启动了某个协程,做完某个工作后,需要重新切换到另外一个上下文进行后续工作,怎么办?

此时withContext()的作用就来了,我们看看下面的例子,这个例子在Android的场景下非常适合:

    suspend fun doSomeWork(): String {
        println("do....")
        delay(1000)
        return "data..."
    }
  
	launch(Dispatchers.IO) {
        val data=doSomeWork()
        withContext(Dispatchers.Main){
            label=data
        }
    }
  • doSomeWork本来运行在IO线程中的,获取结果后需要将数据渲染到UI线程上。
  • withContext(Dispatchers.Main)这一段简单的代码就将协程切换到了UI线程了。

协程作用域

协程作用域就是将某些有关联的逻辑在同一个作用域内起作用,他们拥有同一个coroutineContext,所以他们彼此有关系,这样可以统一来控制他们的取消。

GlobalScope

该方法是创建协程作用域最常见的一个方法,创建的是底层的协程,通常起到线程协程连接的作用:对外阻塞线程,对内等待协程结束。

coroutineScope

比如如下代码就是在同一个协程作用域内,当某个协程出现问题后,可以统一的将其取消。

suspend fun showSomeData() = coroutineScope {
  	//1、通过 io 执行某些操作
    val data = async(Dispatchers.IO) { // <- extension on current scope
     ... load some UI data for the Main thread ...
    }
  	//2、在主线程进行展示
    withContext(Dispatchers.Main) {
        doSomeWork() //2.1 调用某个方法
        val result = data.await() //2.2 等待 1中的方法的执行结果
        display(result) //2.3 调用显示方法
    }
}
  • 上述代码就是通过coroutineScope包裹的作用域
  • 其中任何一个方法doSomeWork或者获取 data 的过程,获取 display 的任何一个方法抛出异常,都会导致在该作用域内的协程被取消。
  • 同时在使用coroutineScope的情况下,异常会往上抛。

supervisorScope

    @Test
    fun testSupervisor()= runBlocking {
        //子协程互不相干
        supervisorScope {
          	//协程 1
            launch {
                delay(200)
                println("1")
            }
          	//协程 2
            launch {
                delay(100)
                throw RuntimeException("xxx")
                println("2")
            }
          	//协程 3
            launch {
                delay(300)
                println("3")
            }
        }
        return@runBlocking
    }

在上述例子中,虽然协程 2 抛出了异常,但是不会导致协程 1 和协程 3 被取消,1 和 3 仍然会执行下去。

你可以将supervisorScope 改为 coroutineScope ,看看效果如何!

其他

  1. IDE中可以添加vm参数-Dkotlinx.coroutines.debug,这样当我们利用代码Thread.currentThread().name获取线程名的时候,会将协程名称也统一获取到,比如:main @coroutine#5
  2. delay不同于sleep的一点在于,不会阻碍线程的执行,只是将协程挂起。

问题

  1. 协程的调度也是在线程上的,难道能够保证协程启动一定是在抢占了CPU的那个线程吗?
  2. suspend 修饰的方法的目的是什么?反编译查看,应该是传递Coroutine这个东西。
不积跬步,无以至千里。不积小流,无以成江海。