一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(八)初識(shí)協(xié)程異常

1. 引言

如果學(xué)習(xí)使用了協(xié)程的取消和結(jié)構(gòu)化并發(fā)部分的內(nèi)容,那么協(xié)程的異常將是不得不說(shuō)的內(nèi)容。

2. 協(xié)程的取消異常

協(xié)程的取消篇當(dāng)中,涉及過(guò)的ensureActive,withContext這兩個(gè)函數(shù)可以作為取消的協(xié)作點(diǎn),其實(shí)這兩個(gè)函數(shù)的本質(zhì)是,在協(xié)程調(diào)被取消后再觸及這兩個(gè)函數(shù)調(diào)用之時(shí),會(huì)拋出異常CancellationException,沒(méi)錯(cuò),協(xié)程取消的協(xié)作點(diǎn),便是以異常CancellationException的拋出來(lái)作為中斷點(diǎn)的!

應(yīng)該是常識(shí)的內(nèi)容:異常拋出以后的代碼邏輯將不會(huì)獲得執(zhí)行,直到異常被捕獲;如果異常在應(yīng)用內(nèi)沒(méi)有被捕獲,那么最終將引起應(yīng)用的閃退。

現(xiàn)在不妨再回頭想想,在協(xié)程取消后,取消協(xié)作點(diǎn)以后的邏輯都不再執(zhí)行,其實(shí)便是因?yàn)閰f(xié)作點(diǎn)拋出了異常所以終止了執(zhí)行邏輯,而用協(xié)程的地方并沒(méi)有因?yàn)楫惓6罎?,說(shuō)明協(xié)程內(nèi)部已經(jīng)對(duì)異常進(jìn)行了捕獲處理。

取消與異常緊密相關(guān)。協(xié)程內(nèi)部使用 CancellationException 來(lái)進(jìn)行取消,這個(gè)異常會(huì)被所有的處理者忽略,所以那些可以被 catch 代碼塊捕獲的異常僅僅應(yīng)該被用來(lái)作為額外調(diào)試信息的資源。

上面的內(nèi)容,引用自Kotlin中文文檔的”取消與異?!?/strong>部分:https://www.kotlincn.net/docs/reference/coroutines/exception-handling.html

從這里可以得到一個(gè)信息,在協(xié)程執(zhí)行的過(guò)程中,任意地方拋出CancellationException異常是安全,所謂的協(xié)程取消協(xié)作點(diǎn),便是以該異常的拋出作為約定!

那么除去這個(gè)異常以外的其他異常呢?至少,在Android平臺(tái)上的實(shí)踐中,除去CancellationException以及其子異常,其他所有異常在協(xié)程中拋出,沒(méi)有進(jìn)行捕獲或進(jìn)行相關(guān)處理,最終會(huì)拋出到應(yīng)用層面造成閃退。

3. 協(xié)程中的異常處理

有些情況下,開(kāi)發(fā)代碼所調(diào)用的API當(dāng)中,不可避免地可能會(huì)遇到一些異常的拋出,在Java中會(huì)強(qiáng)制我們捕獲所有非運(yùn)行時(shí)異常(RuntimException)的異常,而在Kotlin當(dāng)中,則沒(méi)有在編譯時(shí)強(qiáng)制捕獲非運(yùn)行時(shí)異常的限制,比如,前面一直使用的Thread#sleep()函數(shù),如果在Java中調(diào)用,要么在函數(shù)聲明異常的拋出,要么用try-catch包裹并捕獲InterruptedException才能通過(guò)編譯;但在Kotlin中可以隨意調(diào)用,萬(wàn)一出現(xiàn)異常便是崩潰。

這里主要是引出Kotlin對(duì)于非運(yùn)行時(shí)異常編譯時(shí)的處理差異,用作調(diào)用API時(shí)某些場(chǎng)景下會(huì)拋出異常的示例,至于兩種語(yǔ)言對(duì)于異常的詳細(xì)設(shè)計(jì),這里不作展開(kāi)。

為更好地說(shuō)明,這里設(shè)計(jì)一個(gè)函數(shù),這個(gè)函數(shù)在某些場(chǎng)景下可能拋出非法狀態(tài)異常(IllegalStateException):

private fun someAPIMayThrowException(scope: CoroutineScope) {
    val randomTime = randomMilli
    Thread.sleep(randomTime)
    scope.ensureActive()
    if (randomTime < TEN_SECONDS) {
        throw IllegalStateException("Throw Exception by code")
    }
}

