Kotlin 协程 | 青训营

87 阅读11分钟

这是我参与「第四届青训营 」笔记创作活动的第10天

相关术语

  • 并发concurrency:代码单元无序执行(无论是并发还是分时),并且仍能获得正确结果的能力
  • 并行parallelism:同时执行多个代码单元
  • 多任务multitasking:与并行相同
  • 多线程multithreading:使用线程来实现的并行
  • 异步asynchrony:在等待结果的同时不阻塞主线程,即主线程之外的非阻塞操作

解决同步问题

  • 最佳解决方案:并发数据结构、锁和避免共享可变状态
    • 并发数据结构可以允许并发访问,且不会破坏存储数据的完整性,且可用于共享可变状态,有助于并行编程
    • 锁可以每次只允许一个并发单元对关键部分进行访问以避免竞争条件
    • 避免共享可变状态,如:并发单元仅通过消息传递进行通信,避免了共享可变状态

进程、线程、协程

  • 关系:一个进程可以包含多个线程,一个线程可以运行(注意,这里不是包含,因为协程不会绑定到某一具体的线程上,如一个协程可以在一个线程中启动、暂停,但在另一个线程中恢复)多个协程

Kotlin协程

suspend

  • 作用:suspend关键字可以将普通函数转变为挂起函数

  • 理解:suspend相当于挂起提醒,如果被suspend修饰的函数内没有需要被真正挂起的操作,那么即使是被suspend关键字修饰了,也不会在运行时候被真的挂起

  • 实现过程:

    • 首先,需要知道的是,协程之所以能够用同步的代码,写出异步的效果,其实并不是魔法,其原理和很多其他函数调用是一样的,只不过,这里调用的是一个耗时函数。因此,在分析协程的实现过程之前,我们可以对比一下普通函数的调用过程。
    • 普通函数的调用过程:
      • 稍微了解过汇编中的函数调用的过程的应该都知道,在调用一个函数之前,需要先将当前的状态保存,在汇编中表现为将eaxesp等寄存器的值入栈保存,即保存现场/保存上下文然后才将程序的执行掌控权交给要调用的子函数,当其执行完后,就会将之前保存进栈的内容弹出,即还原现场,此时就可以重新回到原函数进行执行下面的代码而不出错了
    • 协程中挂起函数的调用:
      • 与上面的普通函数的调用过程相似,挂起函数在执行的时候,也要先保存当前的上下文,然后再将程序执行的掌控权给到挂起函数中进行执行,待到挂起函数执行完了,就会还原上下文,同时接着往下执行。只是与普通函数的调用略有不同的是,在切换到挂起函数的时候,需要将对应的上下文(准确点说,应该是挂起函数之后的操作Continuation)传入(这一点由Kotlin自己完成),也正是因为挂起函数的调用过程与普通函数的调用过程类似的特性,所以挂起函数也能拥有类似普通函数的性质,比如,普通函数并不依赖其他函数存在,普通函数可以在任何其他函数中被调用,类比到协程中的挂起函数,就表现为,挂起函数并不会绑定到线程中,且可以在任何线程中调用,同样的,在一个线程中开启、暂停,到另一个线程中恢复的操作也是允许的。因为Kotlin会自动帮你传递需要的上下文。
  • 需要注意的是,挂起函数不能是入口函数,这一点也好理解,就像我们自定义的函数,一定要通过其他函数来调用一样,挂起函数本身不能成为入口,所以挂起函数只能从另一个挂起点(挂起函数、可挂起Lambda表达式、协程或者内联到协程的Lambda表达式)中调用,而所有的挂起点的最终启动就是协程构建器

