Kotlin協程作用域(4)

CoroutineScope:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

這裏先把這個方法的註釋文檔放過來:

定義新協程的範圍。每個協程構建器都是CoroutineScope的擴展,並繼承其coroutineContext以自動傳播上下文元素和取消。

獲取範圍的獨立實例的最佳方法是CoroutineScope()和MainScope()工廠函數。可以使用plus運算符將其他上下文元素附加到作用域。

建議不要手動實現此接口,應優先考慮通過委派實現。按照慣例,作用域的上下文應包含作業實例以強制執行結構化併發。

每個協同程序構建器(如launch,async等)和每個作用域函數(如coroutineScope,withContext等)都會將自己的作用域實例提供給它運行的內部代碼塊。按照慣例,它們都會等待塊內的所有協同程序在完成自己之前完成,從而強制執行結構化併發規則。

CoroutineScope應該在具有明確定義的生命週期的實體上實現(或用作字段),這些實體負責啓動子協同程序。

詳情點這裏 

0,CoroutineScope

CoroutineScope是必須的麼?其實不是的。當協程還是實驗性質的時候Kotlin 1.1時,我們啓動協程是可以這樣寫的:

fun requestSomeData() {
    launch {
        updateUI(performRequest())
    }
}

這裏我們在UI上下文中啓動一個新的協同程序launch(UI),調用掛起函數performRequest對後端進行異步調用而不阻塞主UI線程,然後用結果更新UI。每個requestSomeData調用創建自己的協程,它很好,不是嗎?

但這是一個問題。如果網絡或後端出現問題,這些異步操作可能需要很長時間才能完成。而且,這些操作通常在某些UI元素(如窗口或頁面)的範圍內執行。如果操作需要很長時間才能完成,則典型用戶會關閉相應的UI元素並執行其他操作,或者更糟糕的是,重新打開此UI並一次又一次地嘗試操作。但是我們之前的操作仍然在後臺運行,當用戶關閉相應的UI元素時,我們需要一些機制來取消它。

一個簡單的launch { … }易於編寫,但它不是你應該寫的。

協同程序始終與應用程序中的某些本地作用域相關,這是一個生命週期有限的實體,如UI元素。因此,對於結構化併發,我們現在要求在CoroutineScope中調用啓動,CoroutineScope是由您的終身受限對象(如UI元素或其對應的視圖模型)實現的接口。

對於更新UI操作CoroutineScope提供專門的實現,在這裏可以看到

對於那些需要全局協程,其生命週期受應用程序生命週期限制的極少數情況,我們現在提供GlobalScope對象,因此之前爲全局協程啓動launch{...},現在變爲GlobalScope.launch {...},這個協同程序的全局特性在代碼中變得明確。GlobalScope在之前的幾章中經常用到的。

emmm............加入CoroutineScope就只是解決了這個異步操作的問題麼?

再看下面示例:

suspend fun loadAndCombine(name1: String, name2: String): Image { 
    val deferred1 = async { loadImage(name1) }
    val deferred2 = async { loadImage(name2) }
    return combineImages(deferred1.await(), deferred2.await())
}

這個例子看起來不錯,這個suspend函數最終會在某個協程內部調用,異步下載2張圖片然後合併成一張,但是還是有很多微妙的錯誤,如果這個協程取消怎麼辦?然後加載兩個圖片的異步任務仍然沒有受到影響,這不是一個可靠的代碼。

那在父協程取消的時候把子協程都取消不就可以了,改成這樣async(coroutineContext) { … }。

它仍然還是有問題,比如下載第一張圖片失敗了,則deferred1.await()拋出了相應的異常,但是加載第二張圖片的協程仍然在後臺工作,解決這個問題就更加複雜了。

一個簡單async { … }易於編寫,但它不是你應該寫的。

使用結構化併發async協同程序構建器CoroutineScope就像是一樣成爲擴展launch。你不能簡單地寫async { … },你必須提供範圍。一個適當的並行分解的例子變成:

suspend fun loadAndCombine(name1: String, name2: String): Image =
    coroutineScope { 
        val deferred1 = async { loadImage(name1) }
        val deferred2 = async { loadImage(name2) }
        combineImages(deferred1.await(), deferred2.await())
    }

你必須將代碼包裝到coroutineScope { … }塊中,以建立操作的邊界及其範圍。所有async協同程序都成爲此範圍的子代,如果範圍因異常而失敗或被取消,則所有子代也將被取消。

協程的團隊在引入了結構化併發(Structured concurrency)之後,他們就改變了協程構建器功能launch()和async()從頂級更改爲使用CoroutineScope接收器的擴展。

1,coroutineScope

爲了更加理解coroutineScope,看下下面示例:

  @Test
    fun main() {
        runBlocking {
            try {
                coroutineScope {
                    launch { // “1”
                        println("a")
                    }
                    launch {// “2”
                        println("b")
                        launch {// “3”
                            delay(1000)
                            println("c")
                            throw ArithmeticException("Hey!!")
                        }
                    }
                    val job = launch {// “4”
                        println("d")
                        delay(2000)
                        println("e")
                    }
                    job.join()
                    println("f")
                }

            } catch (e: Exception) {
                println("g")
            }
            println("h")
        }
    }

