總是在聊執行緒Thread,試試協程吧!

MDove發表於2019-05-05

前言

本文主要基於Kotlin,之前寫過一些Kotlin的文章,比較淺,有興趣的小夥伴可以看上那麼一看

快速切換至Kotlin for Android模式

充分理解Kotlin,快速上手寫業務

對於Java的小夥伴來說,執行緒可以說是一個又愛又恨的傢伙。執行緒可以帶給我們不阻礙主執行緒的後臺操作,但隨之而來的執行緒安全、執行緒消耗等問題又是我們不得不處理的問題。

對於Java開發來說,合理使用執行緒池可以幫我們處理隨意開啟執行緒的消耗。此外RxJava庫的出現,也幫助我們更好的去執行緒進行切換。所以一直以來執行緒佔據了我的日常開發...

直到,我接觸了協程...

正文

我們們先來看一段Wiki上關於協程(Coroutine)的一些介紹:協程是計算機程式的一類元件,允許執行被掛起與被恢復。但是,到2003年,很多最流行的程式語言,包括C和它的後繼,都未在語言內或其標準庫中直接支援協程。在當今的主流程式設計環境裡,執行緒是協程的合適的替代者...

但是!如今已經2019年了,協程真的沒有用武之地麼?!今天讓我們從Kotlin中感受協程的有趣之處!

一、協程

開始實戰之前,我們聊一聊協程這麼的概念。開啟協程之前,我們先說一說我們們日常中的函式

函式,在所有語言中都是層級呼叫,比如函式A呼叫函式B,函式B中又呼叫了函式C,函式C執行完畢返回,函式B執行完畢返回,最後是函式A執行完畢。

所以可以看出來函式的呼叫是通過棧實現的。

函式的呼叫總是一個入口,一次return,呼叫順序是明確的。而協程的不同之處就在於,執行過程中函式內部是可中斷的,也就是說中斷之後,可以轉而執行別的函式,在合適的時機再return回來繼續執行沒有執行完的內容。

而這種中斷,叫做掛起。掛起我們當前的函式,再某個合適的時機,才反過來繼續執行~這裡我們再想想回撥:註冊一個回撥函式,在合適的時機執行這個回撥。

  • 回撥採用的是一種非同步的形式
  • 而協程則是同步

是不是一時有點懵逼。不著急,我們往下看,往下更懵逼,哈哈~

二、Kotlin中的協程

通過Wiki上的介紹,我們不難看出協程是一種標準。任何語言都可以選擇去支援它。

這裡是關於Kotlin中協程的文件:kotlinlang.org/docs/refere…

假設我們想在android中的專案中使用協程該怎麼辦?很簡單。

假設可以已經配好了Kotlin依賴

2.1、gradle引入

在Android中協程的引入非常的簡單,只需要在gradle中:

apply plugin: 'kotlin-android-extensions'
複製程式碼

然後依賴中新增:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.0"
複製程式碼

2.2、基本demo

先看一段官方的基礎demo:

// 啟動一個協程
GlobalScope.launch(Dispatchers.Main) {
    // 執行一個延遲10秒的函式
    delay(10000L)
    println("World!-${Thread.currentThread().name}")
}
println("Hello-${Thread.currentThread().name}-")
複製程式碼

這段程式碼執行結果應該大家都能猜到:Hello-main-World!-main。大家有沒有注意到,這倆個輸出,全部列印了main執行緒。

這段程式碼在主執行緒執行,並且延遲了10秒鐘,而且也沒有出現ANR!

當然,這裡有小夥伴會說,我可以通過Handler進行postDelay()也能做到這種效果。沒錯,我們的postDelay(),是一種回撥的解決方案。而我們開頭提到過,協程使用同步的方式去解決這類問題。

所以,協程中的delay()也是通過佇列實現的。但是!它用同步的形式屏棄掉了回撥,讓我們的程式碼可讀性+100%。

2.2.1、delay()的實現

預警...這裡將會引入大量的Kotlin中的協程api。為了避免閱讀不適。這一小節建議直接跳過