协程构建器

  • 分类:

    • launch:用于没有返回值的即用即弃的操作

    • async:用于有返回结果(或异常)的操作

    • runBlocking:用于桥接阻塞与非阻塞的世界

  • 注意:

    • 处于活动状态的协程不会阻止程序的退出,即如果程序已经到了退出出口,但协程仍有内容没执行,则程序不会等待协程执行完再退出,而是会直接退出

      import kotlinx.coroutines.delay
      import kotlinx.coroutines.*
      suspend fun doSomething(){
          println("耗时中...")
          delay(2000L)
          println("耗时结束")
      }
      
      
      fun main() {
          println("协程开始前:")
           GlobalScope.launch {
               doSomething()
           }
      }
      
      // 运行结果:
      协程开始前:
      

      从上面测试代码可以看出,协程不能阻止程序的退出,否则结果将为:

      协程开始前:
      耗时中...
      耗时结束
      

      当然,如果是使用runBlocking,则可以阻塞住主线程,从而达到延缓程序退出的效果

    • 协程的取消,必须是针对可取消的内容进行取消的,否则无法取消成功

      import kotlinx.coroutines.*
      fun main() {
          runBlocking {
              val job = GlobalScope.launch {
                  repeat(10){
                      Thread.sleep(300L) // Thread.sleep是不可取消的函数
                      println("${it + 1} of 10 done")
                  }
              }
              delay(1000L)
              job.cancelAndJoin() // 取消会失败,因为sleep是不可取消的
          }
      }
      
      // 运行结果:
      1 of 10 done
      2 of 10 done
      3 of 10 done
      4 of 10 done
      5 of 10 done
      6 of 10 done
      7 of 10 done
      8 of 10 done
      9 of 10 done
      10 of 10 done
      

      从上面代码可以看出,即使是主动对协程进行取消,也许其内部的代码也是可取消的,才能将协程取消,否则无法成功取消/停止,所以,在使用协程时,一个基本原则是在协程代码内部尽量使用可取消的可挂起代码

runBlocking

  • 作用:作为协程构建器创建并启动一个新的协程,并会阻塞当前线程,直到其中传递的代码块执行完成

  • 特点:使用runBlocking的线程,仍然可以被其他线程中断,但不能执行任何其他代码,即会阻塞在 runBlocking代码块处,因此,不能在协程中调用runBlocking,否则协程就被阻塞了,这与协程应该是非阻塞的初衷相矛盾

  • 一般用途:主要用于main函数和单元测试

  • 值得注意的是,runBlocking本质上是一个函数,而不是关键字,下面给出其函数签名:

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

    如上所示,suspend关键字修饰的Lambda参数,就称为挂起函数类型挂起Lambda表达式

  • 一般调用:

    import kotlinx.coroutines.delay
    import kotlinx.coroutines.*
    suspend fun doSomething(){
        println("耗时中...")
        delay(2000L)
    }
    
    
    fun main() {
        runBlocking {
            println("耗时调用前:")
            doSomething()
            println("耗时调用后")
        }
    }
    // 运行结果:
    耗时调用前:
    耗时中...
    耗时调用后
    

launch

  • 作用:用于创建那些独立于主程序而执行且没有返回值的即用即弃的协程

  • 值得注意的是,launch与前面的runBlocking不同,launch不会阻塞住当前线程,即launch启动的协程,会依附到目标线程中,并被延迟执行,即将其加入延迟执行队列,待得当前线程空闲了,才会执行延迟执行队列里的任务

  • 实例代码:

    import kotlinx.coroutines.delay
    import kotlinx.coroutines.*
    
    suspend fun doSomething(){
        println("耗时中...")
        delay(2000L)
        println("耗时结束")
    }
    
    fun main() {
        runBlocking { 
            val job = GlobalScope.launch { 
                println("协程开始...")
                doSomething()
                println("协程结束...")
            }
            
            println("主线程开始...")
            job.join()
            println("主线程退出...")
            
        }
    }
    // 运行结果:
    主线程开始...
    协程开始...
    耗时中...
    耗时结束
    协程结束...
    主线程退出...
    

    从上面的测试结果可以看出,launch启动的协程,不会阻塞当前线程的执行,且会在线程空闲的时候,再执行相应的代码

