天天看點

Kotlin實戰指南十三:協程

文章目錄

    • 前言-協程介紹
    • 主流語言對協程的支援
    • Android 項目引用
    • 建立一個協程
    • 取消協程工作
    • launch 參數詳解
    • 線程排程器 Dispatchers
    • withContext
    • withContext 的性能
    • 綜合演練
    • 協程到底是什麼
    • 參考資料

協程又稱微線程,從名字可以看出,協程的粒度比線程更小,并且是使用者管理和控制的,多個協程可以運作在一個線程上面。那麼協程出現的背景又是什麼呢,先來看一下目前線程中影響性能的特性:

  • 使用鎖機制
  • 線程間的上下文切換
  • 線程運作和阻塞狀态的切換

以上任意一點都是很消耗cpu性能的。相對來說協程是由程式自身控制,沒有線程切換的開銷,且不需要鎖機制,因為在同一個線程中運作,不存在同時寫變量沖突,在協程中操作共享資源不加鎖,隻需要判斷狀态就行了,是以執行效率比線程高的多。

But , But , But , But , But , But , But , But , But , But , But .......

在 kotlin 語言環境下,協程 僅僅是一個線程架構

, 并沒有什麼高深的東西,這一點會把很多初學者搞暈。

  • Lua語言

Lua從5.0版本開始使用協程,通過擴充庫coroutine來實作。

  • Python語言

python可以通過 yield/send 的方式實作協程。在python 3.5以後,async/await 成為了更好的替代方案。

  • Go語言

Go語言對協程的實作非常強大而簡潔,可以輕松建立成百上千個協程并發執行。

  • Java語言

如上文所說,Java語言并沒有對協程的原生支援,但是某些開源架構模拟出了協程的功能,有興趣的小夥伴可以看一看Kilim架構的源碼:https://github.com/kilim/kilim

  • C/C++

c/c++需要自己借助ucontext、setjmp、longjmp庫實作,微信開源了c/c++的協程庫libco。

Kotlin

協程庫的

GitHub

位址:https://github.com/Kotlin/kotlinx.coroutines/tree/master/ui/kotlinx-coroutines-android

Gradle

引用

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1"
           

class MainActivity : AppCompatActivity() {

    var tv1: TextView? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        tv1 = findViewById(R.id.tv1)
        
        //在主線程啟動一個協程
        GlobalScope.launch(Dispatchers.Main) {
            // launch coroutine in the main thread
            for (i in 10 downTo 1) { // countdown from 10 to 1
                tv1?.text = "Countdown $i ..." // update text
                delay(1000) // wait a second
            }
            tv1?.text = "Done!"
        }
    }
}
           

如果你仔細觀察,你會發現,耗時操作和更新UI 放在一起執行了,納尼?

你會有這樣的疑問,怎麼沒有線程切換,這難道不會卡頓嗎?

答案是不會的,這就是協程的牛逼之處。

還有一點需要注意,上面的代碼中,我們使用

delay(1000)

來做延時操作,

delay

是一個特殊的函數,這裡暫且稱之為挂起函數,它不會阻塞線程,但是會挂起協程,而且它隻能在協程中使用。

再延伸一點,我們能否用

Thread.sleep(1000)

來代替

delay(1000)

, 答案是不能的。我們的協程是在主線程的基礎上建立的,本質上是主線程的小邏輯單元,用

Thread.sleep(1000)

會直接卡死 UI 主線程。

java

開發

Android

應用時,我們用子線程執行耗時操作,當然我們也會中斷子線程來達到取消耗時操作的目的。

那麼我們在協程中執行耗時操作的時候,改怎麼取消呢?

GlobalScope.launch

的傳回值是

Job

對象,用

job.cancel()

來取消協程。例子如下:

class MainActivity : AppCompatActivity() {

    var tv1: TextView? = null
    var mCancelButton: Button? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        tv1 = findViewById(R.id.tv1)
        mCancelButton = findViewById(R.id.cancel)

        //在主線程啟動一個協程
        var job = GlobalScope.launch(Dispatchers.Main) {
            // launch coroutine in the main thread
            for (i in 10 downTo 1) { // countdown from 10 to 1
                tv1?.text = "Countdown $i ..." // update text
                delay(1000) // wait a second
            }
            tv1?.text = "Done!"
        }

        mCancelButton?.setOnClickListener {
            job.cancel() //取消協程工作
            mCancelButton?.text = "已經取消了"
        }
    }
}
           

在上文中,我們已經學會了使用

GlobalScope.launch

建立一個協程,下面我們來看看建立協程所需要的參數,

launch

的參數有三個,依次為

協程上下文

協程啟動模式

協程體

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,  //上下文`
    start: CoroutineStart = CoroutineStart.DEFAULT,   //啟動模式
    block: suspend CoroutineScope.() -> Unit   //協程體
): Job
           

啟動模式

不是一個很複雜的概念,不過我們暫且不管,預設直接允許排程執行。

上下文

可以有很多作用,包括

攜帶參數

攔截協程執行

等等,多數情況下我們不需要自己去實作上下文,隻需要使用現成的就好。上下文有一個重要的作用就是線程切換,

Dispatchers.Main

就是一個官方提供的上下文,它可以確定

launch

啟動的協程體運作在

UI

線程當中(除非你自己在

launch

的協程體内部進行線程切換、或者啟動運作在其他有線程切換能力的上下文的協程)。

協程體

就是我們具體執行的代碼

上面我們建立協程的時候,用的是:

GlobalScope.launch(Dispatchers.Main) {
   //do some things
}
           

為了指定coroutines在什麼線程運作,kotlin提供了四種Dispatchers:

