[譯] 管中窺豹:RxJava 與 Kotlin 協程的對比

LeviDing發表於2017-11-14

引言

Kotlin 的協程是否讓 RxJava 和 響應式程式設計光輝不再 了呢?答案取決於你詢問的物件。狂信徒和營銷者們會毫不猶豫地是是是。如果真是這樣的話,開發者們遲早會將 Rx 程式碼用協程重寫一遍,抑或從一開始就用協程來寫。 因為 協程 目前還是實驗性的,所以目前的諸如效能瓶頸之類的不足,都將逐漸解決。因此,相對於原生效能,本文的重點更在於易用性方面。

方案設計

假設有兩個函式,f1f2,用來模仿不可信的服務,二者都會在一段延遲之後返回一個數。呼叫這兩個函式,將其返回值求和並呈現給使用者。然而如果 500ms 之內沒有返回的話,就不再指望它會返回值了,因此我們會在有限次數內取消並重試,直到超過次數最終放棄請求。

協程的方式

協程用起來就像是傳統的 基於 ExecutorServiceFuture 的工具套裝, 不同點在於協程的底層是用的掛起、狀態機和任務排程來代替執行緒阻塞的。

首先,寫兩個函式來實現延遲操作:

suspend fun f1(i: Int) {
    Thread.sleep(if (i != 2) 2000L else 200L)
    return 1;
}

suspend fun f2(i: Int) {
    Thread.sleep(if (i != 2) 2000L else 200L)
    return 2;
}
複製程式碼

與協程排程有關的函式需要加上 suspend 關鍵字並通過協程上下文來呼叫。為了演示上面的目的,如果傳入引數不是 2 的時候,函式會延遲 2s。這樣就會讓超時檢測將其結束掉,並在第三次嘗試時在規定時間內成功。

因為非同步總會在結束時離開主執行緒,我們需要一個方法來在業務邏輯完成前阻塞它,以防止直接退出 JVM。為了達到目的,可以使用 runBlocking 在主執行緒中呼叫函式。

fun main(arg: Array<string>) = runBlocking <unit>{

     coroutineWay()

     reactiveWay()
}

suspend func coroutineWay() {
    // TODO implement
}

func reactiveWay() {
    // TODO implement
}</unit> </string>
複製程式碼

相比 RxJava 的函式式,用協程寫出來的程式碼邏輯更簡潔,而且程式碼看起來就像是線性和同步的一樣。

suspend fun coroutineWay() {
    val t0 = System.currentTimeMillis()

    var i = 0;
    while (true) {                                       // (1)
        println("Attempt " + (i + 1) + " at T=" +
            (System.currentTimeMillis() - t0))

        var v1 = async(CommonPool) { f1(i) }             // (2)
        var v2 = async(CommonPool) { f2(i) }

        var v3 = launch(CommonPool) {                    // (3)
            Thread.sleep(500)
            println("    Cancelling at T=" +
                (System.currentTimeMillis() - t0))
            val te = TimeoutException();
            v1.cancel(te);                               // (4)
            v2.cancel(te);
        }

        try {
            val r1 = v1.await();                         // (5)
            val r2 = v2.await();
            v3.cancel();                                 // (6)
            println(r1 + r2)
            break;                                       
        } catch (ex: TimeoutException) {                 // (7)
            println("         Crash at T=" +
                (System.currentTimeMillis() - t0))
            if (++i > 2) {                               // (8)
                throw ex;
            }
        }
    }
    println("End at T=" 
        + (System.currentTimeMillis() - t0))             // (9)

}
複製程式碼

新增的一些輸出是用來觀察這段程式碼如何執行的。

  1. 通常線性程式設計的情況下,是沒有直接重試某個操作的快捷方法的,因此,我們需要建立一個迴圈以及重試計數器 i
  2. 通過 async(CommonPool) 來執行非同步操作,該函式可以在一些後臺執行緒立即啟動並執行函式。該函式會返回一個 Deferred,稍後會用到這個值。 如果用 await() 來得到 v1 作為最終值的話,當前執行緒將會掛起,另外,對 v2 的計算也不會開始,除非前一個恢復執行。除此以外,我們還需要在超時的情況下取消當前操作的方法。參考步驟 3 和 5。
  3. 如果想讓兩個操作都超時的話,看起來我們只能在另一個非同步執行緒中執行等待操作。launch(CommonPool) 方法會返回一個可以用在這種情況下的 Job 物件。 與 async 的區別是,這樣執行無法返回值。之所以儲存返回的 Job 是因為先前的非同步操作可能及時返回,就不再需要取消操作了。
  4. 在超時的任務中,我們用 TimeoutException 來取消 v1v2 ,這將恢復任何已經掛起來等待二者返回的操作。
  5. 等待兩個函式執行結果。如果超時,await 將重新扔出在第四步中使用的異常。
  6. 如果沒有異常,則取消不再需要執行的超時任務,並跳出迴圈。
  7. 如果有超時,則走老一套捕獲異常並執行狀態檢查來確定下一步操作。注意任何其他異常都會直接被丟擲並退出迴圈。
  8. 萬一是第三次或更多次的嘗試,直接扔出異常,什麼都不做。
  9. 如果一切按劇本走,列印執行的總時間,然後退出當前函式。

