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

淚已無痕發表於2019-03-03

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

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

協程從 1.3 開始成為穩定版!

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

當你深入研究協程時,以下是一些你需要知道的事情。

取消 + 阻塞操作 = ?

沒有辦法繞過它:在某些時候,你不得不用原生 Java 流。這裡的問題(很多情況下 ?)是使用流將會堵塞當前執行緒。這在協程中是一個壞訊息。現在,如果你想要取消一個協程,在能夠繼續執行之前,你不得不等待讀寫操作完成。

作為一個簡單可重複的例子,讓我們開啟 ServerSocket 並且等待 1 秒的超時連線:

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

         // 我們將卡在這裡直到有人接收該連線。難道你不想知道為什麼嗎??
        socket.accept()
    }
}
複製程式碼

應該可以執行,對嗎?不。

現在你的感受有點像:?。 那麼我們如何解決呢?

Closeable APIs 構建良好時,它們支援從任何執行緒關閉流並適當地失敗。

注意:通常情況下,JDK 中的 APIs 遵循了這些最佳實踐,但需注意第三方 Closeable APIs 可能並沒有遵循。 你被提醒過了。

幸虧 suspendCancellableCoroutine 函式,當一個協程被取消時我們可以關閉任何流:

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章