Kotlin Coroutine(協程): 三、瞭解協程

孟老闆發表於2021-07-14

@


前言

上一篇, 我們已經講述了協程的基本用法, 這篇將從協程上下文, 啟動模式, 異常處理角度來了解協程的用法

一、協程上下文

我們先看一下 啟動協程構建函式; launch, async等 它們引數都差不多

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {

第一個引數: CoroutineContext 就是協程上下文.
第二個引數: CoroutineStart 時協程的啟動模式, 我們後面再說
第三個引數: 就是協程的執行程式碼塊.


CoroutineContext: 是一個介面, 它可以包含 排程器, 攔截協程執行, 區域性變數等.
裡面有一個操作符過載函式:

public operator fun plus(context: CoroutineContext): CoroutineContext = ...省略...

所以,才能看到 兩個上下文元素相加; 例如: SupervisorJob() + Dispatchers.Main
沒錯, 這就是 MainScope() 定義的上下文;

//kotlin.coroutines.CoroutineContext
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

當然, 我們也可以看見 協程作用域 + 上下文

//kotlinx.coroutines.CoroutineScope
public operator fun CoroutineScope.plus(context: CoroutineContext): CoroutineScope =
    ContextScope(coroutineContext + context)

不管怎麼加, 反正都是合併協程上下文中的內容.

1.排程器

上一篇已經介紹過了, 我們再次貼這幾種排程器的區別:

排程器 意義
不指定 它從啟動了它的 CoroutineScope 中承襲了上下文
Dispatchers.Main 用於Android. 在UI執行緒中執行
Dispatchers.IO 子執行緒, 適合執行磁碟或網路 I/O操作
Dispatchers.Default 子執行緒,適合 執行 cpu 密集型的工作
Dispatchers.Unconfined 從當前執行緒直接執行, 直到第一個掛起點

2.給協程起名

還記得執行緒別名嗎? 沒錯 它們差不多; 它也是協程上下文元素

CoroutineName("name"):

launch(CoroutineName("v1coroutine")){...}

但要獲取附帶協程別名的執行緒名, 還得加JVM引數: -Dkotlinx.coroutines.debug

3.區域性變數

有時,能夠將一些執行緒區域性資料傳遞到協程與協程之間是很方便的。 它們不受任何特定執行緒的約束
使用 ThreadLocal 構建; 用 asContextElement(value = "launch") 轉換為協程上下文並賦值.

val threadLocal = ThreadLocal<String?>() // 宣告執行緒區域性變數
runBlocking {
    threadLocal.set("main")
    letUsPrintln("start!! 變數值為:'${threadLocal.get()}';;")
    launch(Dispatchers.Default + threadLocal.asContextElement(value = "launch")) {
        letUsPrintln("launch! 變數值為:'${threadLocal.get()}';;")
        delay(2000)
        launch{
            letUsPrintln("子協程! 變數值為:'${threadLocal.get()}';;")
        }
        letUsPrintln("launch! 變數值為:'${threadLocal.get()}';;")
    }
    launch {
        delay(1000)
        letUsPrintln("弟協程! 變數值為:'${threadLocal.get()}';;")
    }
    threadLocal.set(null)
    letUsPrintln("在末尾! 變數值為:'${threadLocal.get()}';;")
}

列印結果如下:

start!! 變數值為:'main';; Thread_name:main
launch! 變數值為:'launch';; Thread_name:DefaultDispatcher-worker-1
在末尾! 變數值為:'null';; Thread_name:main
弟協程! 變數值為:'null';; Thread_name:main
launch! 變數值為:'launch';; Thread_name:DefaultDispatcher-worker-1
子協程! 變數值為:'launch';; Thread_name:DefaultDispatcher-worker-1

注意:
當一個執行緒區域性變數變化時,這個新值不會傳播給協程呼叫者

當然還有:
攔截器(ContinuationInterceptor): 多用作執行緒切換, 有興趣的小夥伴自行百度.
異常處理器(CoroutineExceptionHandler): 這個後面再說


二、啟動模式 CoroutineStart

1.DEFAULT
預設模式, 立即執行; 雖說立即執行, 實際上是立即排程執行. 程式碼塊是否接著執行 還得看執行緒的空閒狀態啥的.

2.LAZY
延遲啟動, 我們可以先把協程定義好. 在需要的時候呼叫 start()

下面我們用 async 為例:

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

suspend fun doSomethingUsefulTwo(): Int {
    println("doSomethingUsefulTwo")
    delay(500L) // 假設我們在這裡也做了一些有用的事
    return 29
}

runBlocking {
    val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
    val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
    delay(2000)	//掛起一下, 看看 LAZY 協程是否被啟動
    println("終於要啟動了")
    one.start() // 啟動第一個
    two.start() // 啟動第二個
    println("The answer is ${one.await() + two.await()}")
}

列印結果:

終於要啟動了
doSomethingUsefulOne
doSomethingUsefulTwo
The answer is 42

可以看出, 即使 delay(2000); LAZY模式的協程, 仍沒有啟動. 呼叫 start() 後才會啟動.

需要注意:
start() 或 await() 雖然都可以讓 LAZY協程啟動, 但上面的例子中, 只呼叫 await()的話, 兩個async會變為順序執行, 損失非同步性質. 因此請使用 start() 來啟動 LAZY協程

3.ATOMIC
跟 DEFAULT 差不多, 區別在於 開始執行之前無法取消

如果不是 LAZY模式, 從協程定義 到程式碼塊執行還是很簡短的. 這段時間內的取消與否 只能說也許在特殊業務中它才會被使用.


4.UNDISPATCHED
當前執行緒立即執行協程體,直到第一個掛起點.

怎麼聽起來這麼耳熟呢? 沒錯 它跟 排程器:Dispatchers.Unconfined 效果類似. 實現方式是否一致不得而知.

三、異常處理

異常處理較為複雜, 注意點也比較多, 真正理解需要很多測試程式碼,或一定實戰經驗. 所以不能貫通理解也沒有關係,我們只需要對它有一定了解, 做到大體心中有數即可.

子協程:

我們先來了解一下子協程的定義:
當一個協程被其它協程在 CoroutineScope 中啟動的時候, 它將通過 CoroutineScope.coroutineContext 來承襲上下文,並且這個新協程的 Job 將會成為父協程作業的子作業。當一個父協程被取消的時候,所有它的子協程也會被遞迴的取消。
然而,當使用 GlobalScope 來啟動一個協程時,則新協程的作業沒有父作業。 因此它與這個啟動的作用域無關且獨立運作。一個父協程總是等待所有的子協程執行結束。父協程並不顯式的跟蹤所有子協程的啟動,並且不必使用 Job.join 在最後的時候等待它們

簡而言之:

  • 協程中啟動的協程, 就是子協程. GlobalScope 除外; 新協程的Job, 也是子Job
  • 父協程取消時(主動取消或異常取消), 遞迴取消所有子協程, 及子子協程
  • 父協程會等待子協程全部執行完畢才會結束

當一個協程由於異常而執行失敗時:

  1. 取消它自己的子級;
  2. 取消它自己;
  3. 將異常傳播並傳遞給它的父級。

異常會到達層級的根部,而且當前 CoroutineScope 所啟動的所有協程都會被取消。

1.異常測試

我們用幾個例子來檢測一下

runBlocking {
    launch {
        println("協程1-start")    //2
        delay(100)
        throw Exception("Failed coroutine") //4
    }
    launch {
        println("協程2-start")    //3
        delay(200)
        println("協程2-end")  //未列印
    }
    println("start")    //1
    delay(500)
    println("end")      //未列印
}

列印結果如下:

 start
 協程1-start
 協程2-start
 Exception in thread "main" java.lang.Exception: Failed coroutine ...

可以看出: 協程1異常. 協程2(兄弟協程)被取消. runBlocking(作用域)也被取消.


當 async 被用作根協程時,它的結果和異常會包裝在 返回值 Deferred.await() 中;

runBlocking {
    //async 依賴使用者來最終消費異常; 通過 await()
    val deferred = GlobalScope.async {
        letUsPrintln("協程1")
        throw Exception("Failed coroutine")
    }
    try {
        deferred.await()
    }catch (e: Exception){
        println("捕捉到了協程1異常")
    }
    letUsPrintln("end")
}

因此, try{..}catch {..} 需要包裹 await(); 而包裹 async{..} 是沒有意義的.


然而 try{..}catch{..} 並不一定合適;

runBlocking {
    try {
        launch {
            letUsPrintln("協程1")
            throw Exception("Failed coroutine")
        }
    }catch (e: Exception){
        println("捕捉到了協程1異常")	//未列印
    }
    delay(100)
    letUsPrintln("end")	//未列印
}

列印結果:

協程1 Thread_name:main
Exception in thread "main" java.lang.Exception: Failed coroutine ...

未能捕獲異常, runBlocking(父協程) 被終止; 我們嘗試用真實環境,包裹根協程:

try {
    lifecycleScope.launch {
        letUsPrintln("111協程1")
        throw Exception("Failed coroutine")
    }
}catch (e: Exception){
    println(e.message)
}

好吧, 程式直接 crash; 想想也對, 協程塊程式碼始終是要分發給執行緒去做. try catch 又不是包在程式碼塊裡面.

2.CoroutineExceptionHandler

異常處理器, 它是 CoroutineContext 的一個可選元素,它讓您可以處理未捕獲的異常。

我們先定義一個 handler

val handler = CoroutineExceptionHandler {
    context, exception -> println("Caught $exception")
}

然後:

runBlocking {
    val scope = CoroutineScope(Job())	//自定義一個作用域
    val job = scope.launch(handler) {
        letUsPrintln("one")
        throw Exception("Failed coroutine")
    }
    job.join()
    letUsPrintln("end")
}

列印結果如下:

one Thread_name:DefaultDispatcher-worker-1
Caught java.lang.Exception: Failed coroutine
end Thread_name:main

這裡新建作用域的目的, 是防止 launch 作為 runBlocking 的子協程; 我們去掉自定義作用域:

runBlocking {
    val job = launch(handler) {
        letUsPrintln("one")
        throw Exception("Failed coroutine")
    }
    job.join()
    letUsPrintln("end")	//未列印
}

列印結果如下:

one Thread_name:main
Exception in thread "main" java.lang.Exception: Failed coroutine ...

沒有捕獲異常, crash了. 這是為什麼呢?

可以向上取消的子協程(非supervisor) 會委託父協程處理它們的異常. 所以異常是交給父協程處理. 而CoroutineExceptionHandler只能處理未被處理的異常, 因此:

  • 把它加到 根協程 或作用域上. runBlocking,coroutineScope 中建立的協程不是根協程
  • 單向取消的子協程(例如: supervisorScope 下的一級子協程), 這樣寫: launch(handler), 可以捕獲異常
  • 其他情況, 子協程即便帶上Handler, 它也不生效

所以這樣可以捕獲異常:

lifecycleScope.launch(handler) {	//根協程 成功捕獲異常
    letUsPrintln("111協程1")
    throw Exception("Failed coroutine")
}

這樣無法捕獲異常:

lifecycleScope.launch {
    letUsPrintln("111協程1")
    launch(handler) {	//不能捕獲異常, 並引發 crash
        throw Exception("Failed coroutine")
    }
}

異常聚合:

當協程的多個子協程因異常而失敗時, 一般規則是“取第一個異常”,因此將處理第一個異常。 在第一個異常之後發生的所有其他異常都作為被抑制的異常繫結至第一個異常。

runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // 當另一個同級的協程因 IOException  失敗時,它將被取消
            } finally {
                throw ArithmeticException() // 第二個異常
            }
        }
        launch {
            delay(100)
            throw IOException() // 首個異常
        }
        delay(Long.MAX_VALUE)
    }
    job.join()
}