看起來挺簡單的,儘管取消機制可能搞個大新聞:如果 v2 因為其他異常(比如網路原因導致的 IOException)崩潰了呢?當然我們得處理這些情況來確保任務可以在各種情況下被取消(舉個栗子,試試 Kotlin 中的資源?)。然而,這種情況發生的背景是 v1 會及時返回,直到嘗試 await 之前都無法取消 v1 或檢測 v2 的崩潰。

不要在意那些細節,反正程式跑起來了,執行結果如下:

Attempt 1 at T=0
    Cancelling at T=531
         Crash at T=2017
Attempt 2 at T=2017
    Cancelling at T=2517
         Crash at T=4026
Attempt 3 at T=4026
3
End a
複製程式碼

一共進行了 3 次嘗試,最後一次成功了,值是 3。是不是和劇本一模一樣的?一點都不快(此處有雙關(譯者並沒有看出來哪裡有雙關))! 我們可以看到取消事件發生的大概時間,兩次不成功的請求之後大約 500 ms ,然而異常捕獲發生在大約 2000 ms 之後!我們知道 cancel() 被成功呼叫是因為我們捕獲了異常。然而,看起來函式中的 Thread.sleep() 並沒有被打斷,或者用協程的說法,沒有在打斷異常時恢復。這可能是 CommonPool 的一部分,對 Future.cancel(false) 的呼叫處於基礎結構中,抑或只是簡單的程式限制。

響應式

接下來我們看看 RxJava 2 是如何實現相同操作的。讓人失望的是,如果函式前加了 suspended,就無法通過普通方式呼叫了,所以我們還得用普通方法重寫一下兩個函式:

fun f3(i: Int) : Int {
    Thread.sleep(if (i != 2) 2000L else 200L)
    return 1
}

fun f4(i: Int) : Int {
    Thread.sleep(if (i != 2) 2000L else 200L)
    return 2
}
複製程式碼

為了匹配阻塞外部環境的功能,我們採用  RxJava 2 Extensions 中的 BlockingScheduler 來提供返回到主執行緒的功能。顧名思義,它阻塞了一開始的呼叫者/主執行緒,直到有任務通過排程器來提交併執行。

fun reactiveWay() {
    RxJavaPlugins.setErrorHandler({ })                         // (1)

    val sched = BlockingScheduler()                            // (2)
    sched.execute {
        val t0 = System.currentTimeMillis()
        val count = Array<Int>(1, { 0 })                       // (3)

        Single.defer({                                         // (4)
            val c = count[0]++;
            println("Attempt " + (c + 1) +
                " at T=" + (System.currentTimeMillis() - t0))

            Single.zip(                                        // (5)
                    Single.fromCallable({ f3(c) })
                        .subscribeOn(Schedulers.io()),
                    Single.fromCallable({ f4(c) })
                        .subscribeOn(Schedulers.io()),
                    BiFunction<Int, Int> { a, b -> a + b }               // (6)
            )
        })
        .doOnDispose({                                         // (7)
            println("    Cancelling at T=" + 
                (System.currentTimeMillis() - t0))
        })
        .timeout(500, TimeUnit.MILLISECONDS)                   // (8)
        .retry({ x, e ->
            println("         Crash at " + 
                (System.currentTimeMillis() - t0))
            x < 3 && e is TimeoutException                     // (9)
        })
        .doAfterTerminate { sched.shutdown() }                 // (10)
        .subscribe({
            println(it)
            println("End at T=" + 
                (System.currentTimeMillis() - t0))             // (11)
        },
        { it.printStackTrace() })
    }
}
複製程式碼

