Kotlin 协程(一)

Come and Meet Kotlin Coroutine

Tags of Kotlin Coroutine

Kotlin协程可以被理解为一种轻量级的线程,它具有挂起恢复的特点,可以将我们从异步编程的回调陷阱中解放出来

下面我们一一来看给协程贴上的标签如何理解:

  1. 挂起和恢复

    1. 挂起函数(suspend function)

    协程最吸引人的特点就在协程的挂起和恢复特性上,通过这个特性我们能够像编写同步代码一样简化异步回调。这种特性在Kotlin语言层面表现为suspend关键字:

    // suspend function
    suspend fun function1() {
        delay(1000L)
        println("suspend function1")
    }
    
    // normal function
    fun function2() {
    //    delay(2000L) not satisfy structural concurrency
        println("suspend function2")
    }
    
    // type check:
    val funcVal1: suspend () -> Unit = ::function1
    val funcVal2: () -> Unit = ::function2
    

    相比普通的函数,suspend函数可以理解为一种新的函数类型。

    1. 协程构建器(Coroutine Builder)

    launch async runBlocking是三种常见的协程构建器,我们从函数签名上【感性】地认识一下他们的区别:

    // launch
    public fun CoroutineScope.launch(
        context: CoroutineContext = EmptyCoroutineContext,
        start: CoroutineStart = CoroutineStart.DEFAULT,
        block: suspend CoroutineScope.() -> Unit
    ): Job
    
    // aysnc
    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 
    

我们通常可以使用launch启动一个协程,他的返回值Job可以用于控制这个协程的生命周期, async可以看做是一个升级版的launch,他的block的返回值会被放在Deferred中。DeferredJob的子类,可以通过await方法获取返回值:

fun main() = runBlocking {

    val job = launch {
        println("execute of job")
        "execute of job" // launch中的block不考虑返回值,lambda的返回值会被忽略
    }
    val deferred = async {
        println("execute of deferred")
        "result of deferred"
    }

    println(deferred.await())

    Unit
}

/**
    execute of job
    execute of deferred
    result of deferred
**/

launchasync默认都是写了之后立刻启动(这一点非常重要,aysnc并不需要await触发执行),可以通过调整CoroutineStart参数变更启动方式:

fun main() = runBlocking {

    val lazyJob = launch(start = CoroutineStart.LAZY) {
        println("execute now!")
    }

    println("before lazy starts")
    // 通过delay先让父协程挂起,明显去别处launch没有立刻执行
    println("parent sleeps")
    delay(1000L)
    println("parent wakes up")
    lazyJob.start()

    Unit
}

/**
    before lazy starts
    parent sleeps
    parent wakes up
    execute now!
**/
  1. 理解挂起和恢复

下面我分别在两个suspend函数和两个由launch发起的协程中delay两秒,请问main函数执行完成分别需要几秒?

  • suspend函数
fun main() = runBlocking {
    getUserInfo()
    getFriendList()
}

suspend fun getUserInfo() {
    println("getUserInfo: start time: ${System.currentTimeMillis()}")
    delay(2000L)
    println("getUserInfo: end time: ${System.currentTimeMillis()}")
    logX("suspend function1")
}

suspend fun getFriendList() {
    println("getFriendList: start time: ${System.currentTimeMillis()}")
    delay(2000L)
    println("getFriendList end time: ${System.currentTimeMillis()}")
    logX("suspend function2")
}
  • Launch
fun main() = runBlocking {
   launch {
       println("launch1: start time: ${System.currentTimeMillis()}")
       delay(2000L)
       println("launch1: end time: ${System.currentTimeMillis()}")
       logX("launch1")
   }

    launch {
        println("launch2: start time: ${System.currentTimeMillis()}")
        delay(2000L)
        println("launch2: end time: ${System.currentTimeMillis()}")
        logX("launch2")
    }

    Unit
}

答案揭晓时刻:

suspend函数需要4秒,launch需要2秒。我们来看看挂起函数和launch的执行模型:

截屏2022-04-16 下午4.00.59.png