async

  • 作用:用于创建会返回结果或异常的异步调用

  • 一般用途:用于REST API请求从数据库中提取条目从文件中读取内容引入等待时间和提取某些数据等操作

  • 注意:

    • async可以用于有返回值的异步调用,其返回值为Deferred<T>,当要获得实际的延迟调用的返回值时,必须对其调用await函数
    • Deferred是Kotlin协程库的轻量级future实现,与future类似的,返回值的类型T被包裹在Deferred<T>
    • launch不同的是,async会等待await任务完成,并接收其返回值,值得注意的是,这里的等待,是非阻塞式的等待,即,如果一个表达式有两个await任务需要执行,则不会等一个await执行完再执行下一个,而是会在需要耗时等待的时候,直接到下一个await进行执行,因此一个表达式中,多个await的等待时间,可以近似看成是耗时最长的那一个await的耗时。
    • kotlin中的asyncawait都是函数,而不是关键字
  • 实例代码:

    import kotlinx.coroutines.delay
    import kotlinx.coroutines.*
    import java.util.Date
    
    
    fun firstAsync() = GlobalScope.async {
        delay(2000L)
        12
    }
    
    fun secondAsync() = GlobalScope.async {
        delay(2000L)
        6
    }
    
    fun main() {
        runBlocking {
            println(Date())
            val first = firstAsync()
            val second = secondAsync()
            val res = first.await() / second.await()
            println(res)
            println(Date())
        }
    }
    // 运行结果:
    Fri May 27 17:06:00 CST 2022
    2
    Fri May 27 17:06:02 CST 2022
    

    从上面代码可以看到,第21行有两个await任务,但整体耗时只有2s,这符合asyncawait非阻塞式等待的结果,如果将上述第19~21行代码改成以下,则:

    val first = firstAsync().await()
    val second = secondAsync().await()
    val res = first / second
    

    此时的运行结果为:

    Fri May 27 17:10:52 CST 2022
    2
    Fri May 27 17:10:56 CST 2022
    

    可以看到,此时因为await是分开的,所以与普通的耗时等待并没有不同,也就是要分别等待两个await的耗时才年往下执行,因此耗时为4s,也就是说此时非阻塞式等待的特性就展现不出来了,所以代码的调用顺序在kotlin中,也有很大学问,甚至可以让效率提高数倍

  • 总结:

    • launchjoinasyncawait类似,其中launchasync都用于启动一个并行执行工作的新协程,只不过在launch中使用join来等待任务执行完成,而在async中使用await来等待结果
    • launch返回job类型,而async返回Deferred类型,但是Deferred也实现了Job接口,所以同样可以对Deferred使用canceljoin,只不过用的少而已

协程的上下文

  • 协程上下文CoroutineContext:所有的协程构建器都接收一个CoroutineContext,这是一组诸如协程名称协程的调度器协程任务详细信息索引元素,即记录了协程相关的信息的索引
  • 重要组成元素:
    • CoroutineDispatcher:用来决定协程在哪个线程上运行 \star
    • Job:提供了执行相关的详细信息,可用于生成子协程 \star
    • CoroutineName
    • CoroutineExceptionHandler

协程调度器CoroutineDispatcher

  • 作用:当协程需要恢复的时候,就需要调度器决定将该协程在哪个线程上进行恢复

  • 如何指定恢复到哪个线程上:

    • 在上面提到的协程构建器函数中,进行传参,传入目标线程即可
上下文withContext
  • 作用:提供特定的上下文,以便将协程的部分内容运行在特定的上下问中,如在UI线程上更新UI操作

  • 注意:

    • 在同一个线程上的协程之间切换,代价远比线程之间的切换要小得多,但如果是不同线程上的协程之间切换,就不仅有协程切换带来的代价,同时还要考虑线程切换带来的代价开销
    • 除非需要并行运行多个异步调用,否则当希望从挂起函数返回结果时,用withContext的方法,通常比async-await方法更好
  • 实例:

    import kotlinx.coroutines.*
    
    suspend fun updateWeather(userId: Int){
        val user = fetchUser(userId)
        val location = fecthLocation(user)
        val weatherData = fetchWeather(location)
        // 指定在UI主线程上下文中更新UI
        withContext(UI){
            updateUI(weatherData)
        }
    }
    
    fun main() {
        GlobalScope.launch { 
            updateWeather(userId = userId)
        }
    }
    
调用超时withTimeout
  • 作用:可以方便地指定调用超时时间,并会抛出超时异常

  • 实例:

    import kotlinx.coroutines.*
    fun main() {
        runBlocking {
            withTimeout(1200){
                repeat(10){
                    delay(500L)
                    println("${it + 1} of 10")
                }
            }
        }
    }
    //运行结果:
    1 of 10
    2 of 10
    Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1200 ms
    

