Android Kotlin Coroutine原理簡述

Kotlin

Kotlin已經(jīng)被谷歌指定為Android的第一開發(fā)語言,現(xiàn)在大多數(shù)團隊都在改用kotlin進行開發(fā)。而kotlin的版本發(fā)布也挺快,目前出了一些新的東西可以進行嘗試。

Coroutine

2018年10月的樣子,Kotlin1.3正式發(fā)布,其中有一項特性是Android開發(fā)中以前從未有過的,那就是Coroutine,而且是正式版。
其實Coroutine的概念在1963年就由梅爾文*康威(一個牛逼的計算機科學家)提出,但是直到近代才逐漸走進大多數(shù)開發(fā)者的視野。比如:Python、Lua、C#、Go等等語言已經(jīng)支持Coroutine了。其中Go更是憑借著Coroutine,成為了目前比較火熱的服務端開發(fā)語言,在處理高并發(fā)上有著天然的優(yōu)勢。
下面就進入正題,Coroutine實現(xiàn)原理及給我們帶來了什么。

同步編程

先看看同步編程的代碼,其中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)
    }
}

異步編程

異步編程的目的是解決:如何防止應用因為執(zhí)行代碼而陷入阻塞?
Coroutine就是眾多解決方案中的一種。那么除了Coroutine有哪些常用的方法呢?

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

下面我們就先來介紹一下這幾種方法。

Thread

這是最原始的解決方案,Thread就是為此而生,他是操作系統(tǒng)級別的解決方案。如果不想陷入阻塞,直接起一個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)
            }
        }
    }
}

因為現(xiàn)代應用程序都是有一個主線程,而UI操作必須在主線程中去做。那么使用thread方式時,需要顯示的將結果拋到主線程中去處理,這其實是增加了線程切換的復雜度。

Callback

再后來,發(fā)現(xiàn)基于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接口給出計算結果。那么在使用的過程中簡化了操作,無需關系具體細節(jié)。一個Callback嵌套另一個Callback,只有1~2層嵌套看上去還挺美好。但是,我們實際使用中發(fā)現(xiàn)當邏輯比較復雜時,會出現(xiàn)N層嵌套的情況,可想而知會有多少層縮進。可讀性與復雜程度成指數(shù)級下降。這就是可怕的:回調地獄

Future/Promise/Rx

好了,好在時代在進步。出現(xiàn)了Future/Promise/Rx這幾種方法。之所以把這幾種放在一起,是因為他們有相似之處。再一個Promise/Rx我不太熟就不多講了,參照Future即可(如有不對歡迎指正)。Future在java 1.8中提供了一個基于數(shù)據(jù)流向的封裝,把所有計算都看做數(shù)據(jù)從第一步處理到下一步處理再到下一步。這樣就把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)
        }
    }
}

我們可以看到,就一層,數(shù)據(jù)一路向下傳遞直到最后。具體的異步還是同步細節(jié),都被封裝到了Futrue內部。讓開發(fā)者更加關注業(yè)務邏輯。其實要說也有缺點,我感覺API太復雜了,上手比較難,容易被不會用的人玩出翔來。。

Coroutine

前面說了那么多方法,對比下前面說的同步編程方法,我認為把異步化為同步最簡單。異步編程除了異步不阻塞UI這個天大的好處,比起同步編程的順著寫邏輯更復雜,從Future/Promise/Rx來看,他們也是通過封裝盡量將異步復雜的切換扁平化,來達到簡化的目的。但是從寫代碼來看,還是太復雜了。沒有純粹的同步代碼好寫。
這時候Coroutine出現(xiàn)了,他以語言\編譯器級別的支持將異步編程變成了同步編程。

/**
 * @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。這不就是同步的寫法么?運行起來會發(fā)現(xiàn)并沒有阻塞UI。對,就是這么神奇。

基礎概念

那么接下來我們就簡單介紹下如何使用協(xié)程。還是回到getToken的吧。。雖然寫的不太合適但是能夠直觀的了解協(xié)程的相關概念。

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

    lateinit var job : Job //1. 每個協(xié)程都是一個job,可以取消
    override fun onCreate() {
        job = GlobalScope /*2. 所有的協(xié)程都在一個作用域下執(zhí)行*/.launch {//3. launch 表示啟動一個協(xié)程
            val token = getToken() //掛起函數(shù)執(zhí)行完后協(xié)程會被掛起,等待被恢復的時機
            launch(
                this.coroutineContext + Dispatchers.Main //4. 每個協(xié)程都有個一個context
            ){
                setToken(token)
            }
            val userInfo = async {
                getUserInfo(token)
            }
            launch(Dispatchers.Main){
                delay(3000)
                setUserInfo(userInfo.await() /*可以等待數(shù)據(jù)返回,與launch的區(qū)別*/)
            }
        }