suspend函数和launch这类的协程构建器是有本质上的不同的,suspend函数在Kotlin编译器的作用下会变成一个自动机,而launch这类都不是suspend,他们其实是将任务【分发】到线程池(在JVM平台上)上实现的执行。

suspend和协程构建器的结合之处就在await上:

public suspend fun await(): T

await是一个挂起函数,后续的流程会像上图以上被挂起,我们来看这个例子:

fun main() = runBlocking {
    val def = async {
        println("async starts")
        delay(2000L)
        println("async end")
        "hello world"
    }

    println("message from main")
    println(def.await())
    println("end of story")
}

/**
    message from main
    async starts
    async end
    hello world // end of story的输出被挂起到await执行完成再恢复
    end of story
**/

suspend函数到自动机的转换在最后一节会说明。Kotlin Coroutine狭义的协程指的是通过构建器启动的协程,后文不再说明。

  1. 轻量级的线程

    1. 如何理解【轻量级】

    在下面的代码中我们开启了很多个协程,但是等量的线程会OOM

    fun main() = runBlocking {
        repeat(1000_000_000) {
            launch { //常见的协程
                delay(1000000)
            }
        }
    
        delay(10000L)
    }
    
    1. Kotlin Coroutine VS Thread

协程本身是运行在线程池上的:

fun main() = runBlocking {

    logX("main ")
    val job = launch(Dispatchers.IO) {
        logX("launch 1")
    }
}

/**
================================
main 
Thread:main @coroutine#1
================================
================================
launch 1
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
**/ 

Dispatchers就可以指定运行的线程池。


d89e8744663d45635a5125829a9037a9.gif

Structured Concurrency

结构化并发的思想贯穿于Kotlin coroutine的始终,我通过一句话概述:控制协程执行的范围。这个范围使用CoroutineScope实现。因为上面的代码都运行在runBlocking中,传入参数的时候直接将block设置为CoroutineScope的扩展lambda,所以不需要再指定scope:

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

包括suspend函数也需要运行在scope中,否则就会在编译期报错。

Suspend Function : A CPS Transformation

Kotlin编译器会对挂起函数进行转换,如图所示:


784ce5776def5255e6d300cd5890a6yy.gif

这种转换在Kotlin中被称为CPS(continuation-passing-style)转换,Continuation可以理解为是存储了中间过程的Callback。下面我们具体看一个例子:

要注意什么?

  1. 编译后新增加的匿名内部类:TestContinuation

  2. 看【挂起】和【恢复】的逻辑:invokeSuspend

下面代码将编译前的挂起函数和编译后的挂起函数进行了一个比较,在编译后的testCoroutine中增加了一个新的匿名内部类,TestContinuation,其中记录了获取的结果的信息,同时注意看invokeSuspend方法,这个方法有点像递归,最后还会调用到自身,但是会走不同的状态机的分支逻辑:

// 编译前的代码
suspend fun testCoroutine() {
    log("start")
    val user = getUserInfo()
    log(user)
    val friendList = getFriendList(user)
    log(friendList)
    val feedList = getFeedList(friendList)
    log(feedList)
}
// 编译后的代码
fun testCoroutine(completion: Continuation<Any?>): Any? {
    // TestContinuation本质上是匿名内部类
    class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
        // 表示协程状态机当前的状态
        var label: Int = 0

        // 三个变量,对应原函数的三个变量
        lateinit var user: String
        lateinit var friendList: String
        lateinit var feedList: String

        // result 接收协程的运行结果
        var result = continuation.result

        // suspendReturn 接收挂起函数的返回值
        var suspendReturn: Any? = null

        // CoroutineSingletons 是个枚举类
        // COROUTINE_SUSPENDED 代表当前函数被挂起了
        val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED

        // invokeSuspend 是协程的关键
        // 它最终会调用 testCoroutine(this) 开启协程状态机
        // 状态机相关代码就是后面的 when 语句
        // 协程的本质,可以说就是 CPS + 状态机
        override fun invokeSuspend(_result: Result<Any?>): Any? {
            result = _result
            label = label or Int.Companion.MIN_VALUE
            return testCoroutine(this)
        }
    }

    // ...
    val continuation = if (completion is TestContinuation) {
        completion
    } else {
        //                作为参数
        //                   ↓
        TestContinuation(completion)
    }
}

