Kotlin 協程一 —— 協程 Coroutine

Kotlin 協程系列文章導航:
Kotlin 協程一 —— 協程 Coroutine
Kotlin 協程二 —— 通道 Channel
Kotlin 協程三 —— 數據流 Flow
Kotlin 協程四 —— Flow 和 Channel 的應用
Kotlin 協程五 —— 在Android 中使用 Kotlin 協程

一、協程的一些前置知識

1.1 進程和線程

1.1.1基本定義

進程
進程是一個具有一定獨立功能的程序在一個數據集上的一次動態執行的過程,是操作系統進行資源分配和調度的一個獨立單位,是應用程序運行的載體。
進程是資源分配的最小單位,在單核CPU中,同一時刻只有一個程序在內存中被CPU調用運行。

線程
基本的CPU執行單元,程序執行過程中的最小單元,由 線程ID程序計數器寄存器組合堆棧 共同組成。
線程的引入減小了程序併發執行時的開銷,提高了操作系統的併發性能。

1.1.2爲什麼要有線程

  1. 單個進程只能幹一件事,進程中的代碼依舊是串行執行。
  2. 執行過程如果堵塞,整個進程就會掛起,即使進程中某些工作不依賴於正在等待的資源,也不會執行。
  3. 多個進程間的內存無法共享,進程間通訊比較麻煩

1.1.3 進程與線程的區別

  1. 一個程序至少有一個進程,一個進程至少有一個線程,可以把進程理解做 線程的容器;
  2. 進程在執行過程中擁有 獨立的內存單元,該進程裏的多個線程 共享內存;
  3. 進程可以拓展到 多機,線程最多適合 多核;
  4. 每個獨立線程有一個程序運行的入口、順序執行列和程序出口,但不能獨立運行,需依存於應用程序中,由應用程序提供多個線程執行控制;
  5. 「進程」是「資源分配」的最小單位,「線程」是 「CPU調度」的最小單位
  6. 進程和線程都是一個時間段的描述,是 CPU工作時間段的描述,只是顆粒大小不同。

1.2 協作式與搶佔式

1.2.1 協作式

早期的操作系統採用的就是協作式多任務, 即:由進程主動讓出執行權,如當前進程需等待IO操作,主動讓出CPU,由系統調度下一個進程。
問題:

  1. 流氓應用進程一直佔用cpu,不讓出資源
  2. 某個進程程序健壯性較差,出現死循環、死鎖等問題,導致整個系統癱瘓。

1.2.2 搶佔式

操作系統決定執行權,操作系統具有從任何一個進程取走控制權和使另一個進程獲得控制權的能力。系統公平合理地爲每個進程分配時間片,進程用完就休眠,甚至時間片沒用完,但有更緊急的事件要優先執行,也會強制讓進程休眠。

有了進程設計的經驗,線程也做成了搶佔式多任務,但也帶來了新的——線程安全問題,這個一般通過加鎖的方式來解決,這裏就不展開了。

1.3 協程

Go、Python 等很多變成語言在語言層面上都實現協程,java 也有三方庫實現協程,只是不常用, Kotlin 在語言層面上實現協程,對比 java, 主要還是用來解決異步任務線程切換的痛點。

協程基於線程,但相對於線程輕量很多,可理解爲在用戶層模擬線程操作;
每創建一個協程,都有一個內核態線程動態綁定,用戶態下實現調度、切換,真正執行任務的還是內核線程。
線程的上下文切換都需要內核參與,而協程的上下文切換,完全由用戶去控制,避免了大量的中斷參與,減少了線程上下文切換與調度消耗的資源。
線程是操作系統層面的概念,協程是語言層面的概念

線程與協程最大的區別在於:線程是被動掛起恢復,協程是主動掛起恢復。

一種非搶佔式(協作式)的任務調度模式,程序可以主動掛起或者恢復執行。

本質上,協程是輕量級的線程。 —— kotlin 中文文檔

我覺得這個概念有點模糊---------把人帶入誤區。後面再說。

"假"協程,Kotlin在語言級別並沒有實現一種同步機制(鎖),還是依靠Kotlin-JVM的提供的Java關鍵字(如synchronized),即鎖的實現還是交給線程處理
因而Kotlin協程本質上只是一套基於原生Java線程池 的封裝。
Kotlin 協程的核心競爭力在於:它能簡化異步併發任務,以同步方式寫異步代碼。

二、 Kotlin 協程的基本使用

講概念之前,先講用法。

場景: 開啓工作線程執行一段耗時任務,然後在主線程對結果進行處理。

常見的處理方式:

  • 自己定義回調,進行處理

  • 使用 線程/線程池, Callable
    線程 Thread(FeatureTask(Callable)).start
    線程池 submit(Callable)

  • Android: Handler、 AsyncTask、 Rxjava