這里在產(chǎn)生的隨機(jī)休眠時(shí)間,如果小于10秒,則會(huì)拋出異常IllegalStateException,由于實(shí)踐代碼中,隨機(jī)時(shí)間的產(chǎn)生范圍是5000毫秒到10000毫秒,所以這個(gè)異常的拋出是絕大概率的(99.98%),這個(gè)異常拋出的概率大小可以由實(shí)踐代碼隨意的控制,此處的概率僅為更方便地實(shí)踐。

在通過(guò)launch啟動(dòng)一個(gè)協(xié)程調(diào)用這個(gè)方法:

private fun launchThrowExceptionClicked() {
    "launchThrowExceptionClicked".let {
        myLog(it)
    }
    scope.launch(Dispatchers.IO) {
        "Coroutine IO runs (launchThrowExceptionClicked)".let {
            myLog(it)
        }
        someAPIMayThrowException(this)
        "Coroutine IO runs after thread sleep (launchThrowExceptionClicked)".let {
            myLog(it)
        }
    }
}

然后,便會(huì)喜獲異常并引起閃退:

java.lang.IllegalStateException: Throw Exception by code
        at pers.teacchen.coroutineusagedemo.activity.CoroutineExceptionActivity.someAPIMayThrowException(CoroutineExceptionActivity.kt:131)
        at pers.teacchen.coroutineusagedemo.activity.CoroutineExceptionActivity.access$someAPIMayThrowException(CoroutineExceptionActivity.kt:11)
        at pers.teacchen.coroutineusagedemo.activity.CoroutineExceptionActivity$launchThrowExceptionClicked$2.invokeSuspend(CoroutineExceptionActivity.kt:50)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

閃退是app開(kāi)發(fā)中最不能忍受的內(nèi)容,所以,既然知道這里可能會(huì)觸發(fā)異常,那么,像Java中一樣,對(duì)可能拋出的異常的地方進(jìn)行捕獲處理(為方便對(duì)比,將新增異常捕獲部分的代碼封裝為新的方法):

private fun launchWithTryCatchClicked() {
    "launchWithTryCatchClicked".let {
        myLog(it)
    }
    scope.launch(Dispatchers.IO) {
        "Coroutine IO runs (launchWithTryCatchClicked)".let {
            myLog(it)
        }
        try {
            someAPIMayThrowException(this)
        } catch (e: IllegalStateException) {
            myLog("catch IllegalStateException:$e")
        }
        "Coroutine IO runs after thread sleep (launchWithTryCatchClicked)".let {
            myLog(it)
        }
    }
}

與前面閃退代碼中,核心邏輯的差別便是對(duì)someAPIMayThrowException(this)進(jìn)行了try-catch包裹,且僅捕獲IllegalStateException異常。這樣的話,便不再有崩潰問(wèn)題,最終所有的日志都能正常輸入:

D/chenhj: launchWithTryCatchClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: catch IllegalStateException:java.lang.IllegalStateException: Throw Exception by code ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: Coroutine IO runs after thread sleep (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]

可以看到,異常還是有拋出,不過(guò)被catch部分處理了。

4. 協(xié)程中try-catch的問(wèn)題

上面補(bǔ)充了try-catch后,似乎已經(jīng)解決了問(wèn)題,但是事實(shí)上,上面實(shí)踐代碼的try-catch的方式,會(huì)帶來(lái)新的問(wèn)題——協(xié)程的取消不再起作用了,也就是說(shuō),在線程休眠喚醒之前,對(duì)協(xié)程進(jìn)行了取消,協(xié)程后面的log輸出仍會(huì)執(zhí)行。執(zhí)行l(wèi)og如下:

D/chenhj: launchWithTryCatchClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: cancelBtnClicked ::running in Thread:[id:2][name:main]
D/chenhj: catch IllegalStateException:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@7afd9ac ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: Coroutine IO runs after thread sleep (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]

這里可以看到,在捕獲IllegalStateException的時(shí)候,把協(xié)程取消異常也捕獲到了,進(jìn)而地,將協(xié)程取消異常處理的內(nèi)部機(jī)制不察覺(jué)間給“破壞”了。

關(guān)于取消異常,有以下繼承關(guān)系:

Throwable - Exception - RuntimeException - IllegalStateException - CancellationException - JobCancellationException

所以,在捕獲IllegalStateException異常的時(shí)候,也會(huì)捕獲到協(xié)程取消異常,當(dāng)異常被捕獲以后,便不會(huì)往外傳遞,所以協(xié)程取消的功能便失效了。