Dispatchers 用途 使用場景
Dispatchers.Main 主線程,和UI互動,執行輕量任務 1.call suspend functions。2. call UI functions。 3. Update LiveData
Dispatchers.IO 用于網絡請求和檔案通路 1. Database。 2.Reading/writing files。3. Networking
Dispatchers.Default CPU密集型任務 1. Sorting a list。 2.Parsing JSON。 3.DiffUtils
Dispatchers.Unconfined 不限制任何制定線程 進階排程器,不應該在正常代碼裡使用

上面的部分,我們介紹了排程器

Dispatchers

, 那麼具體是怎麼切換線程的,就是用

withContext

函數。

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
):
           

withContext

suspend

修飾,說明

suspend

是一個挂起函數。

withContext(Dispatchers.IO)

定義一段代碼塊,這個代碼塊将在排程器

Dispatchers.IO

中運作,方法塊中的任何代碼總是會運作在

IO

排程器中。

舉個例子:

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}

// Dispatchers.Main
suspend fun get(url: String) =
    // Dispatchers.IO
    withContext(Dispatchers.IO) {
        // Dispatchers.IO
        /* perform blocking network IO here */
    }
    // Dispatchers.Main
}
           

通過協程,你可以細粒度的控制線程排程,因為

withContext

讓你可以控制任意一行代碼運作在什麼線程上,而不用引入回調來擷取結果。你可将其應用在很小的函數中,例如資料庫操作和網絡請求。是以,比較好的做法是,使用

withContext

確定每個函數在任意排程器上執行都是安全的,包括

Main

,這樣調用者在調用函數時就不需要考慮應該運作在什麼線程上。

對于提供主線程安全性,

withContext

與回調或

RxJava

一樣快。在某些情況下,甚至可以使用協程上下文

withContext

來優化回調。如果一個函數将對資料庫進行10次調用,那麼您可以告訴

Kotlin

在外部的

withContext

中調用一次切換。盡管資料庫會重複調用

withContext

,但是他它将在同一個排程器下,尋找最快路徑。此外,

Dispatchers.Default

Dispatchers.IO

之間的協程切換已經過優化,以盡可能避免線程切換。

下面我們來模拟一個真實的網絡請求

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //在子線程啟動一個協程
        GlobalScope.launch(Dispatchers.IO) {

            //發起一個網絡請求
            var result = HttpUtil.get("https://www.baidu.com")

            Log.e("zhaoyanjun:22", "${Thread.currentThread().name}")

            withContext(Dispatchers.Main) {
                //網絡請求成功以後,到主線程更新UI
                Log.e("zhaoyanjun:33", "${Thread.currentThread().name}")
            }

            //再次回到子線程的協程
            Log.e("zhaoyanjun:44", "${Thread.currentThread().name}")
        }
    }
}
           

日志列印結果:

E/zhaoyanjun:22: DefaultDispatcher-worker-2 
E/zhaoyanjun:33: main
E/zhaoyanjun:44: DefaultDispatcher-worker-2
           

好,堅持讀到這裡的朋友們,你們一定是異步代碼的“受害者”,你們肯定遇到過“回調地獄”,它讓你的代碼可讀性急劇降低;也寫過大量複雜的異步邏輯處理、異常處理,這讓你的代碼重複邏輯增加;因為回調的存在,還得經常處理線程切換,這似乎并不是一件難事,但随着代碼體量的增加,它會讓你抓狂,線上上報的異常因線程使用不當導緻的可不在少數。

而協程可以幫你優雅的處理掉這些。

簡單來說就是,協程是一種非搶占式或者說協作式的計算機程式并發排程的實作,程式可以主動挂起或者恢複執行。這裡還是需要有點兒作業系統的知識的,我們在 Java 虛拟機上所認識到的線程大多數的實作是映射到核心的線程的,也就是說線程當中的代碼邏輯線上程搶到 CPU 的時間片的時候才可以執行,否則就得歇着,當然這對于我們開發者來說是透明的;而經常聽到所謂的協程更輕量的意思是,協程并不會映射成核心線程或者其他這麼重的資源,它的排程在使用者态就可以搞定,任務之間的排程并非搶占式,而是協作式的。

如果大家熟悉 Java 虛拟機的話,就想象一下 Thread 這個類到底是什麼吧,為什麼它的 run 方法會運作在另一個線程當中呢?誰負責執行這段代碼的呢?顯然,咋一看,Thread 其實是一個對象而已,run 方法裡面包含了要執行的代碼——僅此而已。協程也是如此,如果你隻是看标準庫的 API,那麼就太抽象了,但我們開篇交代了,學習協程不要上來去接觸标準庫,kotlinx.coroutines 架構才是我們使用者應該關心的,而這個架構裡面對應于 Thread 的概念就是 Job 了,大家可以看下它的定義:

public interface Job : CoroutineContext.Element {
    ...
    public val isActive: Boolean
    public val isCompleted: Boolean
    public val isCancelled: Boolean

    public fun start(): Boolean
    public fun cancel(cause: CancellationException? = null)
    public suspend fun join()
    ...
}
           

我們再來看看 Thread 的定義:

public class Thread implements Runnable {
    ...    
    public final native boolean isAlive();
    public synchronized void start() { ... }
    @Deprecated
    public final void stop() { ... }
    public final void join() throws InterruptedException  { ... }
    ...
}
           

這裡我們非常貼心的省略了一些注釋和不太相關的接口。我們發現,Thread 與 Job 基本上功能一緻,它們都承載了一段代碼邏輯(前者通過 run 方法,後者通過構造協程用到的 Lambda 或者函數),也都包含了這段代碼的運作狀态。

而真正排程時二者才有了本質的差異,具體怎麼排程,我們隻需要知道排程結果就能很好的使用它們了。