使用協程:

coroutineScope.launch(Dispatchers.Main) { // 在主線程啓動一個協程
    val result = withContext(Dispatchers.Default) { // 切換到子線程執行
        doSomething()  // 耗時任務
    }
    handResult(result)  // 切回到主線程執行
}

這裏需要注意的是: Dispatchers.Main 是 Android 裏面特有的,如果是java程序裏面是用則會拋出異常。

2.1 創建協程的三種方式

  1. 使用 runBlocking 頂層函數創建:
runBlocking {
    ...
}
  1. 使用 GlobalScope 單例對象創建
GlobalScope.launch {
    ...
}
  1. 自行通過 CoroutineContext 創建一個 CoroutineScope 對象
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
    ...
}
  • 方法一通常適用於單元測試的場景,而業務開發中不會用到這種方法,因爲它是線程阻塞的。
  • 方法二和使用 runBlocking 的區別在於不會阻塞線程。但在 Android 開發中同樣不推薦這種用法,因爲它的生命週期會只受整個應用程序的生命週期限制,且不能取消。
  • 方法三是比較推薦的使用方法,我們可以通過 context 參數去管理和控制協程的生命週期(這裏的 context 和 Android 裏的不是一個東西,是一個更通用的概念,會有一個 Android 平臺的封裝來配合使用)。

2.2 等待一個作業

先看一個示例:

fun main() = runBlocking {
    launch {
        delay(100)
        println("hello")
        delay(300)
        println("world")
    }
    println("test1")
    println("test2")
}

執行結果如下:

test1
test2
hello
world

我們啓動了一個協程之後,可以保持對它的引用,顯示地等待它執行結束,注意這裏的等待是非阻塞的,不會將當前線程掛起。

fun main() = runBlocking {
    val job = launch {
        delay(100)
        println("hello")
        delay(300)
        println("world")
    }
    println("test1")
    job.join()
    println("test2")
}

輸出結果:

test1
hello
world
test2

類比 java 線程,也有 join 方法。但是線程是操作系統界別的,在某些 cpu 上,可能 join 方法不生效。

2.3 協程的取消

與線程類比,java 線程其實沒有提供任何機制來安全地終止線程。
Thread 類提供了一個方法 interrupt() 方法,用於中斷線程的執行。調用interrupt()方法並不意味着立即停止目標線程正在進行的工作,而只是傳遞了請求中斷的消息。然後由線程在下一個合適的時機中斷自己。

但是協程提供了一個 cancel() 方法來取消作業。

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("job: test $i ...")
            delay(500L)
        }
    }
    delay(1300L) // 延遲一段時間
    println("main: ready to cancel!")
    job.cancel() // 取消該作業
    job.join() // 等待作業執行結束
    println("main: Now cancel.")
}

輸出結果:

job: test 0 ...
job: test 1 ...
job: test 2 ...
main: ready to cancel!
main: Now cancel.

也可以使用函數 cancelAndJoin, 它合併了對 cancel 以及 join 的調用。

問題:
如果先調用 job.join() 後調用 job.cancel() 是是什麼情況?

取消是協作的
協程並不是一定能取消,協程的取消是協作的。一段協程代碼必須協作才能被取消。
所有 kotlinx.coroutines 中的掛起函數都是 可被取消的 。它們檢查協程的取消, 並在取消時拋出 CancellationException。
如果協程正在執行計算任務,並且沒有檢查取消的話,那麼它是不能被取消的。

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) { // 一個執行計算的循環,只是爲了佔用 CPU
            // 每秒打印消息兩次
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: hello ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // 等待一段時間
    println("main: ready to cancel!")
    job.cancelAndJoin() // 取消一個作業並且等待它結束
    println("main: Now cancel.")
}

此時的打印結果:

job: hello 0 ...
job: hello 1 ...
job: hello 2 ...
main: ready to cancel!
job: hello 3 ...
job: hello 4 ...
main: Now cancel.

可見協程並沒有被取消。爲了能真正停止協程工作,我們需要定期檢查協程是否處於 active 狀態。

檢查 job 狀態
一種方法是在 while(i<5) 中添加檢查協程狀態的代碼
代碼如下:

while (i < 5 && isActive)

這樣意味着只有當協程處於 active 狀態時,我們工作的纔會執行。

另一種方法使用協程標準庫中的函數 ensureActive(), 它的實現是這樣的:

public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

代碼如下:

while (i < 5) { // 一個執行計算的循環,只是爲了佔用 CPU
    ensureActive()
    ...
}

ensureActive() 在協程不在 active 狀態時會立即拋出異常。