當(dāng)然,根據(jù)上面異常的繼承關(guān)系,想要捕獲目標(biāo)異常又不想破壞協(xié)程的取消功能,根據(jù)try-catch的特性來(lái)解決也很簡(jiǎn)單,那就是在捕獲部分捕獲到取消異常的時(shí)候繼續(xù)往外拋出:

try {
    someAPIMayThrowException(this)
} catch (e: CancellationException) {
    throw e
} catch (e: IllegalStateException) {
    myLog("catch IllegalStateException:$e")
}

這時(shí)候,不妨再回頭看看文檔中的陳述:

取消與異常緊密相關(guān)。協(xié)程內(nèi)部使用 CancellationException 來(lái)進(jìn)行取消,這個(gè)異常會(huì)被所有的處理者忽略,所以那些可以被 catch 代碼塊捕獲的異常僅僅應(yīng)該被用來(lái)作為額外調(diào)試信息的資源。

看完上面的try-catch使用的實(shí)踐代碼后,文檔的最后半句,”僅僅應(yīng)該被用來(lái)“這幾個(gè)字的表述不妨結(jié)合這小節(jié)的實(shí)踐代碼再品品?

5. 協(xié)程異常處理者

誠(chéng)然,調(diào)用可能拋出異常的API時(shí),如果不希望app在拋出異常時(shí)閃退(哪家會(huì)容忍閃退的發(fā)生呢?),異常還是要捕獲的,但是由于try-catch的設(shè)計(jì)和協(xié)程取消功能的設(shè)計(jì),這兩個(gè)一不小心就容易互相干擾,即使很細(xì)心處理了這種異常的層級(jí)關(guān)系,每處可能拋出異常的地方都要寫try-catch,是不是比較麻煩?所以,這時(shí)候,便是引出協(xié)程中一個(gè)很實(shí)用的設(shè)計(jì)——

CoroutineExceptionHandler,協(xié)程異常處理者

為解決上面的API調(diào)用異常捕獲的問(wèn)題,現(xiàn)在用協(xié)程異常處理者來(lái)處理:

private fun launchWithExceptionHandlerClicked() {
    "launchWithExceptionHandlerClicked".let {
        myLog(it)
    }
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        myLog("exceptionHandler throwable:$throwable")
    }

    scope.launch(Dispatchers.IO + exceptionHandler) {
        "Coroutine IO runs (launchWithExceptionHandlerClicked)".let {
            myLog(it)
        }
        someAPIMayThrowException(this)
        "Coroutine IO runs after thread sleep (launchWithExceptionHandlerClicked)".let {
            myLog(it)
        }
    }
}

這樣的話,正常執(zhí)行(沒(méi)有提前取消協(xié)程)時(shí),輸出如下:

D/chenhj: launchWithExceptionHandlerClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithExceptionHandlerClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: exceptionHandler throwable:java.lang.IllegalStateException: Throw Exception by code ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]

這時(shí)候應(yīng)用不會(huì)再發(fā)生閃退,異常拋出時(shí)會(huì)走到了CoroutineExceptionHandler的lambda表達(dá)式函數(shù)體部分。

同時(shí),在someAPIMayThrowException(this)后的代碼也將不再執(zhí)行。

那么,再來(lái)看看協(xié)程取消時(shí)的表現(xiàn):

D/chenhj: launchWithExceptionHandlerClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithExceptionHandlerClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: cancelBtnClicked ::running in Thread:[id:2][name:main]

協(xié)程取消的時(shí)候,由于拋出的異常是CancellationException(更準(zhǔn)確地說(shuō)是其子類JobCancellationException),所以并不會(huì)走到CoroutineExceptionHandler的lambda表達(dá)式函數(shù)體部分。

所以,這里可以看到,異常處理者的作用,僅回調(diào)非取消異常的其他異常,這時(shí)候便可以方便的對(duì)非協(xié)程取消產(chǎn)生的異常進(jìn)行處理了!

6. 從實(shí)用角度看待協(xié)程異常處理者

如同實(shí)踐代碼,協(xié)協(xié)程異常處理者,可以很方便地捕獲到非取消異常的其他異常,避免異常傳遞造成應(yīng)用的閃退,也不會(huì)對(duì)協(xié)程實(shí)用異常實(shí)現(xiàn)的取消功能進(jìn)行了干擾。

協(xié)程異常處理者非常實(shí)用,比如在處理異常的時(shí)候,協(xié)程取消導(dǎo)致的異常是用來(lái)取消協(xié)程剩下執(zhí)行邏輯的,應(yīng)該視為正常的處理邏輯,但是其他異常的時(shí)候,最好還是需要提示或打印相關(guān)信息進(jìn)行排查!

