[譯] Kotlin 、協程、結構化併發

今天 (2018/09/12) 是 kotlinx.coroutines 0.26.0 版本的發佈日,同時在這裏對 Kotlin 協程的「結構化併發」作一些介紹。它不只僅是一個功能改變——它標誌着編程風格的巨大改變,我寫這篇文章就是爲了解釋這一點。html

在 Kotlin 1.1 也就是 2017年初, 首次推出協程做爲實驗性質的特性開始,咱們一直在努力向程序員解釋協程的概念,他們過去經常使用線程理解併發,因此咱們舉的例子和標語是"協程是輕量級線程"。前端

此外,咱們的關鍵 api 被設計爲相似於線程 api,以簡化學習曲線。這種舉例在小規模例子中很適用,可是它不能幫助解釋協程編程風格的轉變。git

當咱們學習使用線程編程時,咱們被告知線程是昂貴的資源,不該該處處建立它們。一個優雅的程序一般在啓動時建立一個線程池而後使用它們搞些事情。有些環境(尤爲是 iOS)甚至說"不同意使用線程"(即便全部的東西仍然在線程上運行)。它們提供了一個系統內的隨時可用的線程池,其中包含可向其提交代碼的相應隊列。程序員

可是協程的狀況不一樣。它能夠很是方便地建立不少你須要的協程,由於它們很是廉價。讓咱們看一下協程的幾個用例。github

異步操做(Asynchronous operations)

假設你正在寫一個前端 UI 應用(移動端、web 端或桌面端——對於這個例子並不重要),而且須要向後端發送一個請求,以獲取一些數據並使用結果更新 UI 模型。咱們最初推薦這樣寫:web

fun requestSomeData() {
    launch(UI) {
        updateUI(performRequest())
    }
}
複製代碼

這裏,咱們使用 launch(UI) 在 UI 上下文中啓動一個新的協程,調用performRequest 掛起函數對後端執行異步調用,而不阻塞主 UI 線程,而後使用結果更新 UI。每一個 requestSomeData 調用都建立本身的協程,這很好,不是嗎?它和 C#、JS 和 GO 中的異步編程並無太大的不一樣。編程

可是這裏有個問題。若是網絡或後端出現問題,這些異步操做可能須要很長時間才能完成。此外,這些操做一般在一些 UI 元素(好比窗口或頁面)的範圍內執行。若是一個操做花費的時間太長,一般用戶會關閉相應的 UI 元素並執行其餘操做,或者更糟糕的是,從新打開這個 UI 並一次又一次地嘗試該操做。可是前面的操做仍然在後臺運行,因此當用戶關閉相應的 UI 元素時,咱們須要某種機制來取消它。在 Kotlin 協程中,這致使咱們推薦了一些很是棘手的設計模式,人們必須在代碼中遵循這些模式,以確保正確處理這種取消。此外,你老是必須記住指定適當的上下文,不然 updateUI 可能會被錯誤的線程調用,從而破壞 UI。這是很容易出錯的。一個簡單的launch{ ... } 很輕鬆就寫出來,可是你不該該寫成這樣。後端

在更哲學的層面上,咱們不多像線程那樣"全局"地啓動協程。協程老是與應用程序中的某個局部做用域相關,這個局部做用域是一個生命週期有限的實體,好比 UI 元素。所以,對於結構化併發,咱們如今要求在一個協程做用域中調用 launch,協程做用域是由你的生命週期有限的對象(如 UI 元素或它們相應的視圖模型)實現的接口。你的 UI 元素實現一次協程做用域後, 你會發現,在你的 UI 類中就能輕鬆的使用的 launch{ … } ,而後你能夠愉快的寫不少次,而且不容易出錯:設計模式

fun requestSomeData() {
    launch {
        updateUI(performRequest())
    }
}
複製代碼

注意,協程做用域的實現還爲 UI 更新定義了適當的協程上下文。你能夠在其文檔頁面上找到一個完整的協程做用域實現示例。對於一些比較少見的狀況,你須要一個全局協程,它的生命週期受整個應用生命週期限制,咱們如今提供了 GlobalScope (全局做用域)對象,所以之前全局協程的launch{ … } 變成了 GlobalScope.launch { … } ,協程的"全局"含義變得直觀了。api

並行分解(Parallel decomposition)

我已經就 Kotlin 協程進行了屢次 討論,,下面的示例代碼展現瞭如何並行加載兩個圖片並在稍後將它們組合起來——這是一個使用 Kotlin 協程並行分解工做的慣用示例:

suspend fun loadAndCombine(name1: String, name2: String): Image { 
    val deferred1 = async { loadImage(name1) }
    val deferred2 = async { loadImage(name2) }
    return combineImages(deferred1.await(), deferred2.await())
}
複製代碼

不幸的是,這個例子在不少層面上都是錯誤的。掛起函數loadAndCombine 自己將從一個已經啓動的執行更大操做的協程內部調用。若是這個操做被取消了呢?而後加載這兩個圖片仍然沒有收到影響。這不是咱們想從可靠代碼中的獲得的,特別是若是這些代碼是許多客戶端使用後端服務的一部分。

咱們推薦的解決方案是寫成這樣async(conroutineContext){ … } ,以便在子協程中加載兩個圖片,當父協程被取消時,子協程將被取消。

它仍然不完美。若是加載第一個圖片失敗,那麼 deferred1.await() 將拋出相應的異常,可是加載第二個圖片的第二個 async 協程仍然在後臺工做。解決這個問題就更復雜了。

咱們在第二個用例中看到了一樣的問題。一個簡單的 async { … } 很容易寫,可是你不該該寫成這樣。

使用結構化併發,async 協程構建器就像 luanch 同樣,變成了協程做用域上的一個擴展。你不能再簡單的編寫 async{ … } ,你必須提供一個做用域。並行分解的一個恰當的例子是:

suspend fun loadAndCombine(name1: String, name2: String): Image =
    coroutineScope { 
        val deferred1 = async { loadImage(name1) }
        val deferred2 = async { loadImage(name2) }
        combineImages(deferred1.await(), deferred2.await())
    }
複製代碼

你必須將代碼封裝到 coroutineScope { ... } 塊中,這個塊爲你的操做及其範圍創建了邊界。全部異步協程都成爲這個範圍的子協程,若是該做用域由於異常致使失敗或被取消了,它全部的子協程也將被取消。

進一步的閱讀

結構化併發的概念背後有更多的哲學。我強烈推薦閱讀 「結構化併發的注意事項,或:Go 語句的危害」 ,它很好的對比了經典的 goto-statement 和結構化編程。

現代語言在剛開始時爲咱們提供一種以徹底非結構化啓動併發任務的方式,這玩意該結束了。

相關文章
相關標籤/搜索