使用 yield()
yield()ensureActive 使用方式一樣。
yield 會進行的第一個工作就是檢查任務是否完成,如果 Job 已經完成的話,就會拋出 CancellationException 來結束協程。yield 應該在定時檢查中最先被調用。

while (i < 5) { // 一個執行計算的循環,只是爲了佔用 CPU
    yield()
    ...
}

2.4 等待協程的執行的結果

對於無返回值的的協程使用 launch 函數創建,如果需要返回值,則通過 async 函數創建。
使用 async 方法啓動 Deferred (也是一種 job), 可以調用它的 await() 方法獲取執行的結果。
形如下面代碼:

val asyncDeferred = async {
    ...
}

val result = asyncDeferred.await()

deferred 也是可以取消的,對於已經取消的 deferred 調用 await() 方法,會拋出
JobCancellationException 異常。

同理,在 deferred.await 之後調用 deferred.cancel(), 那麼什麼都不會發生,因爲任務已經結束了。

關於 async 的具體用法後面異步任務再講。

2.5 協程的異常處理

由於協程被取消時會拋出 CancellationException ,所以我們可以把掛起函數包裹在 try/catch 代碼塊中,這樣就可以在 finally 代碼塊中進行資源清理操作了。

fun main() = runBlocking {
    val job = launch {
        try {
            delay(100)
            println("try...")
        } catch (e: Exception) {
            println("exception: ${e.message}")
        } finally {
            println("finally...")
        }
    }
    delay(50)
    println("cancel")
    job.cancel()
    print("Done")
}

結果:

cancel
Doneexception: StandaloneCoroutine was cancelled
finally...

2.6 協程的超時

在實踐中絕大多數取消一個協程的理由是它有可能超時。 當你手動追蹤一個相關 Job 的引用並啓動,使用 withTimeout 函數。

fun main() = runBlocking {
    withTimeout(300) {
        println("start...")
        delay(100)
        println("progress 1...")
        delay(100)
        println("progress 2...")
        delay(100)
        println("progress 3...")
        delay(100)
        println("progress 4...")
        delay(100)
        println("progress 5...")
        println("end")
    }
}

結果:

start...
progress 1...
progress 2...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 300 ms

withTimeout 拋出了 TimeoutCancellationException,它是 CancellationException 的子類。 我們之前沒有在控制檯上看到堆棧跟蹤信息的打印。這是因爲在被取消的協程中 CancellationException 被認爲是協程執行結束的正常原因。 然而,在這個示例中我們在 main 函數中正確地使用了 withTimeout。如果有必要,我們需要主動 catch 異常進行處理。

當然,還有另一種方式: 使用 withTimeoutOrNull

withTimeout 是可以由返回值的,執行 withTimeout 函數,會阻塞並等待執行完返回結果或者超時拋出異常。withTimeoutOrNull 用法與 withTimeout 一樣,只是在超時後返回 null 。

三、併發與掛起函數

3.1 使用 async 併發

考慮一個場景: 開啓多個任務,併發執行,所有任務執行完之後,返回結果,再彙總結果繼續往下執行。
針對這種場景,解決方案有很多,比如 java 的 FeatureTask, concurrent 包裏面的 CountDownLatch、Semaphore, Rxjava 提供的 Zip 變換操作等。

前面提到有返回值的協程,我們通常使用 async 函數來啓動。

這裏看一段代碼:

fun main() = runBlocking {
    val time = measureTimeMillis {
        val a = async(Dispatchers.IO) {
            printWithThreadInfo()
            delay(1000) // 模擬耗時操作
            1
        }
        val b = async(Dispatchers.IO) {
            printWithThreadInfo()
            delay(2000) // 模擬耗時操作
            2
        }
        printWithThreadInfo("${a.await() + b.await()}")
        printWithThreadInfo("end")
    }
    printWithThreadInfo("time: $time")
}

執行結果:

thread id: 12, thread name: DefaultDispatcher-worker-1 ---> 
thread id: 14, thread name: DefaultDispatcher-worker-3 ---> 
thread id: 1, thread name: main ---> 3
thread id: 1, thread name: main ---> end
thread id: 1, thread name: main ---> time: 2051

async 啓動一個協程後,調用 await 方法後,會阻塞,等待結果的返回,同樣能達到效果。

3.2 惰性啓動 async

async 可以通過將 start 參數設置爲 CoroutineStart.LAZY 變成惰性的。在這個模式下,調用 await 獲取協程執行結果的時候,或者調用 Job 的 start 方法時,協程纔會啓動。

