kotlin-協程的異常處理

在 Kotlin 協程當中,我們通常把異常分為兩大類,一類是取消異常(CancellationException),另一類是其他異常。之所以要這么分類,是因為在 Kotlin 協程當中,這兩種異常的處理方式是不一樣的?;蛘哒f,在 Kotlin 協程所有的異常當中,我們需要把CancellationException 單獨拎出來,特殊對待。

當協程任務被取消的時候,協程內部是會產生一個 CancellationException 。很多初學者都會遇到一個問題,那就是協程無法被取消。帶著這個問題進入下面的內容。

一、協程的取消需要內部配合

先看看下面的例子

fun main() {
    runBlocking {
        printMsg("start")
        val job = launch(Dispatchers.IO) {
            var i = 0
            while (true) {
                Thread.sleep(500L)
                i++
                printMsg("i = $i")
            }
        }
        delay(2000L)
        job.cancel()   <------2秒后在協程作用域內取消協程
        job.join()
        printMsg("end")
    }
}

//日志
main @coroutine#1 start
DefaultDispatcher-worker-1 @coroutine#2 i = 1
DefaultDispatcher-worker-1 @coroutine#2 i = 2
DefaultDispatcher-worker-1 @coroutine#2 i = 3
//停不下來了
......

為什么2秒后協程沒有退出呢?這是因為協程是協作式的,我們在作用域內調用了cancel方法,協程需要自己檢查取消狀態(tài),并在適當的時機主動做出響應。取消狀態(tài)可以通過協程的 isActive 屬性進行檢查。但是在我們的代碼中,由于是無限循環(huán),協程沒有時機主動檢查取消狀態(tài),因此協程無法感知到取消請求并退出。

改造上面的代碼

fun main() {
    runBlocking {
        printMsg("start")
        val job = launch(Dispatchers.IO) {
            var i = 0
            while (isActive) {      <----------方式一:主動提供檢查的時機
                Thread.sleep(500L)
                //delay(500L)    <----------方式二:掛起函數,在掛起點檢查自己的狀態(tài)
                i++
                printMsg("i = $i")
            }
        }
        delay(2000L)
        job.cancel()
        job.join()
        printMsg("end")
    }
}

//日志


提供了二種方式:

  • 方式一:通過isActive主動發(fā)起狀態(tài)檢查。

  • 方式二:將sleep改為掛起函數delay,因為掛起函數在掛起或恢復的時候肯定會檢查協程的狀態(tài)(比如協程已經被cancel肯定不會再從掛起恢復了)。

綜上,協程代碼如果無法被cancel,請檢查協程是否有檢查狀態(tài)的時機。

二、不要打破協程的父子結構

看下面的例子

var startTime: Long = 0
fun main() {
    runBlocking {
        startTime = System.currentTimeMillis()
        printMsg("start")
        var childJob1: Job? = null
        var childJob2: Job? = null
        val parentJob = launch(Dispatchers.IO) {
            childJob1 = launch {        <------子協程使用父協程的上下文
                printMsg("childJob1 start")
                delay(600L)          <------子協程掛起600毫秒后執(zhí)行完
                printMsg("childJob1 end")
            }

            childJob2 = launch(Job()) {       <------子協程使用自己的上下文
                printMsg("childJob2 start")
                delay(600L)          <------子協程掛起600毫秒后執(zhí)行完
                printMsg("childJob2 end")
            }
        }

        delay(400L)
        parentJob.cancel()    <---------400毫秒后取消父協程
        printMsg("childJob1.isActive=${childJob1?.isActive}")   <-----程序執(zhí)行完時打印子協程1的狀態(tài)
        printMsg("childJob2.isActive=${childJob2?.isActive}")   <-----程序執(zhí)行完時打印子協程2的狀態(tài)
        printMsg("end")       <-----程序執(zhí)行完時
    }
}

fun printMsg(msg: Any) {
    println("打印內容:$msg 消耗時間:${System.currentTimeMillis() - startTime} 線程信息:${Thread.currentThread().name} ")
}

