[譯] 關於 Kotlin Coroutines, 你可能會犯的 7 個錯誤


原文作者:Lukas Lechner[1]

原文地址:7 common mistakes you might be making when using Kotlin Coroutines[2]

譯者:秉心說

在我看來,Kotlin Coroutines(協程) 大大簡化了同步和異步代碼。但是,我發現了許多開發者在使用協程時會犯一些通用性的錯誤。

1. 在使用協程時實例化一個新的 Job 實例

有時候你會需要一個 job 來對協程進行一些操作,例如,稍後取消。另外由於協程構建器 launch{}async{} 都需要 job 作爲入參,你可能會想到創建一個新的 job 實例作爲參數來使用。這樣的話,你就擁有了一個 job 引用,稍後你可以調用它的 .cancel() 方法。

fun main() = runBlocking {

    val coroutineJob = Job()
    launch(coroutineJob) {
        println("performing some work in Coroutine")
        delay(100)
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine was cancelled")
        }
    }

    // cancel job while Coroutine performs work
    delay(50)
    coroutineJob.cancel()
}

這段代碼看起來沒有任何問題,協程被成功取消了。

>_ 

performing some work in Coroutine
Coroutine was cancelled

Process finished with exit code 0

但是,讓我們試試在協程作用域 CoroutineScope 中運行這個協程,然後取消協程作用域而不是協程的 job

fun main() = runBlocking {

    val scopeJob = Job()
    val scope = CoroutineScope(scopeJob)

    val coroutineJob = Job()
    scope.launch(coroutineJob) {
        println("performing some work in Coroutine")
        delay(100)
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine was cancelled")
        }
    }

    // cancel scope while Coroutine performs work
    delay(50)
    scope.cancel()
}

當作用域被取消時,它內部的所有協程都會被取消。但是當我們再次執行修改過的代碼時,情況並不是這樣。

>_

performing some work in Coroutine

Process finished with exit code 0

現在,協程沒有被取消,Coroutine was cancelled 沒有被打印。

爲什麼會這樣?

原來,爲了讓異步/同步代碼更加安全,協程提供了革命性的特性 —— “結構化併發” 。“結構化併發” 的一個機制就是:當作用域被取消時,就取消該作用域中的所有協程。爲了保證這一機制正常工作,作用域的 job 和協程的 job 之前的層級結構如下圖所示:


在我們的例子中,發生了一些異常情況。通過向協程構建器 launch() 傳遞我們自己的 job 實例,實際上並沒有把新的 job 實例和協程本身進行綁定,取而代之的是,它成爲了新協程的父 job。所以你創建的新協程的父 job 並不是協程作用域的 job,而是新創建的 job 對象。

因此,協程的 job 和協程作用域的 job 此時並沒有什麼關聯。


我們打破了結構化併發,因此當我們取消協程作用域時,協程將不再被取消。

解決方式是直接使用 launch() 返回的 job

fun main() = runBlocking {
    val scopeJob = Job()
    val scope = CoroutineScope(scopeJob)

    val coroutineJob = scope.launch {
        println("performing some work in Coroutine")
        delay(100)
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine was cancelled")
        }
    }

    // cancel while coroutine performs work
    delay(50)
    scope.cancel()
}

這樣,協程就可以隨着作用域的取消而取消了。

>_

performing some work in Coroutine
Coroutine was cancelled

Process finished with exit code 0

2. 錯誤的使用 SupervisorJob

有時候你會使用 SupervisorJob 來達到下面的效果:

  1. 在 job 繼承體系中停止異常向上傳播
  2. 當一個協程失敗時不影響其他的同級協程

由於協程構建器 launch{}async{} 都可以傳遞 Job 作爲入參,所以你可以考慮向構建器傳遞 SupervisorJob 實例。

launch(SupervisorJob()){
    // Coroutine Body
}

但是,就像錯誤 1 ,這樣會打破結構化併發的取消機制。正確的解決方式是使用 supervisorScope{} 作用域函數。

supervisorScope {
    launch {
        // Coroutine Body
    }
}

3. 不支持取消

當你在自己定義的 suspend 函數中進行一些比較重的操作時,例如計算斐波拉契數列:

// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * n
suspend fun calculateFactorialOf(number: Int): BigInteger =
    withContext(Dispatchers.Default) {
        var factorial = BigInteger.ONE
        for (i in 1..number) {
            factorial = factorial.multiply(BigInteger.valueOf(i.toLong()))
        }
        factorial
    }

這個掛起函數有一個問題:它不支持 “合作式取消” 。這意味着即使執行這個函數的協程被提前取消了,它仍然會繼續運行直到計算完成。爲了避免這種情況,可以定期執行以下函數:

  • ensureActive() [3]
  • isActive() [4]
  • yield() [5]

下面的代碼使用了 ensureActive()[6] 來支持取消。

// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * n
suspend fun calculateFactorialOf(number: Int): BigInteger =
    withContext(Dispatchers.Default) {
        var factorial = BigInteger.ONE
        for (i in 1..number) {
            ensureActive()
            factorial = factorial.multiply(BigInteger.valueOf(i.toLong()))
        }
        factorial
    }