fun main() = runBlocking {
    val time = measureTimeMillis {
        val a = async(Dispatchers.IO, CoroutineStart.LAZY) {
            printWithThreadInfo()
            delay(1000) // 模擬耗時操作
            1
        }
        val b = async(Dispatchers.IO, CoroutineStart.LAZY) {
            printWithThreadInfo()
            delay(2000) // 模擬耗時操作
            2
        }
        a.start()
        b.start()
        printWithThreadInfo("${a.await() + b.await()}")
        printWithThreadInfo("end")
    }
    printWithThreadInfo("time: $time")
}

執行結果:

thread id: 14, thread name: DefaultDispatcher-worker-3 ---> 
thread id: 12, thread name: DefaultDispatcher-worker-1 ---> 
thread id: 1, thread name: main ---> 3
thread id: 1, thread name: main ---> end
thread id: 1, thread name: main ---> time: 2037

試想,如果沒有顯示調用 start() 方法,結果會怎樣?

3.3 掛起函數

還是上面的例子,加入我們把任務 a 的計算過程提取成一個函數。如下:

fun main() = runBlocking {
    val time = measureTimeMillis {
        val a = async(Dispatchers.IO) {
            calA()
        }
        val b = async(Dispatchers.IO) {
            printWithThreadInfo()
            delay(2000) // 模擬耗時操作
            2
        }
        printWithThreadInfo("${a.await() + b.await()}")
        printWithThreadInfo("end")
    }
    printWithThreadInfo("time: $time")
}

fun calA(): Int {
    printWithThreadInfo()
    delay(1000) // 模擬耗時操作
    return 1
}

此時會發現,編譯器報錯了。

delay(1000) // 模擬耗時操作

該行報錯爲:Suspend function 'delay' should be called only from a coroutine or another suspend function
掛起函數 delay 應該在另一個掛起函數調用。