輸出結果:

a
b
d
c
g
h

會發現e,f沒有輸出。

原因:coroutineScope 是繼承外部 Job 的上下文創建作用域,在其內部的取消操作是雙向傳播的,子協程未捕獲的異常也會向上傳遞給父協程。它更適合一系列對等的協程併發的完成一項工作,任何一個子協程異常退出,那麼整體都將退出,簡單來說就是”一損俱損“。這也是協程內部再啓動子協程的默認作用域。

coroutineSocpe啓動了3個協程,“2”協程又啓動了子協程“3”,子協程“3”因爲拋出異常取消了。因爲coroutineSocpe異常時雙向的所以“3”會通知其父協程“2”取消,2會根據其作用域通知coroutineSocpe取消,這是一個自下而上的過程,coroutineSocpe取消會通知“4”取消,這是一個自上而下的過程。

其中join()和delay()是支持取消的,所以這兩處就被取消了e,f就沒有被打出來了。

這裏有一個小細節我們可以對coroutineSocpe內部協程中的異常直接try...catch...捕獲掉表明協程把異步的異常處理到同步代碼邏輯當中。

2,supervisorScope

再說一個和coroutineSocpe類似的supervisorScope:

  @Test
    fun main() {
        runBlocking {
            try {
                supervisorScope{
                    launch { // “1”
                        println("a")
                    }
                    launch {// “2”
                        println("b")
                        launch {// “3”
                            delay(1000)
                            println("c")
                            throw ArithmeticException("Hey!!")
                        }
                    }
                    val job = launch {// “4”
                        println("d")
                        delay(2000)
                        println("e")
                    }
                    job.join()
                    println("f")
                }

            } catch (e: Exception) {
                println("g")
            }
            println("h")
        }
    }

輸出:

a
b
d
c
Exception in thread "main @coroutine#5" java.lang.ArithmeticException: Hey!!
	...
e
f
h

會發現e沒有輸出。

原因:supervisorScope 同樣繼承外部作用域的上下文,但其內部的取消操作是單向傳播的,父協程向子協程傳播,反過來則不然,這意味着子協程出了異常並不會影響父協程以及其他兄弟協程。它更適合一些獨立不相干的任務,任何一個任務出問題,並不會影響其他任務的工作,簡單來說就是”自作自受“,例如 UI,我點擊一個按鈕出了異常,其實並不會影響手機狀態欄的刷新。需要注意的是,supervisorScope 內部啓動的子協程內部再啓動子協程,如無明確指出,則遵守默認作用域規則,也即 supervisorScope 只作用域其直接子協程。

supervisorScope啓動了3個協程,“2”協程又啓動了子協程“3”,子協程“3”因爲拋出異常取消了。但是因爲supervisorScope的取消操作是單向的即父協程向子協程傳播的,所以“3”協程並不會影響“2”協程?

  @Test
    fun main() {
        val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
            println("${coroutineContext[CoroutineName]} $throwable")
        }
        runBlocking {
            try {
                supervisorScope {
                    launch {
                        // "1"
                        println("a")
                    }
                    launch(exceptionHandler + CoroutineName("\"2\"")) {
                        // "2"
                        println("b")
                        launch(exceptionHandler + CoroutineName("\"3\"")) {
                            //"3"
                            launch (exceptionHandler + CoroutineName("\"5\"")){// "5"
                                delay(1000)
                                println("c-")
                            }
                            println("c")
                            throw ArithmeticException("Hey!!")
                        }
                    }
                    val job = launch {
                        //"4"
                        println("d")
                        delay(2000)
                        println("e")
                    }
                    job.join()
                    println("f")
                }

            } catch (e: Exception) {
                println("g")
            }
            println("h")
        }
    }

仔細看下輸出:

a
b
d
c
CoroutineName("2") java.lang.ArithmeticException: Hey!!
e
f
h

異常竟然是協程“2”打出來的而且c-和g沒有打出來。

其實並不意外supervisorScope 內部啓動的子協程內部再啓動子協程,如無明確指出,則遵守默認作用域規則,也即 supervisorScope 只作用域其直接子協程。默認作用域規則就是coroutineScope,子協程未捕獲的異常也會向上傳遞給父協程。

3,GlobeScope 

看一個示例:

  fun work(i: Int) {
        Thread.sleep(1000)
        println("Work $i done")
    }

    @Test
    fun main() {
        val time = measureTimeMillis {
            runBlocking {
                for (i in 1..2) {
                    launch {
                        work(i)
                    }
                }
            }
        }
        println("Done in $time ms")
    }

輸出的結果:

Work 1 done
Work 2 done
Done in 2095 ms

