Kotlin 協程一 —— Coroutine

SharpCJ發表於2022-01-15

一、協程的一些前置知識

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 協程中的一部分。後面的文章會詳細介紹。

相關文章