//日志
打印內容:start 消耗時間:0 線程信息:main @coroutine#1 
打印內容:childJob1 start 消耗時間:15 線程信息:DefaultDispatcher-worker-3 @coroutine#3 
打印內容:childJob2 start 消耗時間:22 線程信息:DefaultDispatcher-worker-2 @coroutine#4 
打印內容:childJob1.isActive=false 消耗時間:430 線程信息:main @coroutine#1 
打印內容:childJob2.isActive=true 消耗時間:430 線程信息:main @coroutine#1    <------程序執(zhí)行完時,子協程2并沒有退出,isActive=true
打印內容:end 消耗時間:430 線程信息:main @coroutine#1 
Process finished with exit code 0

代碼并不難,注釋也很詳細??梢钥吹阶訁f程2使用自己的上下文后脫離了父協程的控制,當父協程被cancel后,子協程2并沒有被cancel,isActive狀態(tài)仍然是true

所以,不要打破協程的父子結構!

三、不要用 try-catch 直接包裹 launch、async

看下面的例子

fun main() {
    runBlocking {
        printMsg("start")
        try {
            printMsg("try start")
            launch {
                printMsg("launch start")
                delay(200L)
                1 / 0          <------------200毫秒后創(chuàng)建一個異常
                printMsg("launch end")
            }
            printMsg("try end")
        } catch (exception: Exception) {
            printMsg("catch $exception")
        }
        printMsg("end")
    }
}

//日志
main @coroutine#1 start
main @coroutine#1 try start
main @coroutine#1 try end
main @coroutine#1 end
main @coroutine#2 launch start
Exception in thread "main" java.lang.ArithmeticException: / by zero    <-----報錯程序崩潰

雖然try-catch包裹了協程的內容,但是程序還是報錯,這是因為子協程與父協程是并發(fā)執(zhí)行的,它們之間是獨立的執(zhí)行流程,所以上面代碼中父協程的 try-catch 無法捕獲子協程拋出的異常。

try-catch修改上面的代碼

fun main() {
    runBlocking {
        printMsg("start")
        launch {
            printMsg("launch start")
            try {
                printMsg("try start")
                delay(200L)
                1 / 0
                printMsg("try end")
            } catch (exception: Exception) {
                printMsg("catch $exception")
            }
            printMsg("launch end")
        }
        printMsg("end")
    }
}

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 launch start
main @coroutine#2 try start
main @coroutine#2 catch java.lang.ArithmeticException: / by zero    <------異常被成功捕獲
main @coroutine#2 launch end
Process finished with exit code 0

如果使用async創(chuàng)建協程,try-catch是應該包裹async內的代碼塊還是應該包裹deferred.await()? 寫段代碼看看

fun main() {
    runBlocking {
        printMsg("start")
        val deferred = async() {
            printMsg("async start")
            delay(200L)
            1 / 0
            printMsg("async end")
        }

        try {
            deferred.await()
        } catch (exception: Exception) {
            printMsg("catch $exception")
        }

        printMsg("end")
    }
}

//日志
main @coroutine#1 start
main @coroutine#2 async start
main @coroutine#1 catch java.lang.ArithmeticException: / by zero     <------捕獲到了異常
main @coroutine#1 end
Exception in thread "main" java.lang.ArithmeticException: / by zero   <-----報錯程序崩潰

雖然捕獲到了異常,但是程序還是報錯了,所以try-catch一般還是包裹具體的代碼塊吧。

四、使用SupervisorJob

上面的一段代碼try-catchdeferred.await()仍然報錯,有沒有辦法補救這段代碼呢?答案是有,可以使用SupervisorJob()。代碼如下:

fun main() {
    runBlocking {
        printMsg("start")
        val deferred = async(SupervisorJob()) {      <-------變化在這里
            printMsg("async start")
            delay(200L)
            1 / 0
            printMsg("async end")
        }

        try {
            deferred.await()
        } catch (exception: Exception) {
            printMsg("catch $exception")
        }

        printMsg("end")
    }
}

//日志
main @coroutine#1 start
main @coroutine#2 async start
main @coroutine#1 catch java.lang.ArithmeticException: / by zero
main @coroutine#1 end
Process finished with exit code 0

為什么加了SupervisorJob()就不報錯了? 看下SupervisorJob()的源碼:

@Suppress("FunctionName")
public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)

public interface CompletableJob : Job {
    
    public fun complete(): Boolean

    public fun completeExceptionally(exception: Throwable): Boolean
}

SupervisorJob() 其實不是構造函數,它只是一個普通的頂層函數。而這個方法返回的對象,是 Job 的子類。默認的 Job 類型會將異常傳播給父協程,如果一個子協程拋出異常,它會取消父協程及其所有兄弟協程。