實現起來有點長,對那些不熟悉 lambda 的人來說看起來可能有點可怕。

  1. 眾所周知 RxJava 2 無論如何都會傳遞異常。在 Android 上,無法傳遞的異常會使應用崩潰,除非使用 RxJavaPlugins.setErrorHandler 來捕獲。在此,因為我們知道取消事件會打斷 Thread.sleep() ,呼叫棧打出來的結果只會是一團亂麻,我們也不會去注意這麼多的異常。
  2. 設定 BlockingScheduler 並分發第一個執行的任務,以及剩下的主執行緒執行邏輯。 這是由於一旦鎖住, start() 將會給主執行緒增加一個活鎖狀態,直到有任何隨後事件打破鎖定,主執行緒才會繼續執行。
  3. 設定一個堆變數來記錄重試次數。
  4. 一旦有通過 Single.defer 的訂閱,計數器加一併列印 “Attempt” 字串。該操作符允許保留每個訂閱的狀態,這正是我們在下游執行的 retry() 操作符所期望的。
  5. 使用 zip 操作符來非同步執行兩個元素的計算,二者都在後臺執行緒執行自己的函式。
  6. 當二者都完成時,將結果相加。
  7. 為了讓超時取消,使用 doOnDispose 操作符來列印當前狀態和時間。
  8. 使用 timeout 操作符定義求和的超時。如果超時則會傳送 TimeoutException(例如該場景下沒有反饋時)。
  9. retry 操作符的過載提供了重試時間以及當前錯誤。列印錯誤後,應該返回 true ——也就是說必須執行重試——如果重試次數小於三並且當前錯誤是 TimeoutException 的話。任何其他錯誤只會終止而不是觸發重試。
  10. 一旦完成,我們需要關閉排程器,來讓釋放主執行緒並退出JVM。
  11. 當然,在完成前我們需要列印求和結果以及整個操作的耗時。

可能有人說,這比協程的實現複雜多了。不過……至少跑起來了:

    Cancelling at T=4527

Attempt 1 at T=72
    Cancelling at T=587
         Crash at 587
Attempt 2 at T=587
    Cancelling at T=1089
         Crash at 1090
Attempt 3 at T=1090
    Cancelling at T=1291
3
End at T=1292
複製程式碼

有趣的是,如果在 main 函式中同時呼叫兩個函式的話,Cancelling at T=4527 是在呼叫 coroutineWay() 方法時列印出來的:儘管最後根本沒有時間消耗,取消事件自身就浪費在無法停止的計算問題上,也因此在取消已經完成的任務上增加了額外消耗。

另一方面,RxJava 至少及時地取消和重試了函式。然而,實際上也有幾乎沒必要的 Cancelling at T=1291 被列印出來了。吶,沒辦法,寫出來就這樣了,或者說我懶吧,在 Single.timeout 中是這樣實現的:如果沒有延時就完成了的話,無論操作符真實情況如何,內部的 CompositeDisposable 代理了上游的 Disposable 並將其和操作符一起取消了。

結論

最後呢,我們通過一個小小的改進來看一下響應式設計的強大之處:如果只需要重試沒有響應的函式的話,為什麼我們要重試整個過程呢?改進方法也可以很容易地在 RxJava 中找到:將 doOnDispose().timeout().retry() 放到每一個函式呼叫鏈中(也許用 transfomer 可以避免程式碼的重複):

val timeoutRetry = SingleTransformer<Int, Int> { 
    it.doOnDispose({
        println("    Cancelling at T=" + 
            (System.currentTimeMillis() - t0))
    })
    .timeout(500, TimeUnit.MILLISECONDS)
    .retry({ x, e ->
        println("         Crash at " + 
            (System.currentTimeMillis() - t0))
        x < 3 && e is TimeoutException
    })
}

// ...

Single.zip(
    Single.fromCallable({ f3(c) })
        .subscribeOn(Schedulers.io())
        .compose(timeoutRetry)
    ,
    Single.fromCallable({ f4(c) })
        .subscribeOn(Schedulers.io())
        .compose(timeoutRetry)
    ,
    BiFunction<Int, Int> { a, b -> a + b }
)
// ...
複製程式碼

歡迎讀者親自動手實踐並更新協程的實現來實現相同行為(順便可以試試各種其他形式的取消機制)。 響應式程式設計的好處之一是大多數情況下都不必去理會諸如執行緒、取消資訊的傳遞和操作符的結構等惱人的東西。RxJava 之類的庫已經設計好了 API 並將這些底層的大麻煩封裝起來了,通常情況下,程式設計師只需要使用即可。

那麼,協程到底有沒有用呢?當然有用啦,但總的來說,我還是覺得效能對其是極大的限制,同時,我也想知道協程可以怎麼做才能整體取代響應式程式設計。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章