查看 delay 函數源碼:

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        if (timeMillis < Long.MAX_VALUE) {
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

可以看到,方法簽名用 suspend 修飾,表示該函數是一個掛起函數。解決這個異常,只需要將我們定義的 calA() 方法也用 suspend 修飾,使其變成一個掛起函數。

使用 suspend 關鍵字修飾的函數成爲掛起函數,掛起函數只能在另一個掛起函數,或者協程中被調用。在掛起函數中可以調用普通函數(非掛起函數)。

3.4 協程和掛起的本質

3.4.1 協程到底是什麼

kotlin 中文文檔中說,本質上,協程是輕量級的線程。我前面說,這個概念有點模糊,kotlin 協程的實現是藉助線程,可以理解爲對線程的一個封裝框架。啓動一個協程,使用 launch 或者 async 函數,啓動的是函數中閉包代碼塊,好比啓動一個線程,實現上是執行 run 方法中的代碼,所以協程可以理解爲是這個代碼塊。
協程的核心點就是函數或者一段程序能夠被掛起,稍後再在掛起的位置恢復。

3.4.2 掛起是什麼意思

那協程中掛起是什麼意思?
suspend 翻譯過來是,中斷、暫停的意思。剛開始接觸到這個概念的時候,覺得掛起,就是代碼執行到這裏停下來了,這是不對的。

我們在協程中應該理解爲:當線程執行到協程的 suspend 函數的時候,暫時不繼續執行協程代碼了。這個掛起,是針對當前線程來說的,從當前線程掛起,就是這個協程從執行它的線程上脫離,並不是說協程停下來了,而是當前線程不再管這個協程要去做什麼了。

當協程執行到掛起函數時,從當前線程脫離,然後繼續執行,這個時候在哪個線程執行,由協程調度器所指定,掛起函數執行完之後,又會重新切回到它原先的線程來。這個就是協程的優勢所在。

理解一下協程和線程的區別:

  • 線程一旦開始執行就不會暫停,直到任務結束,這個過程是連續的,線程是搶佔式的調度,不存在協作的問題。
  • 協程程序能夠自己掛起和恢復,程序自己處理掛起恢復實現程序執行流程的協作式調度。

Kotlin 中所謂的掛起,就是一個稍後會被自動切回來的線程調度操作,這個 resume 功能是協程的,如果不在協程裏面調用,那它就沒法恢復。所以掛起函數必須在協程或者另一個掛起函數裏面被調用。總是直接或者間接地在協程裏被調用。

3.5 如何實現掛起函數

實現掛起的的目的是讓程序脫離當前的線程,也就是要切線程,kotlin 協程提供了一個 withContext() 方法,來實現線程切換。

private suspend fun calB(): Int {
    withContext(Dispatchers.IO) {
        printWithThreadInfo()
    }
    return 2
}

withContext() 本身也是一個掛起函數,它接收一個 Dispatcher參數,依賴這個參數,協程被掛起,切到別的線程。所以想要自己寫一個掛起函數,除了加上 suspend 關鍵字加以休市以外,還需要函數內部直接或者間接的調用 Kotlin 協程框架自帶的掛起函數纔行。比如前面調用的 delay 函數,框架內部實際上進行了切線程的操作。

3.5.1 suspend 的意義

suspend 並不能切換線程。切線程依賴的是掛起函數裏面的實際代碼,這個關鍵字,只是一個提醒作用。如果我創建一個 suspend 函數,內部不包含其它掛起函數,編譯器同樣會提示這個修飾符是多餘的。

suspend 表明這個函數時掛起函數,限制了它只能在協程或者其它掛起函數裏面調用。

其它語言,比如 C#,使用的 async 關鍵字。

3.5.2 如何定義掛起函數

如果一個函數比較耗時,那麼就可以把它定義成掛起函數。耗時一般有兩種情況: I/O 操作和CPU 計算工作。
另外還有延時操作也可以把它定義成掛起函數,代碼本身執行不耗時,但是需要延時一段時間。

寫法
給函數加上 suspend 關鍵字,如果是耗時操作在 withContext 把函數的內容操作就可以了。如果是延時操作,則調用 delay 函數即可。
延時操作:

suspend fun testA() {
    ...
    delay(1000)
    ...
}

耗時操作:

suspend fun testB() {
    withContext(Dispatchers.IO) {
        ...
    }
}

也可以寫成:

suspend fun testB() = withContext(Dispatchers.IO) {
    ...
}

四、協程的上下文和作用域

兩個概念:

  • CoroutineContext 協程的上下文
  • CoroutineScope 協程的作用域

4.1 協程上下文 CoroutineContext

協程總是運行在一些以 CoroutineContext 類型爲代表的上下文中。協程上下文是各種不同元素的集合。其中主元素是協程中的 Job 以及它的調度器。

協程上下文包含當前協程scope的信息, 比如的Job, ContinuationInterceptor, CoroutineName 和CoroutineId。在CoroutineContext中,是用map來存這些信息的, map的鍵是這些類的伴生對象,值是這些類的一個實例,你可以這樣子取得context的信息:

val job = context[Job]
val continuationInterceptor = context[ContinuationInterceptor]

Job繼承了CoroutineContext.Element,CoroutineContext.Element繼承了 CoroutineContext。 他是協程上下文的一部分。 Job 一個重要的子類 ———— AbstractCoroutine,即協程。使用launch 或者async方法都會實例化出一個AbstractCoroutine 的協程對象。一個協程的協程上下文的Job值就是他本身。

val job = mScope.launch {
        printWithThreadInfo("job: ${this.coroutineContext[Job]}")
    }
    printWithThreadInfo("job2: $job")
    printWithThreadInfo("job3: ${job[Job]}")

輸出:

thread id: 1, thread name: main ---> job2: StandaloneCoroutine{Active}@1ee0005
thread id: 12, thread name: test_dispatcher ---> job: StandaloneCoroutine{Active}@1ee0005
thread id: 1, thread name: main ---> job3: StandaloneCoroutine{Active}@1ee0005

協程上下文包含一個 協程調度器 (CoroutineDispatcher)它確定了相關的協程在哪個線程或哪些線程上執行。協程調度器可以將協程限制在一個特定的線程執行,或將它分派到一個線程池,亦或是讓它不受限地運行。
所有的協程構建器諸如 launch 和 async 接收一個可選的 CoroutineContext 參數,它可以被用來顯式的爲一個新協程或其它上下文元素指定一個調度器。
當調用 launch { …… } 時不傳參數,它從啓動了它的 CoroutineScope 中承襲了上下文(以及調度器)。

CoroutineContext最重要的兩個信息是 Dispatcher 和 Job, 而 Dispatcher 和 Job 本身又實現了 CoroutineContext 的接口。是其子類。
這個設計就很有意思了。

有時我們需要在協程上下文中定義多個元素。我們可以使用 + 操作符來實現。 比如說,我們可以顯式指定一個調度器來啓動協程並且同時顯式指定一個命名:

launch(Dispatchers.Default + CoroutineName("test")) {
    println("I'm working in thread ${Thread.currentThread().name}")
}

這得益於 CoroutineContext 重載了操作符 +

4.2 協程作用域 CoroutineScope

CoroutineScope 即協程運行的作用域,它的源碼如下:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

可以看出CoroutineScope的代碼很簡單,主要作用是提供 CoroutineContext, 啓動協程需要 CoroutineContext。
作用域可以管理其域內的所有協程。一個CoroutineScope可以有許多的子scope。協程內部是通過 CoroutineScope.coroutineContext 自動繼承自父協程的上下文。而 CoroutineContext 就是在作用域內爲協程進行線程切換的快捷方式。

注意:當使用 GlobalScope 來啓動一個協程時,則新協程的作業沒有父作業。 因此它與這個啓動的作用域無關且獨立運作。GlobalScope 包含的是 EmptyCoroutineContext。

  • 一個父協程總是等待所有的子協程執行結束。父協程並不顯式的跟蹤所有子協程的啓動,並且不必使用 Job.join 在最後的時候等待它們。
  • 取消父協程會取消所有的子協程。所以使用 Scope 來管理協程的生命週期。
  • 默認情況下,協程內,某個子協程拋出一個非 CancellationException 異常,未被捕獲,會傳遞到父協程,任何一個子協程異常退出,那麼整體都將退出

4.3 創建 CoroutineScope

創建一個 CoroutineScope, 只需調用 public fun CoroutineScope(context: CoroutineContext) 方法,傳入一個 CoroutineContext 對象。

在協程作用域內,啓動一個子協程,默認自動繼承父協程的上下文,但在啓動時,我們可以指定傳入上下文。

val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
val myScope = CoroutineScope(dispatcher)
myScope.launch {
    ...
}

4.4 SupervisorJob

啓動一個協程,默認是實例化的是 Job 類型。該類型下,協程內,某個子協程拋出一個非 CancellationException 異常,未被捕獲,會傳遞到父協程,任何一個子協程異常退出,那麼整體都將退出。
爲了解決上述問題,可以使用SupervisorJob替代Job,SupervisorJob與Job基本類似,區別在於不會被子協程的異常所影響。

private val svJob = SupervisorJob()
private val mDispatcher = newSingleThreadContext("test_dispatcher")

private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    printWithThreadInfo("exceptionHandler: throwable: $throwable")
}