通過使用 SupervisorJob,我們可以創(chuàng)建一個具有獨立異常處理行為的作業(yè)層級。這意味著即使子協程中發(fā)生異常,父協程仍然可以繼續(xù)執(zhí)行而不會被取消,從而避免整個程序崩潰。

SupervisorJob()可以作為 CoroutineScope 的上下文,但是它的監(jiān)管范圍并不是無限大的,看下面的例子:

fun main() {
    runBlocking {
        val supervisorJob = SupervisorJob()    
        val scope = CoroutineScope(coroutineContext + supervisorJob)    <-----作用域內使用SupervisorJob()
        val job = scope.launch {              <----注意這里,作用域內啟動子協程
            launch {                   <----注意這里,作用域內啟動孫協程
                printMsg("job1 start")
                delay(200L)
                throw  ArithmeticException("by zero")
            }
            launch {
                printMsg("job2 start")
                delay(300L)
                printMsg("job2 end")      <----關注這個日志
            }
        }
        job.join()
        scope.cancel()
    }
}

//日志
main @coroutine#3 job1 start
main @coroutine#4 job2 start
Exception in thread "main @coroutine#4" java.lang.ArithmeticException: by zero
Process finished with exit code 0

上面的日志中并沒有輸出job2 end,說明上面job1的異常影響了下面協程job2的執(zhí)行,那如何修改呢?

fun main() {
    runBlocking {
        val supervisorJob = SupervisorJob()
        val scope = CoroutineScope(coroutineContext + supervisorJob)
        scope.apply {               <----------變化在這里,launch改為apply
            val job1 = launch {
                printMsg("job1 start")
                delay(200L)
                throw  ArithmeticException("by zero")
            }
            val job2 = launch {
                printMsg("job2 start")
                delay(300L)
                printMsg("job2 end")
            }
            job1.join()       <----------變化在這里
            job2.join()
        }
        scope.cancel()
    }
}

//日志
main @coroutine#2 job1 start
main @coroutine#3 job2 start
Exception in thread "main @coroutine#2" java.lang.ArithmeticException: by zero
main @coroutine#3 job2 end        <--------成功輸出: job2 end
Process finished with exit code 0

可以看到當將 SupervisorJob 作為 CoroutineScope 的上下文時,它的監(jiān)管范圍僅限于該作用域內部啟動的子協程。

SupervisorJob的源碼中是因為重寫了childCancelled方法并直接返回false,保證異常不會向父協程和其他子協程傳遞:

private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

事實上kotlin有提供給我們含SupervisorJob上下文的協程作用域,它就是supervisorScope,源碼如下:


/**
 * Creates a [CoroutineScope] with [SupervisorJob] and calls the specified suspend block with this scope.
 * The provided scope inherits its [coroutineContext][CoroutineScope.coroutineContext] from the outer scope, but overrides
 * context's [Job] with [SupervisorJob].
 * This function returns as soon as the given block and all its child coroutines are completed.
 *
 * Unlike [coroutineScope], a failure of a child does not cause this scope to fail and does not affect its other children,
 * so a custom policy for handling failures of its children can be implemented. See [SupervisorJob] for additional details.
 * A failure of the scope itself (exception thrown in the [block] or external cancellation) fails the scope with all its children,
 * but does not cancel parent job.
 *
 * The method may throw a [CancellationException] if the current job was cancelled externally,
 * or rethrow an exception thrown by the given [block].
 */
public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = SupervisorCoroutine(uCont.context, uCont)      <-------SupervisorCoroutine
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

private class SupervisorCoroutine<in T>(
    context: CoroutineContext,
    uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
    override fun childCancelled(cause: Throwable): Boolean = false    <-------同樣重寫了childCancelled方法返回false
}

我們使用supervisorScope改造上面的代碼:

fun main() {
    runBlocking {
        supervisorScope {
            val job1 = launch {
                printMsg("job1 start")
                delay(200L)
                throw  ArithmeticException("by zero")
            }
            val job2 = launch {
                printMsg("job2 start")
                delay(300L)
                printMsg("job2 end")
            }
            job1.join()
            job2.join()
        }
    }
}