列印結果只有一句, 如下所示:

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

結論:

CoroutineExceptionHandler : 以下稱之為 Handler

  • async異常 依賴使用者呼叫 deferred.await(); 因此 Handler 在 async 這類協程構造器中無效;
  • 當子協程的取消可以向上傳遞時(非supervisor類), Handler 只能加到 根協程 或作用域上, 子協程即便帶上Handler, 它也不生效
  • CoroutineExceptionHandler 將等到所有子協程執行結束後再回撥, 在收尾工作完成後.
  • 它只是獲得異常資訊. 丟擲異常時, 協程將會遞迴終止, 並且無法通過 Handler 恢復.
  • Handler 並不能恢復異常, 如果想捕獲異常, 並使協程繼續執行, 則應當使用 try{..}catch{..}

如下所示, try{..}catch{..} 放到協程體內部, 捕獲最初的異常本體:

launch {
    try {
    	// do something
    	throw ArithmeticException()	// 假定這裡是可能拋異常的正常程式碼
        delay(Long.MAX_VALUE) // 當另一個同級的協程因 IOException  失敗時,它將被取消
    } catch (e: ArithmeticException){
        // do something
    }
}

四、監督:

我們知道, 當子協程異常時, 會連帶父協程取消,直至取消整個作用域. 有時我們並不想要這樣, 例如 UI 作用域被取消, 導致其他正常的UI操作不能執行. 因此我們需要讓異常只向後傳遞.