private val svScope = CoroutineScope(svJob + mDispatcher + exceptionHandler)
private val mScope = CoroutineScope(Job() + mDispatcher + exceptionHandler)

svScope.launch {
    ...
}

// 或者
supervisorScope { 
    launch { 
        ...
    }
}

4.5 如何在 Android 中使用協程

4.5.1 自定義 coroutineScope

不要使用 GlobalScope 去啓動協程,因爲 GlobalScope 啓動的協程生命週期與應用程序的生命週期一致,無法取消。官方建議在 Android 中自定義協程作用域。當然Kotlin 給我們提供了 MainScope,我們可以直接使用。

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

然後讓 Activity 實現該作用域:

class BasicCorotineActivity : AppCompatActivity(), CoroutineScope by MainScope() {
    ...
}

然後再通過 launch 或者 async 啓動協程

private fun loadAndShow() {
    launch {
        val task = async(Dispatchers.IO) {
            // load 過程
            delay(3000)
            ...
            "hello, kotlin"
        }
        tvShow.setText(task.await())
    }
}

最後別忘了,在 Activity onDestory 時取消協程。

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

4.5.2 ViewModelScope

如果你使用了 ViewModel + LiveData 實現 MVVM 架構,根本就不會在 Activity 上書寫任何邏輯代碼,更別說啓動協程了。這個時候大部分工作就要交給 ViewModel 了。那麼如何在 ViewModel 中定義協程作用域呢?直接把上面的 MainScope() 搬過來就可以了。

class ViewModelOne : ViewModel() {

    private val viewModelJob = SupervisorJob()
    private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)

    val mMessage: MutableLiveData<String> = MutableLiveData()

    fun getMessage(message: String) {
        uiScope.launch {
            val deferred = async(Dispatchers.IO) {
                delay(2000)
                "post $message"
            }
            mMessage.value = deferred.await()
        }
    }

    override fun onCleared() {
        super.onCleared()
        viewModelJob.cancel()
    }
}

這裏的 uiScope 其實就等同於 MainScope。調用 getMessage() 方法和之前的 loadAndShow() 效果也是一樣的,記得在 ViewModel 的 onCleared() 回調裏取消協程。

你可以定義一個 BaseViewModel 來處理這些邏輯,避免重複書寫模板代碼。

然而,Kotlin 提供了 viewmodel-ktx 來了。引入下面的依賴:

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-alpha03"

然後直接使用協程作用域 viewModelScope 就可以了。viewModelScope 是 ViewModel 的一個擴展屬性,定義如下:

val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main))
    }

所以,直接使用 viewModelScope 就是最好的選擇。

4.5.3 LifecycleScope

與 viewModelScope 配套的 還有 LifecycleScope, 引入依賴:

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03"

lifecycle-runtime-ktx 給每個 LifeCycle 對象通過擴展屬性定義了協程作用域 lifecycleScope 。可以通過 lifecycle.coroutineScope 或者 lifecycleOwner.lifecycleScope 進行訪問。示例代碼如下:

lifecycleOwner.lifecycleScope.launch {
    val deferred = async(Dispatchers.IO) { 
        getMessage("LifeCycle Ktx")
    }
    mMessage.value = deferred.await()
}

當 LifeCycle 回調 onDestroy() 時,協程作用域 lifecycleScope 會自動取消。

五、協程併發中的數據同步問題

5.1 線程的數據安全問題

經典例子:

var flag = true

fun main() {
    Thread {
        Thread.sleep(1000)
        flag = false
    }.start()
    while (flag) {
    }
}

程序並沒有像我們所期待的那樣,在一秒之後,退出,而是一直處於循環中。

flag 加上 volatile 關鍵修飾:

@Volatile
var flag = true

沒有用 volatile 修飾 flag 之前,改變了不具有可見性,一個線程將它的值改變後,另一個線程卻 “不知道”,所以程序沒有退出。當把變量聲明爲 volatile 類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile 變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。

在訪問volatile變量時不會執行加鎖操作,因此也就不會使執行線程阻塞,因此volatile變量是一種比sychronized關鍵字更輕量級的同步機制。

當對非 volatile 變量進行讀寫的時候,每個線程先從內存拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味着每個線程可以拷貝到不同的 CPU cache 中。

而聲明變量是 volatile 的,JVM 保證了每次讀變量都從內存中讀,跳過 CPU cache 這一步。

volatile 修飾的遍歷具有如下特性:

  1. 保證此變量對所有的線程的可見性,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內存(詳見:Java內存模型)來完成。
  2. 禁止指令重排序優化。
  3. 不會阻塞線程。

如果在 while 循環里加一行打印,即使去掉 volatile 修飾,也可以退出程序,查看 println() 源碼,最終發現,裏面有同步代碼塊,

synchronized (this) {
    ensureOpen();
    textOut.newLine();
    textOut.flushBuffer();
    charOut.flushBuffer();
    if (autoFlush)
        out.flush();
}

那麼問題來了,synchronized 到底幹了什麼。。
按理說,synchronized 只會保證該同步塊中的變量的可見性,發生變化後立即同步到主存,但是,flag 變量並不在同步塊中,實際上,JVM對於現代的機器做了最大程度的優化,也就是說,最大程度的保障了線程和主存之間的及時的同步,也就是相當於虛擬機儘可能的幫我們加了個volatile,但是,當CPU被一直佔用的時候,同步就會出現不及時,也就出現了後臺線程一直不結束的情況。

5.2 協程中的數據同步問題

看如下例子:

class Test {
    private var count = 0
    suspend fun test() = withContext(Dispatchers.IO) {
        repeat(100) {
            launch {
                repeat(1000) {
                    count++
                }
            }
        }
        launch {
            delay(3000)
            printWithThreadInfo("end count: $count")
        }
    }
}

fun main() = runBlocking<Unit> {
    Test().test()
}

執行輸出結果:

thread id: 15, thread name: DefaultDispatcher-worker-4 ---> end count: 58059

並不是我們期待的 100000。很明顯,協程併發過程中數據不同步造成的。

5.2.1 volatile 無效?

很顯然,有人肯定也想着,使用 volatile 修飾變量,就可以解決,真的是這樣嗎?其實不然。我們給 count 變量用 volatile 修飾也依然得不到期望的結果。
volatile 在併發中保證可見性,但是不保證原子性。 count++ 該運算,包含讀、寫操作,並非一次原子操作。這樣併發情況下,自然得不到期望的結果。

5.2.2 使用線程安全的數據結構

一種解決辦法是使用線程安全地數據結構。們可以使用具有 incrementAndGet 原子操作的 AtomicInteger 類:

class Test {
    private var count = AtomicInteger()
    suspend fun test() = withContext(Dispatchers.IO) {
        repeat(100) {
            launch {
                repeat(1000) {
                    count.incrementAndGet()
                }
            }
        }
        launch {
            delay(3000)
            printWithThreadInfo("end count: ${count.get()}")
        }
    }
}

fun main() = runBlocking<Unit> {
    Test().test()
}

輸出結果:

thread id: 35, thread name: DefaultDispatcher-worker-24 ---> end count: 100000

5.2.3 同步操作

對數據的增加進行同步操作。可以同步計數自增的代碼塊:

class Test {

    private val obj = Any()

    private var count = 0
    suspend fun test() = withContext(Dispatchers.IO) {
        repeat(100) {
            launch {
                repeat(1000) {
                    synchronized(obj) {  // 同步代碼塊
                        count++
                    }
                }
            }
        }
        launch {
            delay(3000)
            printWithThreadInfo("end count: $count")
        }
    }
}

或者使用 ReentrantLock 操作。

