Android Kotlin Coroutine原理简述

Kotlin

Kotlin已经被谷歌指定为Android的第一开发语言,现在大多数团队都在改用kotlin进行开发。而kotlin的版本发布也挺快,目前出了一些新的东西可以进行尝试。

Coroutine

2018年10月的样子,Kotlin1.3正式发布,其中有一项特性是Android开发中以前从未有过的,那就是Coroutine,而且是正式版。
其实Coroutine的概念在1963年就由梅尔文*康威(一个牛逼的计算机科学家)提出,但是直到近代才逐渐走进大多数开发者的视野。比如:Python、Lua、C#、Go等等语言已经支持Coroutine了。其中Go更是凭借着Coroutine,成为了目前比较火热的服务端开发语言,在处理高并发上有着天然的优势。
下面就进入正题,Coroutine实现原理及给我们带来了什么。

同步编程

先看看同步编程的代码,其中setToken与setUseInfo为主线程设置TextView:

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : 阻塞式编程
 */
class Demo1(button: Button, infoView: TextView) : Demo(button, infoView) {

    override fun onCreate() {
        //模拟获取token
        val token = URL("https://www.baidu.com/getToken").readText().md5()
        setToken(token)
        val userInfo = URL("https://www.baidu.com/userInfo?$token").readText().md5()
        setUserInfo(userInfo)
    }
}

异步编程

异步编程的目的是解决:如何防止应用因为执行代码而陷入阻塞?
Coroutine就是众多解决方案中的一种。那么除了Coroutine有哪些常用的方法呢?

  1. Thread
  2. Callback
  3. Future/Promise/Rx

下面我们就先来介绍一下这几种方法。

Thread

这是最原始的解决方案,Thread就是为此而生,他是操作系统级别的解决方案。如果不想陷入阻塞,直接起一个Thread即可。

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : thread
 */
class Demo2(button: Button, infoView: TextView) : Demo(button, infoView) {

    override fun onCreate(){
        //模拟获取token
        thread {
            val token = URL("https://www.baidu.com/getToken").readText().md5()
            uiHandler.post {
                setToken(token)
            }
            val userInfo = URL("https://www.baidu.com/userInfo?$token").readText().md5()
            uiHandler.post {
                setUserInfo(userInfo)
            }
        }
    }
}

因为现代应用程序都是有一个主线程,而UI操作必须在主线程中去做。那么使用thread方式时,需要显示的将结果抛到主线程中去处理,这其实是增加了线程切换的复杂度。

Callback

再后来,发现基于thread其实写的代码还是比较多的,挺复杂的。所以有人又想出了另外一种方法将结果通过Callback的方式返回给UI线程。

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : callback
 */
class Demo3(button: Button, infoView: TextView) : Demo(button, infoView) {

    override fun onCreate() {
        getToken { token ->
            setToken(token)
            getUserInfo(token){ userInfo ->
                setUserInfo(userInfo)
            }
        }
    }
    private fun getToken(callback: (token: String) -> Unit){
        thread {
            val token = URL("https://www.baidu.com/getToken").readText().md5()
            uiHandler.post {
                callback(token)
            }
        }
    }
    private fun getUserInfo(token: String, callback: (userinfo: String) -> Unit){
        thread {
            val userInfo = URL("https://www.baidu.com/userInfo?$token").readText().md5()
            uiHandler.post {
                callback(userInfo)
            }
        }
    }
}

我们可以看到这种方式将线程切换、计算封装到了一个方法内部,对外通过一个Callback接口给出计算结果。那么在使用的过程中简化了操作,无需关系具体细节。一个Callback嵌套另一个Callback,只有1~2层嵌套看上去还挺美好。但是,我们实际使用中发现当逻辑比较复杂时,会出现N层嵌套的情况,可想而知会有多少层缩进。可读性与复杂程度成指数级下降。这就是可怕的:回调地狱

Future/Promise/Rx

好了,好在时代在进步。出现了Future/Promise/Rx这几种方法。之所以把这几种放在一起,是因为他们有相似之处。再一个Promise/Rx我不太熟就不多讲了,参照Future即可(如有不对欢迎指正)。Future在java 1.8中提供了一个基于数据流向的封装,把所有计算都看做数据从第一步处理到下一步处理再到下一步。这样就把Callback嵌套给拍平了。只有一层逻辑。

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : future
 */
class Demo4(button: Button, infoView: TextView) : Demo(button, infoView) {

    override fun onCreate() {
        CompletableFuture.supplyAsync {
            URL("https://www.baidu.com/getToken").readText().md5()
        }.thenApply {
            setToken(it)
            it
        }.thenApplyAsync {
            URL("https://www.baidu.com/userInfo?$it").readText().md5()
        }.thenAccept {
           setUserInfo(it)
        }
    }
}