再細(xì)化一點(diǎn),網(wǎng)絡(luò)請(qǐng)求和數(shù)據(jù)返回的過(guò)程中,出現(xiàn)異常的情況可能非常多但概率都很小(接口返回異常,IO異常等),這時(shí)候希望異常出現(xiàn)時(shí)進(jìn)行捕獲并輸出信息(不處理導(dǎo)致的閃退一般是不可接受的)。

如果不用協(xié)程,那么try-catch捕獲Exception或者Throwable便是夠用了。

如果用協(xié)程,try-catch的代碼容易對(duì)協(xié)程本身的取消作用造成破壞,即使處理了異常層級(jí),但是每次可能發(fā)生異常的地方都寫try-catch,造成代碼層級(jí)以及邏輯的復(fù)雜程度加深,一定層度上也不優(yōu)雅。

所以協(xié)程異常處理者便是個(gè)很實(shí)用的內(nèi)容了,不管在協(xié)程執(zhí)行的任意邏輯當(dāng)中,只要出現(xiàn)了異常,都有一個(gè)統(tǒng)一的異?;卣{(diào),而寫法也不會(huì)對(duì)協(xié)程本身的執(zhí)行邏輯進(jìn)行任何干擾(不理解?不妨回頭看看異常處理者是怎么傳入的?對(duì)比下異常處理者與try-catch對(duì)應(yīng)代碼邏輯寫法的影響?)

現(xiàn)在,再來(lái)看看文檔中的這句話

取消與異常緊密相關(guān)。協(xié)程內(nèi)部使用 CancellationException 來(lái)進(jìn)行取消,這個(gè)異常會(huì)被所有的處理者忽略,所以那些可以被 catch 代碼塊捕獲的異常僅僅應(yīng)該被用來(lái)作為額外調(diào)試信息的資源。

這段短短的描述話語(yǔ)中的,“這個(gè)異常會(huì)被所有的處理者忽略”,處理者說(shuō)的便是CoroutineExceptionHandler了,異常會(huì)被忽略?再回頭看下地5節(jié)的實(shí)踐代碼和各種情況的執(zhí)行結(jié)果,再回頭品品這個(gè)“忽略”?

再者,CoroutineExceptionHandler不是萬(wàn)能的,因?yàn)閷惓5牟东@處理邏輯與實(shí)際拋出異常的地方完全分開(kāi),如果不僅想捕獲異常還想根據(jù)異常在不同執(zhí)行位置的拋出作不同的細(xì)節(jié)處理,還是得寫try-catch。

按菜下飯,按需使用!

7. 樣例工程代碼

代碼樣例Demo,見(jiàn)Github:https://github.com/TeaCChen/CoroutineStudy

本文示例代碼,如覺(jué)奇怪或啰嗦,其實(shí)為CoroutineExceptionActivity.kt中的代碼摘取主要部分說(shuō)明,在demo代碼當(dāng)中,為提升細(xì)節(jié)內(nèi)容,有更加多的封裝和輸出內(nèi)容。

本文的頁(yè)面截圖示例如下:

image-8-1.png

8. 補(bǔ)充說(shuō)明

協(xié)程異常部分的內(nèi)容遠(yuǎn)不止本文探討的內(nèi)容,千萬(wàn)千萬(wàn)不用認(rèn)為使用了協(xié)程異常處理者處理異常便是萬(wàn)事大吉,事實(shí)上協(xié)程結(jié)構(gòu)化并發(fā)當(dāng)中,協(xié)程異常處理者部分會(huì)跟協(xié)程上下文傳遞以及協(xié)程結(jié)構(gòu)化后的異常傳遞相關(guān),具體點(diǎn)便是子協(xié)程拋出的異常用協(xié)程異常處理者去處理,仍可能會(huì)傳遞到父協(xié)程當(dāng)中取消父協(xié)程,然后父協(xié)程的取消將導(dǎo)致所有子協(xié)程的取消。

所以,當(dāng)前內(nèi)容,僅僅是初識(shí)協(xié)程異常,看到這部分內(nèi)容的時(shí)候強(qiáng)烈建議接著看下部分的“異常與supervisor“。

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(一)協(xié)程啟動(dòng)

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(二)線程切換

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(三)初遇協(xié)程取消

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(四)協(xié)程作用域

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(五)再遇協(xié)程取消

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(六)初識(shí)掛起

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(七)初識(shí)結(jié)構(gòu)化

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(八)初識(shí)協(xié)程異常(本文)

一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(九)異常與supervisor

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容