kotlin協程[8]:再說作用域

CoroutineScope:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

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

獲取范圍的獨立實例的最佳方法是CoroutineScope()和MainScope()工廠函數??梢允褂胮lus運算符將其他上下文元素附加到作用域。

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

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

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

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/index.html

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接收器的擴展。

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...捕獲掉表明協程把異步的異常處理到同步代碼邏輯當中。

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

會發現g沒有輸出

原因: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,子協程未捕獲的異常也會向上傳遞給父協程。

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 的協程機制非常熟悉。

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來啟動一個新的協程,而不在函數簽名中明確地宣布它。協程是與您的其余代碼同時進行的一項工作,其啟動必須是明確的.

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,428評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,024評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,285評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,548評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,328評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,878評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,971評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,098評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,616評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,554評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,725評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,243評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,971評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,361評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,613評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,339評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,695評論 2 370

推薦閱讀更多精彩內容