我们可以看到,就一层,数据一路向下传递直到最后。具体的异步还是同步细节,都被封装到了Futrue内部。让开发者更加关注业务逻辑。其实要说也有缺点,我感觉API太复杂了,上手比较难,容易被不会用的人玩出翔来。。

Coroutine

前面说了那么多方法,对比下前面说的同步编程方法,我认为把异步化为同步最简单。异步编程除了异步不阻塞UI这个天大的好处,比起同步编程的顺着写逻辑更复杂,从Future/Promise/Rx来看,他们也是通过封装尽量将异步复杂的切换扁平化,来达到简化的目的。但是从写代码来看,还是太复杂了。没有纯粹的同步代码好写。
这时候Coroutine出现了,他以语言\编译器级别的支持将异步编程变成了同步编程。

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : coroutines suspend
 */
class Demo6(button: Button, infoView: TextView) : Demo(button, infoView) {

    override fun onCreate() {
        GlobalScope.launch(Dispatchers.Main) {
            val a = async { computeA() }
            val b = async { computeB() }
            delay(2000)
            setUserInfo("sum : ${a.await() + b.await()}")
        }
    }
    private suspend fun computeA() : Int{
        repeat(3){
            delay(1000)
        }
        return 125
    }
    private suspend fun computeB() : Int{
        repeat(3){
            delay(1000)
        }
        return 100
    }
}

稍微改变下需求更加直观,同时求computeA/computeB的值并显示到UI。我们可以看到代码很简单,如果不要同时计算AB,可以去掉async。这不就是同步的写法么?运行起来会发现并没有阻塞UI。对,就是这么神奇。

基础概念

那么接下来我们就简单介绍下如何使用协程。还是回到getToken的吧。。虽然写的不太合适但是能够直观的了解协程的相关概念。

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : coroutines
 */
class Demo5(button: Button, infoView: TextView) : Demo(button, infoView) {

    lateinit var job : Job //1. 每个协程都是一个job,可以取消
    override fun onCreate() {
        job = GlobalScope /*2. 所有的协程都在一个作用域下执行*/.launch {//3. launch 表示启动一个协程
            val token = getToken() //挂起函数执行完后协程会被挂起,等待被恢复的时机
            launch(
                this.coroutineContext + Dispatchers.Main //4. 每个协程都有个一个context
            ){
                setToken(token)
            }
            val userInfo = async {
                getUserInfo(token)
            }
            launch(Dispatchers.Main){
                delay(3000)
                setUserInfo(userInfo.await() /*可以等待数据返回,与launch的区别*/)
            }
        }
//        job.cancel()
//        setText("job is canceled")
    }

    private suspend fun getToken() : String{
        delay(100)
        return URL("https://www.baidu.com/getToken").readText().md5()
    }

    private suspend fun getUserInfo(token: String): String{
        delay(100)
        return URL("https://www.baidu.com/userInfo?$token").readText().md5()
    }

}

从上面代码可以看到几个关键的点。

  1. GlobalScope
  2. CoroutineContext
  3. launch/async
  4. Job
  5. cancel
  6. Dispatchers
  7. suspend

这里不具体的去说如何使用,而是把几个关键的概念拎出来描述清楚,那么以后就能很好理解了如何使用了。

GlobalScope

如其名Scope、Global。两层含义。Global表示是一个全局的作用域。还有其他的Scope,也可以自己实现接口CoroutineScope定义作用域。所有的协程都是在作用域下运行。

CoroutineContext

看到Context,我们很容易想到Android里的Context。对,每个协程都对应有一个context,Context的作用就是用来保存协程相关的一些信息。比如Dispatchers、Job、名字、等等。他的数据结构其实挺妖,我看了半天才看懂。
最终的实现是一个叫CombinedContext的类,其实就是一个链表,每个节点保存了一个Key。

launch/async

scope和context都具备了,那么如何启动Coroutine呢?也很简单launch或者async就可以了,像启动一个线程一样简单。我们把这种叫做Builder。可以启动各式各样的协程。
其中launch和async的区别只有一个async返回的对象可以调用await方法挂起Coroutine直到async执行完毕。

Job

job也好理解,每次启动一个Coroutine会返回一个job对象。job对象可以对Coroutine进行取消操作,async返回的job还能挂起当前Coroutine直到Coroutine的job执行完毕。

cancel

前面说到Coroutine是可以取消的。直接使用Job的cancel方法即可。

取消需要其他配合

但是需要注意的是,如果Coroutine中执行的代码是无法退出的,比如while(true)。那么调用了cancel是不起作用的。只有在suspend方法结束的时候才会去生效。但是我们可以做一点改进:while(isActive)。isActive是Coroutine的状态,如果调用了cancel,isActive会变成false。