1.SupervisorJob

使用 SupervisorJob 時,子協程的執行失敗不會影響到其他子協程。也不會傳播異常給它的父級,它會讓子協程自己處理異常。

runBlocking {
    val supervisor = SupervisorJob() //取消單向傳遞的 job
    with(CoroutineScope(coroutineContext + supervisor)) {
        launch {	//兄弟協程
            delay(100)
            println("第一個協程執行完畢")
        }
        launch {	//第二個協程丟擲異常;
            throw AssertionError("The second child is cancelled")
        }
        delay(300)
        println("作用域被取消沒?")
    }
    println("全部執行完畢")
}

列印結果如下:

Exception in thread "main" java.lang.AssertionError: The first child is cancelled ...
第一個協程執行完畢
作用域被取消沒?
全部執行完畢

可以看出, 異常列印後. 兄弟協程 及 作用域都沒有被取消; 我們去掉 supervisor 再執行, 發現作用域協程被取消了. 可見是 SupervisorJob() 起了作用.

2.supervisorScope

對於作用域的併發,可以用 supervisorScope 來替代 coroutineScope 來實現相同的目的。它的直接子協程 將不會傳播異常給它的父級.

runBlocking {
    supervisorScope {
        launch {	//兄弟協程
            delay(100)
            println("第一個協程執行完畢")
        }
        launch {	//第二個協程丟擲異常;
            throw AssertionError("The second child is cancelled")
        }
        delay(300)
        println("作用域被取消沒?")
    }
    println("全部執行完畢")
}