跳過總結:

Kotlin為我們提供了一些api,幫我們能夠擺脫CallBack,本質也是通過封裝CallBack的形式,實現同步化非同步程式碼

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    // 很明顯可以看出,實現仍然是用CallBack的形式
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
    }
}

/** Returns [Delay] implementation of the given context */
internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay
internal actual val DefaultDelay: Delay = DefaultExecutor
複製程式碼

delay()使用suspendCancellableCoroutine()掛起協程,一般情況下控制協程恢復的關鍵在DefaultExecutor.scheduleResumeAfterDelay()中,中實現是schedule(DelayedResumeTask(timeMillis, continuation)),關鍵邏輯是將DelayedResumeTask放到DefaultExecutor的佇列最後,在延遲的時間到達就會執行DelayedResumeTask,那麼該 task 裡面的實現是什麼:

override fun run() {
    // 直接在呼叫者執行緒恢復協程
    with(cont) { resumeUndispatched(Unit) }
}
複製程式碼

2.3、繼續理解

接下來,我們們來好好理解一下上面程式碼的含義。

首先delay()被稱之為掛起函式,這種函式在協程的作用域中,可以被掛起,掛起後不阻塞當前執行緒協程作用域以外的程式碼執行。並且協程會在合適的時機,恢復掛起繼續執行協程作用域中後續的程式碼。

而上述程式碼中的GlobalScope.launch(Dispatchers.Main) {},就是在主執行緒建立一個全域性的協程作用域。而我們的delay(10000)是一個掛起函式,執行到它的時候,協程會掛起此函式。讓出CPU,此時我們協程作用域之外的println("Hello-${Thread.currentThread().name}-")就有機會執行了。

當合適的時機到來,也就是10000毫秒過後。協程會恢復掛起函式,繼續執行後續的程式碼。

思考

看到這,我猜肯定有小夥伴,內心臥槽了一聲:“這不完全不需要執行緒了?以後阻塞操作,直接寫在掛起函式了?”。這是完全錯誤的想法!協程提供的是同步化非同步程式碼的能力。協程是在使用者態幫我們封裝了對應的非同步api。而不是真正提供了非同步的能力。所以如果我們在主執行緒的協程中進行IO操作,一樣會阻塞住主執行緒。

GlobalScope.launch(Dispatchers.Main) {
    ...網路請求/...大量資料的資料庫操作
}
複製程式碼

一樣會丟擲NetworkOnMainThread/一樣會阻塞主執行緒。因為上述程式碼,本質還是在主執行緒執行。所以假設我們在協程中執行阻塞當前執行緒的程式碼(比如IO操作),仍然會阻塞住當前的執行緒。也就是有可能出現我們常見的ANR。

因此,在這種場景下,我們需要這麼呼叫:

GlobalScope.launch(Dispatchers.IO) {
    ...網路請求/...大量資料的資料庫操作
}
複製程式碼

我們在啟動一個協程的時候,改了一個新的協程上下文(這個上下文會將協程切換到IO執行緒進行執行)。這樣我們就做到在子執行緒啟動協程,完成我們曾經執行緒的樣子...

思考

很多朋友,肯定這裡就產生疑問了。既然還是用子執行緒做後臺任務...那協程存在的意義有是什麼呢?那接下來讓我們們走進協程的意義。

三、協程的作用

3.1、拒絕CallBack

我們日常開發時,經常會遇到這樣的需求:比如一個發文流程中,我們要先登入;登入成功後,我們再進行發文;發文成功後我們更新UI。

來段偽碼,簡單實現一下這樣的需求:

// 登入的偽碼。傳遞一個lambda,也就是一個CallBack
fun login(cb: (User) -> Unit) { ... }
// 發文的偽碼
fun postContent(user: User, content: String, cb: (Result) -> Unit) { ... }
// 更新UI
fun updateUI(result: Result) { ... }

fun ugcPost(content: String) {
    login { user ->
        postContent(user, content) { result ->
            updateUI(result)
        }
    }
}
複製程式碼