它打印Work 1 done和Work 2 done,但它需要兩秒鐘才能完成。併發在哪裏?launch已經繼承了從引進範圍協程調度runBlocking協同程序生成器,該組合限制住執行到單個線程,所以這兩個任務在主線程中執行順序。

要併發換成這樣就行了:

   launch(Dispatchers.Default) {
                        work(i)
                    }

這樣就能在1s中完成了。

如果我換成GlobalScope啓動協同程序會發生什麼?它應該是相同的,因爲它在後臺線程Dispatchers.Default中執行協程。

@Test
    fun main() {
        val time = measureTimeMillis {
            runBlocking {
                for (i in 1..2) {
                   GlobalScope.launch {
                        work(i)
                    }
                }
            }
        }
        println("Done in $time ms")
    }

輸出結果:

Done in 97 ms

並沒有打印Work x done,直接打印了Done in 97 ms。爲什麼?

原因:通過 GlobeScope 啓動的協程單獨啓動一個協程作用域,內部的子協程遵從默認的作用域規則。通過 GlobeScope 啓動的協程“自成一派”。

GlobeScope.launch{...}和launch(Dispatchers.Default){...}的區別就出來了。啓動(Dispatchers.Default)在runBlocking範圍內創建子協程,因此runBlocking會自動等待它們的完成。但是,GlobalScope.launch創建了全局協程。

我們可以通過以下手段控制來達到和launch(Dispatchers.Default){...}同樣的效果:

   @Test
    fun main() {
        val time = measureTimeMillis {
            runBlocking {
                val jobs = mutableListOf<Job>()
                for (i in 1..2) {
                    jobs += GlobalScope.launch {
                        work(i)
                    }
                }
                jobs.forEach { it.join() }
            }
        }
        println("Done in $time ms")
    }

現在輸出:

Work 1 done
Work 2 done
Done in 1102 ms

現在這個例子與GlobalScope代碼的工作方式類似launch(Dispatchers.Default),但需要付出更多努力,爲什麼還要編寫更多代碼?幾乎沒有理由GlobalScope在基於Kotlin協同程序的應用程序中使用。

對於上面的操作還可以這樣:

  suspend fun work(i: Int) = withContext(Dispatchers.Default) {
        Thread.sleep(1000)
        println("Work $i done")
    }

tips:

  • 對於沒有協程作用域,但需要啓動協程的時候,適合用 GlobalScope

  • 對於已經有協程作用域的情況(例如通過 GlobalScope 啓動的協程體內),直接用協程啓動器啓動

  • 對於明確要求子協程之間相互獨立不干擾時,使用 supervisorScope

  • 對於通過標準庫 API 創建的協程,這樣的協程比較底層,沒有 Job、作用域等概念的支撐,例如我們前面提到過 suspend main 就是這種情況,對於這種情況優先考慮通過 coroutineScope 創建作用域;更進一步,大家儘量不要直接使用標準庫 API,除非你對 Kotlin 的協程機制非常熟悉。

4.launch 

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    // ...
): Job

它被定義爲CoroutineScope上的擴展函數,並將CoroutineContext作爲參數,因此它實際上需要兩個協程上下文(因爲範圍只是對上下文的引用)。它與它們有什麼關係?它使用plus運算符合並它們,生成其元素的集合,以便context參數中的元素優先於作用域中的元素。生成的上下文用於啓動新的協程,但它不是新協程的上下文而是新協程的父上下文。新的協程創建自己的子Job實例(使用此上下文中的job作爲其父)並將其子上下文定義爲父上下文plus其job:

 圖片來自於:Coroutine Context and Scope

a,按照慣例,CoroutineScope中的上下文包含一個Job,它將成爲新的coroutine的父級(GlobalScope除外,你應該避免)。 

b,啓動時的CoroutineContext參數是提供額外的上下文元素來覆蓋否則將從父作用域繼承的元素。

c,按照慣例,我們通常不會在上下文參數中傳遞Job來啓動,因爲這會破壞父子關係,除非我們明確想要使用NonCancellable作業來打破它。

d,按照慣例,所有協程構建器作用域的coroutineContext屬性與在此block內運行的協同程序的上下文相同。

 @Test
    fun main() = runBlocking<Unit> {
        launch { scopeCheck(this) }
    }

    suspend fun scopeCheck(scope: CoroutineScope) {
        println(scope.coroutineContext === coroutineContext)
    }

輸出爲:true

e,由於上下文和範圍在本質上是相同的,我們可以在沒有訪問範圍的情況下啓動協程,而不使用GlobalScope只需將當前coroutineContext包裝到CoroutineScope的實例中,如以下函數所示:

suspend fun doNotDoThis() {
    CoroutineScope(coroutineContext).launch {
        println("I'm confused")
    }
}

不要這樣做!它使協程的啓動範圍變得不透明和隱含,捕獲一些外部Job來啓動一個新的協程,而不在函數簽名中明確地宣佈它。協程是與您的其餘代碼同時進行的一項工作,其啓動必須是明確的.

 

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