列印結果跟使用 with(CoroutineScope(coroutineContext + supervisor)) 時完全一致;


越級子協程

子子協程會不會將異常向上傳遞呢?

runBlocking {
	val scope = CoroutineScope(SupervisorJob())
    scope.launch {	//兄弟協程
        delay(100)
        println("第一個協程執行完畢")
    }
    scope.launch {	//協程二
        launch {    //第二個協程 的子協程 丟擲異常;
            throw AssertionError("The second child is cancelled")
        }
        delay(200)
        println("第二個協程執行完畢?")	//未列印
    }
    delay(300)
    println("全部執行完畢")
}

列印結果如下:

Exception in thread "main" java.lang.AssertionError: The first child is cancelled ...
第一個協程執行完畢
全部執行完畢

可見, 第二個協程的完畢資訊 未列印; 協程二 被取消; 這是因為監督只能作用一層, 它的直接子協程不會向上傳遞取消. 但子協程的內部還是普通的雙向傳遞模式;

小結:

  • supervisorScope 會建立一個子作用域 (使用一個 SupervisorJob 作為父級); 以SupervisorJob 為父級的協程, 不會將取消操作向上級傳遞.
  • SupervisorJob 只有作為 supervisorScope 或 CoroutineScope(SupervisorJob()) 的一部分時,才會按照上面的描述工作。

SupervisorJob() 的使用,一定是配合作用域(CoroutineScope) 的建立; 但當它作為引數傳入一個協程的 Builder 時 會怎麼樣?:

runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> println("Caught $exception")}
    val jobBase = SupervisorJob()
    launch(jobBase) {	//與異常協程同一父 job;
        delay(50)
        println("協程1 執行完畢")
    }
    launch {	//新建 Job 承襲 父Job
        delay(60)
        println("協程2 執行完畢")
    }
    launch {	//新建 Job 承襲 父Job
        delay(70)
        println("協程3 執行完畢")
    }
    launch(jobBase+handler) {	//新建 Job 承襲 jobBase
        throw AssertionError("The first child is cancelled")
    }
    delay(100)
    println("全部執行完畢")
}

列印結果如下:

Caught java.lang.AssertionError: The first child is cancelled
協程1 執行完畢
協程2 執行完畢
協程3 執行完畢
全部執行完畢

這種方式, 實際上是替換了本該從父協程中承襲的Job;
可見 同父Job的 協程1 並沒有被取消; 我們換成 Job 試試; 只需要更換一句程式碼:

val jobBase = Job()

結果如下:

Caught java.lang.AssertionError: The first child is cancelled
協程2 執行完畢
協程3 執行完畢
全部執行完畢

可見 同父Job的 協程1 被取消; 協程2和協程3正常執行;

注意: 這種直接將Job傳入協程Builder 的方式, 會破壞原本協程繼承 Job的模式;

總結

CoroutineContext 協程上下文;

  • 排程器: 四種排程器, 可以指定協程的執行方式, 或執行執行緒
  • 還有協程別名, 區域性變數, 攔截器, 異常處理器等

CoroutineStart 啟動模式

  • 四種啟動模式, 延遲啟動等

異常處理:

  • CoroutineExceptionHandler: 處理未被處理的異常
  • 監督: 一般配合建立作用域 CoroutineScope(SupervisorJob()); 或使用 supervisorScope;

注意點:

  • 當一個協程由於異常而執行失敗時, 會取消所有子協程, 取消自己, 再傳播給父級, 直到取消整個作用域,
  • 異常處理器只能處理 未被處理的異常, 在雙向取消的子協程中不起作用. 在 async 類協程中不起作用
  • 監督: 會在作用域內 使用一個SupervisorJob作為父級. 只能生效一層. 因為子協程會新建自己的Job, 子子協程繼承的是 Job, 而不是 SupervisorJob
  • 當 async 不是根協程時, 異常仍然會通過 Job 向上傳遞, 導致作用域取消, crash等; runBlocking, coroutineScope 的程式碼塊中建立的協程, 並不是根協程

相關文章