這種需求下,我們通常會由倆個CallBack完成這種序列的需求。不知道大家日常寫這種程式碼的時候,有沒有思考過,為什麼序列的邏輯,要用**CallBack的形式(非同步)**完成?

可能大家會說:這些需求要用執行緒去進行後臺執行,只能通過CallBack拿到結果。

那麼問題又來了,為什麼用執行緒做後臺邏輯時,我們就必須要用CallBack呢?畢竟從我們的思維邏輯上來說,這些需求就是序列,理論上順序執行程式碼就ok了。所以協程的作用就出現了...

這種通過非同步形式的邏輯,在協程的輔助下就可變成同步執行:

// 掛起函式,不需要任何CallBack,我們CallBack的內容,只需要當做返回值return即可
suspend fun login(): User { ... }   
suspend fun postContent(user: User, content: String): Result { ... } 
fun updateUI(result: Result) { ... }

fun ugcPost(content: String) {
    GlobalScope.launch {
        val user = login()
        val result = postContent(user, content)
        updateUI(result)
    }
}
複製程式碼

這樣我們就完成了原本需要層層巢狀的CallBack程式碼,直來直去,直接順序邏輯寫即可。

沒錯,這就是協程的作用之一。

  • 1、當然,很多小夥伴會說Java8引入的Future也可以完成類似的序列執行。(不過,話說回來是不是很多小夥伴沒有升到Java8)...
  • 2、肯定也有其他小夥伴說,我可以使用Rx的方式,也能完成這種呼叫...

哈哈,完全沒錯。因為大家都是為了解決同樣的問題,但是協程還有其他好用的地方...

3.2、方便的執行緒切換

想一個我們很常見的需求,子執行緒網路請求,資料回來後切到主執行緒更新UI。

runOnUiThread()、RxJava都能很方便的幫我們切換執行緒。這裡我們看一下協程的方式:

GlobalScope.launch(Dispatchers.Main) {
    val result = withContext(Dispatchers.IO){
        // 網路請求,並return請求結果
        ... result
    }
    // 更新UI
    updateUI(result)
}
複製程式碼

很直來直去的邏輯,很直來直去的程式碼。可讀性簡直+100%。

withContext()可以方便的幫我們在協程的上下文環境中切換執行緒,並返回執行結果。

3.3、方便的併發

我們再來看一段官方程式碼:

import kotlinx.coroutines.*
import kotlin.system.*

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = doSomethingUsefulOne()
        val two = doSomethingUsefulTwo()
        println("The answer is ${one + two}")
    }
    println("Completed in $time ms")    
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // 假設我們在這裡做了些有用的事
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // 假設我們在這裡也做了一些有用的事
    return 29
}
複製程式碼

輸出結果如下: The answer is 42

Completed in 2017 ms

假設我們耗時計算操作,沒有任何依賴關係。因此最佳的方案,就是讓它們倆並行執行。如何讓doSomethingUsefulOne()doSomethingUsefulTwo()同時執行呢?

答案是:async + await

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = async { doSomethingUsefulOne() }
        val two = async { doSomethingUsefulTwo() }
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")    
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // 假設我們在這裡做了些有用的事
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // 假設我們在這裡也做了些有用的事
    return 29
}
複製程式碼

四、總結

這篇文章,主要是引出協程。協程不是一個新概念,很多語言都支援。

協程,引入了掛起的概念,讓我們的函式可以隨意的暫停,然後在我們原意的時候再執行。通知提供給了我們同步寫非同步程式碼的能力...幫助我們更高效的寫程式碼,更直觀的寫程式碼。

尾聲

關於協程,有很多很多的內容,可以聊。因為篇幅和時間的關係更多的細節,留給我們接下來的文章吧。

我是一個應屆生,最近和朋友們維護了一個公眾號,內容是我們在從應屆生過渡到開發這一路所踩過的坑,以及我們一步步學習的記錄,如果感興趣的朋友可以關注一下,一同加油~

個人公眾號:鹹魚正翻身

相關文章