testCoroutine运行的逻辑如下:

协程状态机的核心逻辑反编译后的伪代码如下:

when (continuation.label) {
    0 -> {
        // 检测异常
        throwOnFailure(result)

        log("start")
        // 将 label 置为 1,准备进入下一次状态
        continuation.label = 1

        // 执行 getUserInfo
        suspendReturn = getUserInfo(continuation)

        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }

    1 -> {
        throwOnFailure(result)

        // 获取 user 值
        user = result as String
        log(user)
    
        // 准备进入下一个状态
        continuation.label = 2

        // 执行 getFriendList
        suspendReturn = getFriendList(user, continuation)

        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }

    2 -> {
        throwOnFailure(result)

        user = continuation.mUser as String

        // 获取 friendList 的值
        friendList = result as String
        log(friendList)

        // 准备进入下一个状态
        continuation.label = 3

        // 执行 getFeedList
        suspendReturn = getFeedList(user, friendList, continuation)

        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }

    3 -> {
        throwOnFailure(result)

        user = continuation.mUser as String
        friendList = continuation.mFriendList as String
        feedList = continuation.result as String
        log(feedList)
        loop = false
    }
}

我们来捋一下其中的顺序,最开始先构建一个TestContinuation的实例,注意,Continuation的这个实例是三个挂起函数的公共参数。

  1. getUserInfo

开始时label = 0, 此时进入逻辑,先进行异常的检查,设置下一次的入口label=1,执行getUserInfo:

when (continuation.label) {
    0 -> {
        // ...
        continuation.label = 1
        // 执行 getUserInfo
        suspendReturn = getUserInfo(continuation)
        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }
    // ...
}

在Kotlin编译器CPS转换之后的getUserInfo方法中,因为传入了continuation参数,需要再执行一次Continuation#invokeSuspend,这个方法同时也将结果记录在了result

 override fun invokeSuspend(_result: Result<Any?>): Any? {
     result = _result
     label = label or Int.Companion.MIN_VALUE
     return testCoroutine(this)
 }

相当于【递归】地执行一次这样的逻辑(个人认为这个逻辑和传递事件的分发有点相似)。此时getUserInfo执行完成返回的结果是CoroutineSingletons.COROUTINE_SUSPEND,所以继续执行下个when的case。

后面的结果其他的挂起函数的执行过程都差不多。具体过程如图所示:

截屏2022-04-15 上午9.20.55.png

通过这个状态机的分析能够让我们更加深刻的理解挂起函数中【挂起】和【恢复】的本质:其实就是基于状态机的回调函数,但是这种回调函数的执行逻辑是Kotlin编译器自动生成的,大大减少了我们的脑力消耗。

需要注意的是,以上的挂起函数都是【真正的】挂起函数,suspend function中都带有挂起的操作,但是Kotlin编译器在进行CPS转换的时候只认supsend关键字,对于伪suspend函数,走else分支,节省开销:

 if (suspendReturn == sFlag) {
      return suspendReturn
 } else {
      result = suspendReturn
      //go to next state
 }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容

  • 协程(Coroutine) 协程引入 异步加载图片 普通代码:val view = ...loadImageAsy...
    晨起清风阅读 1,269评论 0 1
  • 1. Kotlin协程作用 Kotlin协程是一套基于Java Thread的线程框架,最大的特点就是可以1,用同...
    小红军storm阅读 5,324评论 0 8
  • 一、前言: 1、什么是协程? 协程可以理解就是一种用户空间线程(另外一种线程),他的调度是由程序员自己写程序来管理...
    因为我的心阅读 879评论 3 1
  • 在今年的三月份,我因为需要为项目搭建一个新的网络请求框架开始接触 Kotlin 协程。那时我司项目中同时存在着两种...
    业志陈阅读 1,028评论 0 5
  • 在今年的三月份,我因为需要为项目搭建一个新的网络请求框架开始接触 Kotlin 协程。那时我司项目中同时存在着两种...
    Android开发指南阅读 813评论 0 2