CoroutineContext参数

  • launchasync因为是非阻塞式的,所以,可以传递相应的上下文CoroutineContext参数,来指定该协程在哪个线程上下文中挂起,而CoroutineContext的几个重要组成元素:CoroutineDispatcherJobCoroutineNameCoroutineExceptionHandler本质上都是其子类,所以,传递的时候,只要是这几个类型的,都可以作为CoroutineContext的值传入

  • 这里以Job为例,说明JobCoroutineContext的子类:

    首先根据Job的定义处:

    public interface Job : CoroutineContext.Element {...}
    

    再往上看Element定义处:

    public interface Element : CoroutineContext {...}
    

    到此,从上面的分析可以看出,Job就是CoroutineContext的子类,其他几个组成元素也大同小异

  • 那么问题来了,不管是launch还是async,其CoroutineContext参数就那么一个,而其子类又那么多个,如果要有多个类型要传递,那要怎么处理呢?

    • 很好解决,CoroutineContext类重载了plus函数,其作用是,将CoroutineContext类型的值作为操作数进行相加,所以,如果我们有多个值需要传,就可以直接利用+运算进行操作。

    • 需要注意的是,对于CoroutineContextplus操作,如果各个参数之间有相同的属性,则在右侧的元素会覆盖掉左侧中元素的相同属性值

      • plus运算符签名:

        public operator fun plus(context: CoroutineContext): CoroutineContext
        
    • 实例:

      val name = CoroutineName("Corouter")
      val exceptionHandler = CoroutineExceptionHandler{context,exception->exception.printStackTrace()}
      launch(name + exceptionHandler){...}
      
  • 至此,我们就解决了CoroutineContext传参的问题了,那么,我们应该怎么取出来呢?

    • 这个kotlin协程早就为我们解决了,每个协程都会携带一个CoroutineContext,并且是可以从其内部进行访问的,而且更方便的是,正如前面介绍所说的,该CoroutineContext是由一组索引元素组成的,所以,可以直接用key-value的方式访问其中特定的元素

    • 实例:

      val name = CoroutineName("Corouter")
      val exceptionHandler = CoroutineExceptionHandler{context,exception->exception.printStackTrace()}
      launch(name + exceptionHandler){
          val coroutineName = CoroutineContext[CoroutineName]
          val exceptionHanlder = CoroutineContext[CoroutineExceptionHandler]
      }
      

协程作用域CoroutineScope

  • CoroutineScope即协程运行的作用域,这里提供其源码定义:

    public interface CoroutineScope {
        public val coroutineContext: CoroutineContext
    }
    
  • 从上面的源码定义可以看出CoroutineScope的代码很简单,主要作用是提供CoroutineContext,因为启动协程需要 CoroutineContext

  • 作用:作用域可以管理其域内的所有协程。一个CoroutineScope可以有许多的子scope。协程内部是通过 CoroutineScope.coroutineContext自动继承自父协程的上下文。而CoroutineContext就是在作用域内为协程进行线程切换的快捷方式。

  • 注意:当使用GlobalScope来启动一个协程时,则新协程的作业没有父作业。 因此它与这个启动的作用域无关且独立运作。GlobalScope包含的是EmptyCoroutineContext

    • 一个父协程总是等待所有的子协程执行结束。父协程并不显式的跟踪所有子协程的启动,并且不必使用Job.join 在最后的时候等待它们。
    • 取消父协程会取消所有的子协程所以使用 Scope 来管理协程的生命周期
    • 默认情况下,协程内,某个子协程抛出一个非CancellationException 异常,未被捕获,会传递到父协程,任何一个子协程异常退出,那么整体都将退出

创建CoroutineScope

创建一个CoroutineScope, 只需调用public fun CoroutineScope(context: CoroutineContext)方法,传入一个CoroutineContext对象。

  • 示例:

    val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
    val myScope = CoroutineScope(dispatcher)
    myScope.launch {
        ...
    }
    

生成器

  • 定义:生成器是一个可以按需要惰性地生成值的迭代器,并且使用yield函数将值发送出去。值得注意的是,虽然它的实现看起来像是连续的,但在请求下一个值之前,其执行将被挂起;且生成器总是控制它的调用者,而协程可以控制其他协程;最后,sequence可以进行多次迭代,且是无状态的

  • 实例:

    fun main() {
        val fibonacci = sequence {
            yield(1)
            var a = 0
            var b = 1
            while (true){
                val next = a + b
                yield(next)
                a = b
                b = next
            }
        }
    
        for (item in fibonacci.take(10)) {
            println(item)
        }
    }
    // 运行结果:
    1
    1
    2
    3
    5
    8
    13
    21
    34
    55
    

    从上面的示例中可以看出,sequence与协程构建器类似,都接收一个挂起Lambda,但它本身不是挂起函数,而是使用协程来计算新的值。yield是一个挂起函数,而不是一个异步函数(也就是说,yield并不会进行异步操作),它会将执行挂起直到下一个元素被请求

  • 这里附上sequence的函数签名:

    public fun <T> sequence(@BuilderInference block: suspend SequenceScope<T>.() -> Unit): Sequence<T> = Sequence { iterator(block) }
    

