天天看點

Kotlin協程入門

1、概述

最開始準備學習協程的時候,網上鋪天蓋地的文章都在宣傳“Kotlin協程是一種輕量級的線程”,因為官方确實也是這麼說的。我非常疑惑,因為從國文的角度分析,去掉定語之後,就是“協程是線程”。既然協程是線程,那麼線程是變成協程之後,怎麼就輕量級了呢,是占用的資源少了?學完之後發現,其實協程的本質是個異步架構,隻是與RxJava等其他異步架構不同的是,它是文法級别的異步架構,也可以說是一個更友善的線程API集合。用不用協程對于資源開銷來說是沒什麼差別的,與使用線程池相關API相比也沒有明顯的效率上的差別,是以“輕量級”不知從何談起。

那麼協程跟線程到底有什麼關系呢?首先相似的地方是使用線程的時候我們會說“啟動一個線程去執行任務”,使用協程的時候我們也會說“啟動一個協程去執行任務”,它們可以說都是執行任務的一種載體。不同的地方是,啟動線程執行任務是任務會在啟動的線程執行,如果任務執行過程中需要執行類似更新UI等操作我們需要手動地去切換線程;而啟動協程執行任務,這個任務可能會跨越多個線程,線程切換的過程幾乎是架構自動幫我們完成的。

其實所有的異步架構都試圖解決兩個問題:

  1. 臭名昭著的“回調地獄”
  2. 簡化線程排程

相比于最原始的異步寫法,RxJava也在一定程度上解決了這兩個問題。RxJava仍有類似回調的東西,不過它隻有一層而不是一層一層又一層。而協程更為徹底和簡潔,它允許我們通過類似寫同步代碼的方式去寫異步的代碼。

2、協程的簡單使用

看如下示例

import kotlinx.coroutines.*
import java.util.*

fun main() {
    GlobalScope.launch {
        // 通過IO線程進行IO操作并擷取操作的位元組數
        var bytes = doIO()
        // 目前線程展示結果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")

        // 通過IO線程進行IO操作并擷取操作的位元組數
        bytes = doIO()
        // 目前線程展示結果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")

        // 通過IO線程進行IO操作并擷取操作的位元組數
        bytes = doIO()
        // 目前線程展示結果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")
    }

    println("Main thread is going on...")

    Thread.sleep(4000) // 等待操作結束
}

suspend fun doIO() = withContext(Dispatchers.IO) {
    delay(1000) // 模拟IO操作
    val bytes = Random().nextInt(10000) // 假設這是io位元組數
    println("Do IO at ${Thread.currentThread()},bytes:$bytes")
    bytes
}
           

輸出:

Main thread is going on...
Do IO at Thread[DefaultDispatcher-worker-1,5,main],bytes:9482
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 9482
Do IO at Thread[DefaultDispatcher-worker-1,5,main],bytes:7627
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 7627
Do IO at Thread[DefaultDispatcher-worker-1,5,main],bytes:311
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 311
           

這個段代碼進行了三次線程切換,請各位客官自行想象一下如果使用線程池API或者單開子線程的方式怎麼才能比較優雅地實作呢?

再來解釋一下,其中GlobalScope是協程的作用域,launch則是一個啟動協程的函數。沒錯,協程是有作用域的,比如引入“lifecycle-viewmodel-ktx”後在ViewModel中,我們可以通過以下代碼來啟動一個協程

viewModelScope.launch { 
    // do sth
}
           

架構會在ViewModel被清理的時候自動幫我們清理未完成的任務,ViewModel裡協程的作用域就是ViewModel存活的周期,全局作用域的協程除外。GlobalScope就是全局作用域,它伴随程序從啟動到結束,由它啟動的協程是不會被自動取消的,那麼能不能手動取消呢?是可以的,launch方法其實是有傳回值的

val job = GlobalScope.launch {
    ... // do sth
}

job.cancel() // 取消
           

另外注意到witContext方法是接收兩個參數的

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

一個是CoroutineContext接口,另一個就是要執行的block了。我們上面傳入的是Dispatchers .IO,它是CoroutineContext實作類的對象,在日常使用中其實我們可以簡單地認為它的作用就是線程排程,看一下還有哪些取值

public actual object Dispatchers {
    
    @JvmStatic
    public actual val Default: CoroutineDispatcher = createDefaultDispatcher()

    @JvmStatic
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher

    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined

    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultScheduler.IO
}
           

值得一提的是launch也是可以指定CoroutineContext的