//        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,也可以自己實現(xiàn)接口CoroutineScope定義作用域。所有的協(xié)程都是在作用域下運行。

CoroutineContext

看到Context,我們很容易想到Android里的Context。對,每個協(xié)程都對應有一個context,Context的作用就是用來保存協(xié)程相關的一些信息。比如Dispatchers、Job、名字、等等。他的數(shù)據(jù)結構其實挺妖,我看了半天才看懂。
最終的實現(xiàn)是一個叫CombinedContext的類,其實就是一個鏈表,每個節(jié)點保存了一個Key。

launch/async

scope和context都具備了,那么如何啟動Coroutine呢?也很簡單launch或者async就可以了,像啟動一個線程一樣簡單。我們把這種叫做Builder。可以啟動各式各樣的協(xié)程。
其中l(wèi)aunch和async的區(qū)別只有一個async返回的對象可以調用await方法掛起Coroutine直到async執(zhí)行完畢。

Job

job也好理解,每次啟動一個Coroutine會返回一個job對象。job對象可以對Coroutine進行取消操作,async返回的job還能掛起當前Coroutine直到Coroutine的job執(zhí)行完畢。

cancel

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

取消需要其他配合

但是需要注意的是,如果Coroutine中執(zhí)行的代碼是無法退出的,比如while(true)。那么調用了cancel是不起作用的。只有在suspend方法結束的時候才會去生效。但是我們可以做一點改進:while(isActive)。isActive是Coroutine的狀態(tài),如果調用了cancel,isActive會變成false。

父子Coroutine

我們很容易想到,Coroutine中啟動Coroutine的情況。在Kotlin中Coroutine是有父子關系的,那么父子關系默認遵守以下幾條規(guī)律:

  1. Coroutine之間是父子關系,默認繼承父Coroutine的context
  2. 父Coroutine會等待所有子Coroutine完成或取消才會結束
  3. 父Coroutine如果取消或者異常退出則會取消所有子Coroutine
  4. 子Coroutine異常退出則會取消父Coroutine
  5. 取消可以被try…finally捕獲,如果已經(jīng)取消會拋出異常

Dispatchers

這個也比較好理解,我們知道Coroutine本質上還是得依附于thread去執(zhí)行。因此我們需要一個調度器來指定Coroutine具體執(zhí)行在哪一個thread。

suspend

suspend關鍵字可以說是實現(xiàn)Coroutine的關鍵。它表示這個函數(shù)是可以被掛起的,只能在suspend修飾的方法中調用suspend方法。
也就是說代碼執(zhí)行到suspend方法或者suspend方法結束,會切換到其他Coroutine的其他suspend方法執(zhí)行。這也很好的解釋了前面的demo中,computeA和computeB是如何并行執(zhí)行的。launch啟動的Coroutine里的代碼為什么沒有阻塞UI。因為suspend方法遇到delay或者其他suspend方法,會被掛起而不是像Thread.sleep那樣阻塞住線程,等到合適的時機suspend方法會被恢復執(zhí)行。
至于中間是如何掛起并且如何恢復,后續(xù)會講解。

原理解析

下面從源碼的方面來簡述,Coroutine到底是如何實現(xiàn)函數(shù)掛起的。我們分幾部來講。這里炒一個代碼。。自己弄實在是麻煩。

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. 將函數(shù)返回值去掉
  2. 添加cont: Continuation參數(shù),將結果放入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變換后,中間就有了無限的可能,比如可以不直接執(zhí)行postItem里的代碼,而是通過Continuation決定何時再去執(zhí)行具體的代碼。這不就可以實現(xiàn)了函數(shù)的掛起與恢復嗎?
說白了其實kotlin中的Coroutine本質上還是基于回掉去實現(xiàn),只是它幫我們將細節(jié)封裝在了編譯期間。在外在看來,與阻塞編程沒有區(qū)別。
那么具體的函數(shù)實現(xiàn)放哪去了呢?

題外話:尾遞歸與CPS

說到CPS大家都不清楚。說到遞歸,應該再熟悉不過。可是這幾個詞擺在一起是為什么呢?
遞歸
自己調用自己。。但是有個問題。遞歸的性能眾所周知,而且如果太多會出現(xiàn)棧溢出。有什么優(yōu)化方案呢?答案:循環(huán)。問題又來了,有的語言壓根就沒有循環(huán)這一說!那么有什么優(yōu)化方案呢?答案:尾遞歸。
尾遞歸
為什么叫尾遞歸。因為遞歸的函數(shù)調用被放到了最后,所以叫尾遞歸。沒那么簡單。。還必須他的執(zhí)行并不依賴上一次的執(zhí)行結果,這樣編譯器會將代碼優(yōu)化成類似循環(huán)的結構。這樣每次調用不用保存上次的棧,每次執(zhí)行都是重新開始。因此尾遞歸在效率上比遞歸高出不少,而且保留了可讀性。
還是上個代碼對比下把,經(jīng)典例子斐波拉切數(shù)列,可以執(zhí)行下對比下耗時:

/**
 * @Author : xialonghua
 * @Date : Create in 2019/1/28
 * @Description : a new file
 */
//編譯器會將尾遞歸優(yōu)化成循環(huán)
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什么關系呢?
有沒發(fā)現(xiàn)尾遞歸在遞歸函數(shù)參數(shù)上多了2個,將計算結果給直接給到下次遞歸。再看CPS轉換,是不是在函數(shù)后面加了個Continuation,然后把執(zhí)行結果放入resumeWith回調中,然后繼續(xù)執(zhí)行。簡直一毛一樣阿,將結果直接給到下次計算,而不是自上而下又自下而上的調用棧關系。

狀態(tài)機

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

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

}

知道了是如何掛起和恢復,那么suspend方法里還有別的suspend方法呢?編譯器還做了點別的事情。。那就是狀態(tài)機。首先將具體實現(xiàn)放到了invokeSuspend中。它把每一步suspend方法都用一個狀態(tài)表示。當一個suspend方法執(zhí)行完后,將狀態(tài)改變,然后交由Continuation的resumeWith來繼續(xù)執(zhí)行下一個步驟。說白了就像遞歸一樣不停的調用resumeWith來向前推進,至于是直接返回還是繼續(xù)掛起,取決于resumeWith的返回值。值得注意的是子suspend也會持有父suspend的Continuation實例,形成一個鏈表,這樣就能在子suspend執(zhí)行完后回到父suspend繼續(xù)執(zhí)行。


圖

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

非編譯期處理

異步

前面講的這些其實本質上還是在同一個線程不停的回調執(zhí)行,并沒有實現(xiàn)異步,并沒有將Coroutine分布到其他線程。那么是如果做到異步的呢?

Interceptor and Dispatcher

通過前面可以知道Continuation是持有父Continuation引用的,是一個鏈表。那么引入Interceptor的概念,在原本的調用鏈里加入一個InterceptorContinuation,里面包含Dispatcher的引用。它的resumeWith里不干別的,就把下一個Continuation的resumeWith通過Dispatcher丟到其他線程里執(zhí)行。
至于線程的調度就交給Dispatcher去完成,Dispatcher可以有多種實現(xiàn),比如使用線程池、使用Handler等等。
是不是很巧妙?

到這里Coroutine的實現(xiàn)原理就說的差不多了。后面再講講其他的。

與Thread的對比

這個其實沒什么好比了。創(chuàng)建10000個線程的話內存肯定是吃不消,但是創(chuàng)建10000個Coroutine肯定是沒問題的。通過前面的講解,很容易知道Coroutine只是一個Continuation鏈表,它只會占用鏈表的內存空間,比一個thread消耗不是一個量級

用同步的方法編寫異步代碼

這一塊還是值得說道的。怎么寫代碼最簡單,無疑是同步代碼最簡單。那么Coroutine帶來的好處顯而易見,寫代碼只管按照同步去寫,至于何時掛起何時恢復全由Coroutine內部處理。至少外在看起來就是同步的代碼。感覺說還是說不清,還是給一個例子來說明吧。
看demo思考一個問題,滿足如下需求,用其他異步編程方法需要多少代碼:

  1. 點擊按鈕開始計數(shù),并把按鈕disable
  2. 使用retrofit請求百度并將返回結果轉成MD5(算法UUID代替),每隔2秒toast輸出一次
  3. 取消計時,并設置textview文本為”Hello World Finish”
  4. enable按鈕
  5. activity destroy的時候停止計時、請求網(wǎng)絡、定時彈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("網(wǎng)絡錯誤")
            }

            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的具體實現(xiàn)
  2. Continuation、BaseContinuationImpl、ContinuationImpl、SuspendLambda 編譯處理以及流程控制(掛起恢復)
  3. ContinuationInterceptor、CoroutineDispatcher 攔截器與dispatcher,實現(xiàn)了異步
  4. AbstractCoroutine builder的實現(xiàn)抽象父類
  5. CoroutineScope Coroutine的scope

總結

前面寫了這么多,其實大多都是描述Coroutine的本質。文字比較多有可能沒有描述清楚,歡迎拍磚。下面我自己總結兩點:

  1. Coroutine的性能消耗對比Thread微乎其微
  2. 更重要的是它帶來了一種新的編程方式,讓異步編程不再復雜

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容