actorschannels

actors

  • 定义:一个actor是由协程、被限制并封装到该协程中的状态以及一个与其他协程通信的通道channel组合而成的一个实体。简单点理解,actor就像是一个收发器,即可以接受其他收发器发送来的消息,也能向其他收发器发送消息,channel就是收发信息的管道,消息可以在上面进行传输

  • 注意:

    • actor不会直接共享可变状态,actoractor之间,只能通过传递消息来实现通信,所以每个actor都会附加一个消息通道以便能够接收消息。而且actor能基于所接收到的消息来决定其接下来的行为,如生成更多的actor、发送消息、操纵其私有状态等。
    • actor之间不会存在竞争条件,所以在没有共享状态时,不需要使用锁机制
  • actorchannel的关系:

    • actor可以与channel建立多对多的关系,这么做的目的是,单个actor可以读取多个channel中的信息,同样的,多个actor也可以从同一个channel中读取消息,值得注意的是,虽然这是一个并大模型,但actor自身是按照顺序来工作的,即如果接收到多条信息,则会按照顺序对接收到的信息进行处理
  • 创建使用actor

    • 创建:使用kotlin的actor函数即可创建一个actor实例,并将其用来通信
      • 本质上,该函数是另一种协程构建器,因为在kotlin中,actor被认为是协程
  • 示例:

    简单引入:

    import kotlinx.coroutines.GlobalScope
    import kotlinx.coroutines.channels.actor
    import kotlinx.coroutines.runBlocking
    
    fun main() {
        val actor = GlobalScope.actor<String> {
            val message = channel.receive()
            println(message)
        }
    
        runBlocking {
            actor.send("Hello,I am an actor!")
            actor.close()
        }
    }
    
    // 运行结果:
    Hello,I am an actor!
    

    注意:

    • sendreceive都是挂起函数,当channel已满时,send会暂停执行,而当channel为空时,receive会暂停执行
    • 所以,main函数必须使用runBlocking来进行包装以便能够调用挂起的send
    • 调用close并不会立即停止actor协程,相反,close会发送特殊的消息close tokenchannel中,channel仍然会按照先进先出的方式读取消息队列,所以该特殊消息之前的所有消息都会在实际停止之前处理,即close也是一个消息,只有其前面的消息执行完了,才会执行该close 消息

    channelactor

    fun main() {
    
        runBlocking {
            val channel1 = Channel<Int>()
            val channel2 = Channel<Int>()
    
            GlobalScope.launch {
                while (true){
                    select<Unit> {
                        channel1.onReceive{ println("channel 1 $it") }
                        channel2.onReceive{ println("channel 2 $it") }
                    }
                }
            }
    
            channel1.send(17)
            channel1.send(42)
            channel1.close()
            channel2.close()
        }
    }
    // 运行结果:
    channel 1 17
    channel 1 42
    
    • select更倾向于执行第一条子句,也就是说,如果同时有多条子句可供选择的话,就会选择第一条子句

    同一个channel上的多个actor

    import kotlinx.coroutines.channels.Channel
    import kotlinx.coroutines.channels.consumeEach
    import kotlinx.coroutines.channels.take
    import kotlinx.coroutines.launch
    import kotlinx.coroutines.runBlocking
    
    fun main() {
    
        runBlocking {
            val channel = Channel<String>()
    
            repeat(3){n ->
                launch {
                    while (true){
                        channel.send("Message from actor $n")
                    }
                }
            }
    
            channel.take(10).consumeEach { println(it) }
            channel.close()
        }
    }
    // 运行结果:
    Message from actor 0
    Message from actor 0
    Message from actor 0
    Message from actor 1
    Message from actor 2
    Message from actor 0
    Message from actor 0
    Message from actor 0
    Message from actor 1
    Message from actor 2