GlobalScope.launch(Dispatchers.Main) {
    //do sth
}
           

需要注意的是Dispatchers.Main需要依賴kotlinx-coroutines-android

3、非阻塞式和挂起

“非阻塞式”并不是一個新興的名詞,指的就是不阻塞目前線程,無論是通過協程還是通過線程承載耗時任務在工作線程裡執行都是“非阻塞式”的,看如下示例

GlobalScope.launch(Dispatchers.IO) {
    delay(1000)
    println("Coroutine")
}

thread { 
    Thread.sleep(1000)
    print("Worker thread")
}

println("Main thread")
           

通過啟動協程和啟動線程來執行任務,對于主線程來說都是“非阻塞”的。是以阻塞與非阻塞是針對特定的線程來說的,耗時操作是必定會阻塞某一個線程的,隻是我們開發中需要避免阻塞主線程。

而挂起,也是相對指定的線程而言的。比如最開始的例子

fun main() {
    GlobalScope.launch {
        // 通過IO線程進行IO操作并擷取操作的位元組數
        var bytes = doIO()
        // 目前線程展示結果
        println("Show result at ${Thread.currentThread()}, IO bytes: $bytes")
    }

    println("Main thread is going on...")

    Thread.sleep(4000) // 等待操作結束
}

suspend fun doIO() = withContext(Dispatchers.IO) {
    delay(1000) // 模拟IO操作
    val bytes = Random().nextInt(10000) // 假設這是io位元組數
    println("Do IO at ${Thread.currentThread()},bytes:$bytes")
    bytes
}
           

協程在執行到doIO方法的時候,因為它是一個挂起函數,是以目前協程被目前線程挂起了,也就是目前線程暫時不會執行這個協程的代碼了,直到挂起函數傳回。那麼目前線程挂起之後,協程發生了什麼呢?協程切換到IO線程去做IO操作去了。也就是說協程被某一個線程挂起的時候,它是繼續工作的,隻是在另外一個線程。

被suspend關鍵字标記意味着這個函數是挂起函數,它隻能在協程或者另外一個挂起函數中被調用,也就是說隻能在協程裡調用挂起函數。但是導緻協程被挂起的并不是subspend關鍵字,而是kotlin内置的操作挂起的函數,比如上面的witchContext。subspend本身在我們使用的過程中隻是起到一個提醒和文法檢查的作用,這就導緻了我們在普通函數裡面調用被suspend關鍵字标記的函數時,Android Studio會給我們提示錯位。

4、異步并發

開發過程中往往會有異步并發的需求,以2中的IO為例,假設我們需要進行三次IO,并且統計出IO操作的總位元組數。如果使用線程的方式最簡單的方法是在一個子線程裡面串行地執行三次IO,最後把三次IO的結果累加;還有一個方法是并發地使用三個線程分别進行IO,然後每個IO操作執行完成之後,判斷其他的IO操作是否已經完成,在最後一個IO操作完成的時候累加得出結果。前者是非常低效率的一種方式,因為它是串行的,後者又比較複雜,在對臨界資源進行通路的時候要各種判斷和加鎖。如果使用協程,那問題将會變得非常簡單:

fun main() {
    GlobalScope.launch() {
        val bytes1 = async(Dispatchers.IO) { doIO() }
        val bytes2 = async(Dispatchers.IO) { doIO() }
        val bytes3 = async(Dispatchers.IO) { doIO() }
        // 目前線程展示結果
        println("Show result at ${Thread.currentThread()}, IO bytes: ${bytes1.await() + bytes2.await() + bytes3.await()}")
    }

    Thread.sleep(4000) // 等待操作結束
}

suspend fun doIO(): Int {
    val random = Random()
    Thread.sleep(random.nextInt(2000).toLong()) // 模拟IO操作
    val bytes = random.nextInt(10000) // 假設這是io位元組數
    println("Do IO at ${Thread.currentThread()}, IO bytes: $bytes")
    return bytes
}
           

輸出:

Do IO at Thread[DefaultDispatcher-worker-4,5,main], IO bytes: 1230
Do IO at Thread[DefaultDispatcher-worker-2,5,main], IO bytes: 2243
Do IO at Thread[DefaultDispatcher-worker-3,5,main], IO bytes: 4261
Show result at Thread[DefaultDispatcher-worker-1,5,main], IO bytes: 7734
           

這種寫法非常接近同步的寫法,但它是異步的,三次IO分别在三個線程執行,結果卻可以通過看似簡單的相加得倒。

繼續閱讀