[譯] Kotlin 協程高級使用技巧

學習一些障礙以及如何繞過它們

協程從 1.3 開始成爲穩定版!前端

開始 Kotlin 協程很是簡單:只需將一些耗時操做放在 launch 中便可,你作到了,對不?固然,這是針對簡單的狀況。但很快,併發與並行的複雜性會慢慢堆積起來。android

當你深刻研究協程時,如下是一些你須要知道的事情。ios

取消 + 阻塞操做 = 😈

沒有辦法繞過它:在某些時候,你不得不用原生 Java 流。這裏的問題(不少狀況下 😉)是使用流將會堵塞當前線程。這在協程中是一個壞消息。如今,若是你想要取消一個協程,在可以繼續執行以前,你不得不等待讀寫操做完成。git

做爲一個簡單可重複的例子,讓咱們打開 ServerSocket 而且等待 1 秒的超時鏈接:github

runBlocking(Dispatchers.IO) {
    withTimeout(1000) {
        val socket = ServerSocket(42)

         // 咱們將卡在這裏直到有人接收該鏈接。難道你不想知道爲何嗎?😜
        socket.accept()
    }
}
複製代碼

應該能夠運行,對嗎?不。後端

如今你的感覺有點像:😖。 那麼咱們如何解決呢?bash

Closeable APIs 構建良好時,它們支持從任何線程關閉流並適當地失敗。併發

注意:一般狀況下,JDK 中的 APIs 遵循了這些最佳實踐,但需注意第三方 Closeable APIs 可能並無遵循。 你被提醒過了。app

幸好 suspendCancellableCoroutine 函數,當一個協程被取消時咱們能夠關閉任何流:socket

public suspend inline fun <T : Closeable?, R> T.useCancellably(
        crossinline block: (T) -> R
): R = suspendCancellableCoroutine { cont ->
    cont.invokeOnCancellation { this?.close() }
    cont.resume(use(block))
}
複製代碼

確保這適用於你正在使用的 API !

如今阻塞的 accept 調用被 useCancellably 包裹,該協程會在超時觸發的時候失敗。

runBlocking(Dispatchers.IO) {
    withTimeout(1000) {
        val socket = ServerSocket(42)

        // 拋出 `SocketException: socket closed` 異常。好極了!
        socket.useCancellably { it.accept() }
    }
}
複製代碼

成功!

若是你不支持取消怎麼辦?如下是你須要注意的事項:

  • 若是你使用協程封裝類中的任何屬性或方法,即便取消了協程也會存在泄漏。若是你認爲你正在 onDestroy 中清理資源,這尤爲重要。解決方法: 將協同程序移動到 ViewModel 或其餘上下文無關的類中並訂閱它的處理結果。
  • 確保使用 Dispatchers.IO 來處理阻塞操做,由於這可讓 Kotlin 留出一些線程來進行無限等待。
  • 儘量使用 suspendCancellableCoroutine 替換 suspendCoroutine

launch vs. async

因爲上面關於這兩個特性的回答已通過時,我想我會再次分析它們的差別。

launch 異常冒泡

當一個協程崩潰時,它的父節點將被取消,從而取消全部父節點的子節點。一旦整個樹節點中的協程完成取消操做,異常將會發送到當前上線文的異常處理程序。在 Android 中,這意味着 你的 程序將會 崩潰,而無論你使用什麼來進行調度。

async 持有本身的異常

這意味着 await() 顯式處理全部異常,安裝 CoroutineExceptionHandler 將無任何效果。

launch 「blocks」 父做用域

雖然該函數會當即返回,但其父做用域將 不會 結束,直到使用 launch 構建的全部協程以某種方式完成。所以若是你只是想等待全部協程完成,在父做用域末尾調用全部子做業的 join() 就沒有必要了。

與你指望的可能不一樣,即便未調用 await(),外部做用域仍將等待async協程完成。

async 返回值

這一部分至關簡單:若是你須要協程的返回值,async 是惟一的選擇。若是你不須要返回值,使用 launch 來建立反作用。而且在繼續執行以前須要完成這些反作用才須要使用 join()

join() vs. await()

join()await()不會 從新拋出異常。但若是發生錯誤,join() 會取消你的協程,這意味着在 join() 掛起後調用任何代碼都不會起做用。

記錄異常

如今你瞭解了你所使用不一樣構造器異常處理機制的差別,你會陷入兩難境地:你想記錄異常而不崩潰(因此咱們不能使用 launch),可是你不想手動調用 try/catch (因此咱們不能使用 async)。因此這讓咱們無所適從?謝天謝地。

記錄異常是 CoroutineExceptionHandler 派上用場的地方。但首先,讓咱們花點時間瞭解在協程中拋出異常時究竟發生了什麼:

  1. 捕獲異常,而後經過 Continuation 恢復。
  2. 若是你的代碼沒有處理異常而且該異常不是 CancellationException,那麼將經過當前的 CoroutineContext 請求第一個 CoroutineExceptionHandler
  3. 若是未找處處理程序或處理程序有錯誤,那麼異常將發送到平臺中的特定代碼。
  4. 在 JVM 上,ServiceLoader 用於定位全局處理程序。
  5. 一旦調用了全部處理程序或有一個處理程序出現錯誤,就會調用當前線程的異常處理程序。
  6. 若是當前線程沒有處理該異常,它會冒泡到線程組並最終到達默認異常處理程序。
  7. 崩潰!

考慮到這一點,咱們有如下幾個選擇:

  • 爲每一個線程安裝一個處理程序,但這是不現實的。
  • 安裝默認處理程序,但主線程中的錯誤不會讓你的應用崩潰,而且你將處於潛在的不良狀態。
  • 將處理程序添加爲服務 當使用 launch 的任何協程崩潰時都會調用它(hacky)。
  • 使用你本身的自定義域與附加的處理程序來替換 GlobalScope,或將處理程序添加到你使用的每一個做用域,但這很煩人並使日誌記錄由默認變成了可選。

最後一個方案是所推薦的,由於它具備靈活性而且須要最少的代碼和技巧。

對於應用程序範圍內的做業,你將使用帶有日誌記錄處理程序的 AppScope。對於其餘業務,你能夠在日誌記錄崩潰的適當位置添加處理程序。

val LoggingExceptionHandler = CoroutineExceptionHandler { _, t ->
    Crashlytics.logException(t)
}
val AppScope = GlobalScope + LoggingExceptionHandler
複製代碼
class ViewModelBase : ViewModel(), CoroutineScope {
    override val coroutineContext = Job() + LoggingExceptionHandler

    override fun onCleared() = coroutineContext.cancel()
}
複製代碼

不是很糟糕

最後的思考

任什麼時候候咱們必須處理邊緣狀況,事情每每會很快變得混亂。我但願這篇文章可以幫助你瞭解在非標準條件下可能遇到的各類問題,以及你可使用的解決方案。

Happy Kotlining!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索