class Test {

    private val mLock = ReentrantLock()

    private var count = 0
    suspend fun test() = withContext(Dispatchers.IO) {
        repeat(100) {
            launch {
                repeat(1000) {
                    mLock.lock()
                    try{
                        count++
                    } finally {
                        mLock.unlock()
                    }
                }
            }
        }
        launch {
            delay(3000)
            printWithThreadInfo("end count: $count")
        }
    }
}

fun main() = runBlocking<Unit> {
    val cos = measureTimeMillis {
        Test().test()
    }
    printWithThreadInfo("cos time: ${cos.toString()}")
}

輸出結果爲:

thread id: 60, thread name: DefaultDispatcher-worker-49 ---> end count: 100000
thread id: 1, thread name: main ---> cos time: 3127

在協程中的替代品叫做 Mutex, 它具有 lock 和 unlock 方法,關鍵的區別在於, Mutex.lock() 是一個掛起函數,它不會阻塞當前線程。還有 withLock 擴展函數,可以方便的替代常用的 mutex.lock();try { …… } finally { mutex.unlock() } 模式:

class Test {

    private val mutex = Mutex()

    private var count = 0
    suspend fun test() = withContext(Dispatchers.IO) {
        repeat(100) {
            launch {
                repeat(1000) {
                    mutex.withLock {
                        count++
                    }
                }
            }
        }
        launch {
            delay(3000)
            printWithThreadInfo("end count: $count")
        }
    }
}

5.2.4 限制線程

在同一個線程中進行計數自增,就不會存在數據同步問題。每次進行自增操作時,切換到單一線程。如同 Android,UI 刷新必須切換到主線程一般。

class Test {

    private val countContext = newSingleThreadContext("CountContext")

    private var count = 0
    suspend fun test() = withContext(countContext) {
        repeat(100) {
            launch {
                repeat(1000) {
                    count++
                }
            }
        }
        launch {
            delay(3000)
            printWithThreadInfo("end count: $count")
        }
    }
}

5.2.5 使用 Actors

一個 actor 是由協程、 被限制並封裝到該協程中的狀態以及一個與其它協程通信的 通道 組合而成的一個實體。一個簡單的 actor 可以簡單的寫成一個函數, 但是一個擁有複雜狀態的 actor 更適合由類來表示。

有一個 actor 協程構建器,它可以方便地將 actor 的郵箱通道組合到其作用域中(用來接收消息)、組合發送 channel 與結果集對象,這樣對 actor 的單個引用就可以作爲其句柄持有。

使用 actor 的第一步是定義一個 actor 要處理的消息類。 Kotlin 的密封類很適合這種場景。 我們使用 IncCounter 消息(用來遞增計數器)和 GetCounter 消息(用來獲取值)來定義 CounterMsg 密封類。 後者需要發送回覆。CompletableDeferred 通信原語表示未來可知(可傳達)的單個值, 這裏被用於此目的。

// 計數器 Actor 的各種類型
sealed class CounterMsg
object IncCounter : CounterMsg() // 遞增計數器的單向消息
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() // 攜帶回復的請求

接下來定義一個函數,使用 actor 協程構建器來啓動一個 actor:

// 這個函數啓動一個新的計數器 actor
fun CoroutineScope.counterActor() = actor<CounterMsg> {
    var counter = 0 // actor 狀態
    for (msg in channel) { // 即將到來消息的迭代器
        when (msg) {
            is IncCounter -> counter++
            is GetCounter -> msg.response.complete(counter)
        }
    }
}

主要代碼:

class Test {

    suspend fun test() = withContext(Dispatchers.IO) {
        val counterActor = counterActor() // 創建該 actor
        repeat(100) {
            launch {
                repeat(1000) {
                    counterActor.send(IncCounter)
                }
            }
        }
        launch {
            delay(3000)
            // 發送一條消息以用來從一個 actor 中獲取計數值
            val response = CompletableDeferred<Int>()
            counterActor.send(GetCounter(response))
            println("Counter = ${response.await()}")
            counterActor.close() // 關閉該actor
        }
    }
}

actor 本身執行時所處上下文(就正確性而言)無關緊要。一個 actor 是一個協程,而一個協程是按順序執行的,因此將狀態限制到特定協程可以解決共享可變狀態的問題。實際上,actor 可以修改自己的私有狀態, 但只能通過消息互相影響(避免任何鎖定)。
actor 在高負載下比鎖更有效,因爲在這種情況下它總是有工作要做,而且根本不需要切換到不同的上下文。

實際上, CoroutineScope.actor()方法返回的是一個 SendChannel對象。Channel 也是 Kotlin 協程中的一部分。後面的文章會詳細介紹。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章