父子Coroutine

我们很容易想到,Coroutine中启动Coroutine的情况。在Kotlin中Coroutine是有父子关系的,那么父子关系默认遵守以下几条规律:

  1. Coroutine之间是父子关系,默认继承父Coroutine的context
  2. 父Coroutine会等待所有子Coroutine完成或取消才会结束
  3. 父Coroutine如果取消或者异常退出则会取消所有子Coroutine
  4. 子Coroutine异常退出则会取消父Coroutine
  5. 取消可以被try…finally捕获,如果已经取消会抛出异常

Dispatchers

这个也比较好理解,我们知道Coroutine本质上还是得依附于thread去执行。因此我们需要一个调度器来指定Coroutine具体执行在哪一个thread。

suspend

suspend关键字可以说是实现Coroutine的关键。它表示这个函数是可以被挂起的,只能在suspend修饰的方法中调用suspend方法。
也就是说代码执行到suspend方法或者suspend方法结束,会切换到其他Coroutine的其他suspend方法执行。这也很好的解释了前面的demo中,computeA和computeB是如何并行执行的。launch启动的Coroutine里的代码为什么没有阻塞UI。因为suspend方法遇到delay或者其他suspend方法,会被挂起而不是像Thread.sleep那样阻塞住线程,等到合适的时机suspend方法会被恢复执行。
至于中间是如何挂起并且如何恢复,后续会讲解。

原理解析

下面从源码的方面来简述,Coroutine到底是如何实现函数挂起的。我们分几部来讲。这里炒一个代码。。自己弄实在是麻烦。

suspend fun postItem(item: Item): PostResult {
  val token = requestToken()
  val post = createPost(token, item)
  val postResult = processPost(post)
  return postResult
}

编译期处理

suspend方法用起来挺简单,但实际上背后Kotlin做了很多不为人知的事情。
首先,被suspend关键字修饰后,在编译期间,我们看看编译器做了哪些事情。

CPS(Continuation Passing Style)

编译器做的第一件事就是CPS转换。

  1. 将函数返回值去掉
  2. 添加cont: Continuation参数,将结果放入resumeWith回调中。
//转换后的伪代码
fun postItem(item: Item, cont: Continuation): Any?{
}

我们再看看Continuation里有什么。

@SinceKotlin("1.3")
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

看到resumeWith没有,想到能做什么了吗?
对,异步与流程控制。做了CPS变换后,中间就有了无限的可能,比如可以不直接执行postItem里的代码,而是通过Continuation决定何时再去执行具体的代码。这不就可以实现了函数的挂起与恢复吗?
说白了其实kotlin中的Coroutine本质上还是基于回掉去实现,只是它帮我们将细节封装在了编译期间。在外在看来,与阻塞编程没有区别。
那么具体的函数实现放哪去了呢?

题外话:尾递归与CPS

说到CPS大家都不清楚。说到递归,应该再熟悉不过。可是这几个词摆在一起是为什么呢?
递归
自己调用自己。。但是有个问题。递归的性能众所周知,而且如果太多会出现栈溢出。有什么优化方案呢?答案:循环。问题又来了,有的语言压根就没有循环这一说!那么有什么优化方案呢?答案:尾递归。
尾递归
为什么叫尾递归。因为递归的函数调用被放到了最后,所以叫尾递归。没那么简单。。还必须他的执行并不依赖上一次的执行结果,这样编译器会将代码优化成类似循环的结构。这样每次调用不用保存上次的栈,每次执行都是重新开始。因此尾递归在效率上比递归高出不少,而且保留了可读性。
还是上个代码对比下把,经典例子斐波拉切数列,可以执行下对比下耗时:

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : a new file
 */
//编译器会将尾递归优化成循环
fun fibonacci_tail(n: Int, acc1: Int, acc2: Int): Int {
    return if (n < 2) {
        acc1
    } else {
        fibonacci_tail(n - 1, acc2, acc1 + acc2)
    }
}

// 递归
fun fibonacci(n: Int): Int {
    return if (n <= 2) {
        1
    } else {
        fibonacci(n - 1) + fibonacci(n - 2)
    }
}

那么这和CPS什么关系呢?
有没发现尾递归在递归函数参数上多了2个,将计算结果给直接给到下次递归。再看CPS转换,是不是在函数后面加了个Continuation,然后把执行结果放入resumeWith回调中,然后继续执行。简直一毛一样阿,将结果直接给到下次计算,而不是自上而下又自下而上的调用栈关系。

状态机

@SinceKotlin("1.3")
public abstract class BaseContinuationImpl:Continuation<in T> {