//日志
main @coroutine#2 job1 start
main @coroutine#3 job2 start
Exception in thread "main @coroutine#2" java.lang.ArithmeticException: by zero
main @coroutine#3 job2 end        <--------成功輸出: job2 end
Process finished with exit code 0

五、CoroutineExceptionHandler

有時候由于協程嵌套的層級很深,并且也不需要每一個協程去處理異常,這時候CoroutineExceptionHandler就可以派上用場了,如下:

fun main() {
    runBlocking {
        val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
            printMsg("CoroutineExceptionHandler $throwable")
        }
        val scope = CoroutineScope(coroutineExceptionHandler)
        val job = scope.launch {
            launch {
                printMsg("job1 start")
                delay(200L)
                throw  ArithmeticException("by zero")
            }
            launch {
                printMsg("job2 start")
                delay(300L)
                printMsg("job2 end")
            }
        }
        job.join()
        scope.cancel()
    }
}

//日志
DefaultDispatcher-worker-2 @coroutine#3 job1 start
DefaultDispatcher-worker-3 @coroutine#4 job2 start
DefaultDispatcher-worker-3 @coroutine#4 CoroutineExceptionHandler java.lang.ArithmeticException: by zero
Process finished with exit code 0

CoroutineExceptionHandler中成功輸出了異常的日志。試試把CoroutineExceptionHandler放在子協程報錯的地方有什么樣的結果?

fun main() {
    runBlocking {
        val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
            printMsg("CoroutineExceptionHandler $throwable")
        }
        val scope = CoroutineScope(coroutineContext)       <--------變化在這里
        val job = scope.launch {
            launch(coroutineExceptionHandler) {       <--------變化在這里
                printMsg("job1 start")
                delay(200L)
                throw  ArithmeticException("by zero")
            }
            launch {
                printMsg("job2 start")
                delay(300L)
                printMsg("job2 end")
            }
        }
        job.join()
        scope.cancel()
    }
}

//日志
main @coroutine#3 job1 start
main @coroutine#4 job2 start
Exception in thread "main" java.lang.ArithmeticException: by zero      <-------程序報錯
Process finished with exit code 1

程序報錯,且coroutineExceptionHandler并沒有捕獲到異常,說明coroutineExceptionHandler并沒有起到作用,原因是CoroutineExceptionHandler 只在頂層的協程當中才會起作用,當子協程當中出現異常以后,它們都會統一上報給頂層的父協程,然后由頂層的父協程去調用 CoroutineExceptionHandler來處理異常

看上面的日志都沒有輸出job2 end,說明job1的異常影響到了job2的執(zhí)行,那如果既想用coroutineExceptionHandler兜底異常,又不想協程間因為異?;ハ嘤绊懺趺崔k呢? 我們可以試試這樣寫:

fun main() {
    runBlocking {
        val supervisorJob = SupervisorJob()        <----------使用SupervisorJob()
        val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
            printMsg("CoroutineExceptionHandler $throwable")
        }
        val scope = CoroutineScope(coroutineExceptionHandler + supervisorJob)    <-------加入到作用域的上下文
        scope.apply {
            val job1 = launch {
                printMsg("job1 start")
                delay(100L)
                throw  NullPointerException("parameters is null")     <-----子協程的異常
            }

            val job2 = launch {
                printMsg("job2 start")
                delay(200L)
                launch {                 <-----孫協程
                    try {
                        1 / 0            <-----孫協程的異常
                    } catch (exception: ArithmeticException) { 
                        throw  ArithmeticException("by zero")     <------記得拋出來,不拋出來也沒有的
                    }
                }
            }

            val job3 = launch {
                printMsg("job3 start")
                delay(300L)
                printMsg("job3 end")
            }

            job1.join()
            job2.join()
            job3.join()
        }
        scope.cancel()
    }
}

//日志
DefaultDispatcher-worker-1 @coroutine#2 job1 start
DefaultDispatcher-worker-2 @coroutine#3 job2 start
DefaultDispatcher-worker-3 @coroutine#4 job3 start
DefaultDispatcher-worker-2 @coroutine#2 CoroutineExceptionHandler java.lang.NullPointerException: parameters is null
DefaultDispatcher-worker-3 @coroutine#5 CoroutineExceptionHandler java.lang.ArithmeticException: by zero
DefaultDispatcher-worker-3 @coroutine#4 job3 end
Process finished with exit code 0

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容