Kotlin 標準庫中的掛起函數(如 delay()) 都是可以配合取消的。但是對於你自己的掛起函數,不要忘記考慮取消的情況。

4. 進行網絡請求或者數據庫查詢時切換調度器

這一項並不真的是一個 “錯誤” ,但是仍可能讓你的代碼難以理解,甚至更加低效。一些開發者認爲當調用協程時,就應該切換到後臺調度器,例如,進行網絡請求的 Retrofit 的 suspend 函數,進行數據庫操作的 Room 的 suspend 函數。

這並不是必須的。因爲所有的掛起函數都應該是主線程安全的,Retrofit 和 Room 都遵循了這一約定。你可以閱讀我的 這篇文章[7] 以瞭解更多內容。

5. 嘗試使用 try/catch 來處理協程的異常

協程的異常處理很複雜,我花了相當多的時間才完全理解,並通過 博客[8]講座[9] 向其他開發者進行了解釋。我還作了一些 [10] 來總結這個複雜的話題。

關於 Kotlin 協程異常處理最不直觀的方面之一是,你不能使用 try-catch 來捕獲異常。

fun main() = runBlocking<Unit> {
    try {
        launch {
            throw Exception()
        }
    } catch (exception: Exception) {
        println("Handled $exception")
    }
}

如果運行上面的代碼,異常不會被處理並且應用會 crash 。

>_ 

Exception in thread "main" java.lang.Exception

Process finished with exit code 1

Kotlin Coroutines  讓我們可以用傳統的編碼方式書寫異步代碼。但是,在異常處理方面,並沒有如大多數開發者想的那樣使用傳統的 try-catch 機制。如果你想處理異常,在協程內直接使用 try-catch 或者使用 CoroutineExceptionHandler

更多信息可以閱讀前面提到的這篇 文章[11]

6. 在子協程中使用 CoroutineExceptionHandler

再來一條簡明扼要的:在子協程的構建器中使用 CoroutineExceptionHandler 不會有任何效果。這是因爲異常處理是代理給父協程的。因爲,你必須在根或者父協程或者 CoroutineScope 中使用 CoroutineExceptionHandler  。

同樣,更多細節請閱讀 這裏[12]

7. 捕獲 CancellationExceptions

當協程被取消,正在執行的掛起函數會拋出 CancellationException 。這通常會導致協程發生 "異常" 並且立即停止運行。如下面代碼所示:

fun main() = runBlocking {

    val job = launch {
        println("Performing network request in Coroutine")
        delay(1000)
        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

500 ms 之後,掛起函數 delay() 拋出了 CancellationException ,協程 "異常結束" 並且停止運行。

>_

Performing network request in Coroutine

Process finished with exit code 0

現在讓我們假設 delay() 代表一個網絡請求,爲了處理網絡請求可能發生的異常,我們用 try-catch 代碼塊來捕獲所有異常。

fun main() = runBlocking {

    val job = launch {
        try {
            println("Performing network request in Coroutine")
            delay(1000)
        } catch (e: Exception) {
            println("Handled exception in Coroutine")
        }

        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

現在,假設服務端發生了 bug 。catch 分支不僅會捕獲錯誤網絡請求的 HttpException ,對於 CancellationExceptions 也是。因此協程不會 “異常停止”,而是繼續運行。

>_

Performing network request in Coroutine
Handled exception in Coroutine
Coroutine still running ... 

Process finished with exit code 0

這可能導致設備資源浪費,甚至在某些情況下導致崩潰。

要解決這個問題,我們可以只捕獲 HttpException

fun main() = runBlocking {

    val job = launch {
        try {
            println("Performing network request in Coroutine")
            delay(1000)
        } catch (e: HttpException) {
            println("Handled exception in Coroutine")
        }

        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

或者再次拋出 CancellationExceptions

fun main() = runBlocking {

    val job = launch {
        try {
            println("Performing network request in Coroutine")
            delay(1000)
        } catch (e: Exception) {
            if (e is CancellationException) {
                throw e
            }
            println("Handled exception in Coroutine")
        }

        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

以上就是使用 Kotlin Coroutines 最常見的 7 個錯誤。如果你瞭解其他常見錯誤,歡迎在評論區留言!

另外,不要忘記向其他開發者分享這篇文章以免發生這樣的錯誤。Thanks !

Thank you for reading, and have a great day!

參考資料

[1]

Lukas Lechner: https://www.lukaslechner.com/

[2]

7 common mistakes you might be making when using Kotlin Coroutines: https://www.lukaslechner.com/7-common-mistakes-you-might-be-making-when-using-kotlin-coroutines/

[3]

ensureActive(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html

[4]

isActive(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/is-active.html

[5]

yield(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/yield.html

[6]

ensureActive(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html

[7]

這篇文章: https://www.lukaslechner.com/do-i-need-to-call-suspend-functions-of-retrofit-and-room-on-a-background-thread/

[8]

博客: https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/

[9]

講座: https://www.droidcon.com/media-detail?video=481189746

[10]

圖: https://www.lukaslechner.com/coroutines-exception-handling-cheat-sheet/

[11]

文章: https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/

[12]

這裏: https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/


本文分享自微信公衆號 - 秉心說TM(gh_c6504b1af5ae)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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