    protected abstract fun invokeSuspend(result: Result<Any?>): Any?

}

知道了是如何挂起和恢复,那么suspend方法里还有别的suspend方法呢?编译器还做了点别的事情。。那就是状态机。首先将具体实现放到了invokeSuspend中。它把每一步suspend方法都用一个状态表示。当一个suspend方法执行完后,将状态改变,然后交由Continuation的resumeWith来继续执行下一个步骤。说白了就像递归一样不停的调用resumeWith来向前推进,至于是直接返回还是继续挂起,取决于resumeWith的返回值。值得注意的是子suspend也会持有父suspend的Continuation实例,形成一个链表,这样就能在子suspend执行完后回到父suspend继续执行。


图

3108769-730fe1e81c30a4d0..jpg
3108769-730fe1e81c30a4d0..jpg

非编译期处理

异步

前面讲的这些其实本质上还是在同一个线程不停的回调执行,并没有实现异步,并没有将Coroutine分布到其他线程。那么是如果做到异步的呢?

Interceptor and Dispatcher

通过前面可以知道Continuation是持有父Continuation引用的,是一个链表。那么引入Interceptor的概念,在原本的调用链里加入一个InterceptorContinuation,里面包含Dispatcher的引用。它的resumeWith里不干别的,就把下一个Continuation的resumeWith通过Dispatcher丢到其他线程里执行。
至于线程的调度就交给Dispatcher去完成,Dispatcher可以有多种实现,比如使用线程池、使用Handler等等。
是不是很巧妙?

到这里Coroutine的实现原理就说的差不多了。后面再讲讲其他的。

与Thread的对比

这个其实没什么好比了。创建10000个线程的话内存肯定是吃不消,但是创建10000个Coroutine肯定是没问题的。通过前面的讲解,很容易知道Coroutine只是一个Continuation链表,它只会占用链表的内存空间,比一个thread消耗不是一个量级

用同步的方法编写异步代码

这一块还是值得说道的。怎么写代码最简单,无疑是同步代码最简单。那么Coroutine带来的好处显而易见,写代码只管按照同步去写,至于何时挂起何时恢复全由Coroutine内部处理。至少外在看起来就是同步的代码。感觉说还是说不清,还是给一个例子来说明吧。
看demo思考一个问题,满足如下需求,用其他异步编程方法需要多少代码:

  1. 点击按钮开始计数,并把按钮disable
  2. 使用retrofit请求百度并将返回结果转成MD5(算法UUID代替),每隔2秒toast输出一次
  3. 取消计时,并设置textview文本为”Hello World Finish”
  4. enable按钮
  5. activity destroy的时候停止计时、请求网络、定时弹toast
demo
demo

我再贴出使用了Coroutine的核心代码来对比一下,有没感觉简单清晰很多:

class MainActivity2 : AppCompatActivity(), CoroutineScope, AnkoLogger {
    private val job = SupervisorJob()
    override val coroutineContext: CoroutineContext = Dispatchers.Main + job

    @SuppressLint("SetTextI18n")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main2)

        helloClick.onClick {
            val tickJob = launch {
                repeat(100000){
                    delay(10)
                    helloClick.text = "Hello World $it"
                    info("====")
                }
            }
            helloClick.isEnabled = false
            try {
                val result = api.getBaidu().await()
                repeat(3){
                    toast(result.md5())
                    delay(2000)
                }
            }catch (e: Exception){
                e.printStackTrace()
                //请求异常处理
                toast("网络错误")
            }

            tickJob.cancel()
            helloClick.isEnabled = true
            helloClick.text = "Hello World Finish"
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }

    private fun View.onClick(handler: (suspend CoroutineScope.(v: android.view.View?)->Unit)){
        setOnClickListener { v ->
            launch {
                handler(v)
            }
        }
    }
}

源码阅读关键类帮助

我列了一些关于Coroutine关键的类,看源码可以从这些地方入手:

  1. CoroutineContext、CombinedContext context的具体实现
  2. Continuation、BaseContinuationImpl、ContinuationImpl、SuspendLambda 编译处理以及流程控制(挂起恢复)
  3. ContinuationInterceptor、CoroutineDispatcher 拦截器与dispatcher,实现了异步
  4. AbstractCoroutine builder的实现抽象父类
  5. CoroutineScope Coroutine的scope

总结

前面写了这么多,其实大多都是描述Coroutine的本质。文字比较多有可能没有描述清楚,欢迎拍砖。下面我自己总结两点:

  1. Coroutine的性能消耗对比Thread微乎其微
  2. 更重要的是它带来了一种新的编程方式,让异步编程不再复杂

demo源码:https://github.com/xialonghua/AndroidCoroutineRetrofit

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

推荐阅读更多精彩内容