關於RxJava最友好的文章——背壓(Backpressure)

拉丁吳發表於2016-11-20

前言

背壓(Backpressure)可能是所有想要深入運用RxJava的朋友必須理解的一個概念

關於它的介紹,我本意是想寫在RxJava2.0更新介紹的文章裡的,可是寫著寫著發現,要完整介紹這個概念需要花費的篇幅太長,恰好目前對於背壓的介紹文章比較少,所以決定單獨拿出來,自成一篇。而關於RxJava2.0的文章修改之後就會發出來和大家探討。

如果對於RxJava不是很熟悉,那麼在這篇文章之前,我希望大家先看看那篇關於Rxjava最友好的文章,可以幫助大家很順暢的瞭解RxJava。


從場景出發

讓我們先忘掉背壓(Backpressure)這個概念,從RxJava一個比較常見的工作場景說起。

RxJava是一個觀察者模式的架構,當這個架構中被觀察者(Observable)和觀察者(Subscriber)處在不同的執行緒環境中時,由於者各自的工作量不一樣,導致它們產生事件和處理事件的速度不一樣,這就會出現兩種情況:

  • 被觀察者產生事件慢一些,觀察者處理事件很快。那麼觀察者就會等著被觀察者傳送事件,(好比觀察者在等米下鍋,程式等待,這沒有問題)
  • 被觀察者產生事件的速度很快,而觀察者處理很慢。那就出問題了,如果不作處理的話,事件會堆積起來,最終擠爆你的記憶體,導致程式崩潰。(好比被觀察者生產的大米沒人吃,堆積最後就會爛掉)

下面我們用程式碼演示一下這種崩潰的場景:

//被觀察者在主執行緒中,每1ms傳送一個事件
Observable.interval(1, TimeUnit.MILLISECONDS)
                //.subscribeOn(Schedulers.newThread())
                //將觀察者的工作放在新執行緒環境中
                .observeOn(Schedulers.newThread())
                //觀察者處理每1000ms才處理一個事件
                .subscribe(new Action1<Long>() {
                      @Override
                      public void call(Long aLong) {
                          try {
                              Thread.sleep(1000);
                          } catch (InterruptedException e) {
                              e.printStackTrace();
                          }
                          Log.w("TAG","---->"+aLong);
                      }
                  });
複製程式碼

在上面的程式碼中,被觀察者傳送事件的速度是觀察者處理速度的1000倍

這段程式碼執行之後:

    ...
    Caused by: rx.exceptions.MissingBackpressureException
    ...
    ...
複製程式碼

丟擲MissingBackpressureException往往就是因為,被觀察者傳送事件的速度太快,而觀察者處理太慢,而且你還沒有做相應措施,所以報異常。

而這個MissingBackpressureException異常裡面就包含了Backpressure這個單詞,看來背壓肯定和這種異常情況有關係。

那麼背壓(Backpressure)到底是什麼呢?


關於背壓(Backpressure)

我這兩天翻閱了大量的中文和英文資料,我發現中文資料中,很多人對於背壓(Backpressure)的理解是有很大問題的,有的人把它看作一個需要避免的問題,或者程式的異常,有的人則乾脆避而不談,模稜兩可,著實讓人尷尬。

通過參考和對比大量的相關資料,我在這裡先對背壓(Backpressure)做一個明確的定義:背壓是指在非同步場景中,被觀察者傳送事件速度遠快於觀察者的處理速度的情況下,一種告訴上游的被觀察者降低傳送速度的策略

簡而言之,背壓是流速控制的一種策略

需要強調兩點:

  • 背壓策略的一個前提是非同步環境,也就是說,被觀察者和觀察者處在不同的執行緒環境中。
  • 背壓(Backpressure)並不是一個像flatMap一樣可以在程式中直接使用的操作符,他只是一種控制事件流速的策略。

那麼我們再回看上面的程式異常就很好理解了,就是當被觀察者傳送事件速度過快的情況下,我們沒有做流速控制,導致了異常。

那麼背壓(Backpressure)策略具體是哪如何實現流速控制的呢?


響應式拉取(reactive pull)

首先我們回憶之前那篇關於Rxjava最友好的文章,裡面其實提到,在RxJava的觀察者模型中,被觀察者是主動的推送資料給觀察者,觀察者是被動接收的。而響應式拉取則反過來,觀察者主動從被觀察者那裡去拉取資料,而被觀察者變成被動的等待通知再傳送資料

結構示意圖如下:

關於RxJava最友好的文章——背壓(Backpressure)

觀察者可以根據自身實際情況按需拉取資料,而不是被動接收(也就相當於告訴上游觀察者把速度慢下來),最終實現了上游被觀察者傳送事件的速度的控制,實現了背壓的策略。

程式碼例項如下:

//被觀察者將產生100000個事件
Observable observable=Observable.range(1,100000);
class MySubscriber extends Subscriber<T> {
    @Override
    public void onStart() {
    //一定要在onStart中通知被觀察者先傳送一個事件
      request(1);
    }
 
    @Override
    public void onCompleted() {
        ...
    }
 
    @Override
    public void onError(Throwable e) {
        ...
    }
 
    @Override
    public void onNext(T n) {
        ...
        ...
        //處理完畢之後,在通知被觀察者傳送下一個事件
        request(1);
    }
}

observable.observeOn(Schedulers.newThread())
            .subscribe(MySubscriber);
複製程式碼

在程式碼中,傳遞事件開始前的onstart()中,呼叫了request(1),通知被觀察者先傳送一個事件,然後在onNext()中處理完事件,再次呼叫request(1),通知被觀察者傳送下一個事件....

注意在onNext()方法中,最好最後再呼叫request()方法.

如果你想取消這種backpressure 策略,呼叫quest(Long.MAX_VALUE)即可。

實際上,在上面的程式碼中,你也可以不需要呼叫request(n)方法去拉取資料,程式依然能完美執行,這是因為range --> observeOn,這一段中間過程本身就是響應式拉取資料,observeOn這個操作符內部有一個緩衝區,Android環境下長度是16,它會告訴range最多傳送16個事件,充滿緩衝區即可。不過話說回來,在觀察者中使用request(n)這個方法可以使背壓的策略表現得更加直觀,更便於理解

如果你足夠細心,會發現,在開頭展示異常情況的程式碼中,使用的是interval這個操作符,但是在這裡使用了range操作符,為什麼呢?

這是因為interval操作符本身並不支援背壓策略,它並不響應request(n),也就是說,它傳送事件的速度是不受控制的,而range這類操作符是支援背壓的,它傳送事件的速度可以被控制。

那麼到底什麼樣的Observable是支援背壓的呢?


Hot and Cold Observables

需要說明的時,Hot Observables 和cold Observables並不是嚴格的概念區分,它只是對於兩類Observable形象的描述

  • Cold Observables:指的是那些在訂閱之後才開始傳送事件的Observable(每個Subscriber都能接收到完整的事件)。
  • Hot Observables:指的是那些在建立了Observable之後,(不管是否訂閱)就開始傳送事件的Observable

其實也有建立了Observable之後呼叫諸如publish()方法就可以開始傳送事件的,這裡我們們暫且忽略。

我們一般使用的都是Cold Observable,除非特殊需求,才會使用Hot Observable,在這裡,Hot Observable這一類是不支援背壓的,而是Cold Observable這一類中也有一部分並不支援背壓(比如interval,timer等操作符建立的Observable)。

懵逼了吧?

Tips: 都是Observable,結果有的支援背壓,有的不支援,這就是RxJava1.X的一個問題。在2.0中,這種問題已經解決了,以後談到2.0時再細說。

在那些不支援背壓策略的操作符中使用響應式拉取資料的話,還是會丟擲MissingBackpressureException。

那麼,不支援背壓的Observevable如何做流速控制呢?


流速控制相關的操作符

過濾(拋棄)

就是雖然生產者產生事件的速度很快,但是把大部分的事件都直接過濾(浪費)掉,從而間接的降低事件傳送的速度。

相關類似的操作符:Sample,ThrottleFirst.... 以sample為例,

Observable.interval(1, TimeUnit.MILLISECONDS)

                .observeOn(Schedulers.newThread())
                //這個操作符簡單理解就是每隔200ms傳送裡時間點最近那個事件,
                //其他的事件浪費掉
                  .sample(200,TimeUnit.MILLISECONDS)
                  .subscribe(new Action1<Long>() {
                      @Override
                      public void call(Long aLong) {
                          try {
                              Thread.sleep(200);
                          } catch (InterruptedException e) {
                              e.printStackTrace();
                          }
                          Log.w("TAG","---->"+aLong);
                      }
                  });
複製程式碼

這是以殺敵一千,自損八百的方式解決這個問題,因為拋棄了絕大部分的事件,而在我們使用RxJava 時候,我們自己定義的Observable產生的事件可能都是我們需要的,一般來說不會拋棄,所以這種方案有它的缺陷。

快取

就是雖然被觀察者傳送事件速度很快,觀察者處理不過來,但是可以選擇先快取一部分,然後慢慢讀。

相關類似的操作符:buffer,window... 以buffer為例,

Observable.interval(1, TimeUnit.MILLISECONDS)

                .observeOn(Schedulers.newThread())
                //這個操作符簡單理解就是把100毫秒內的事件打包成list傳送
                .buffer(100,TimeUnit.MILLISECONDS)
                  .subscribe(new Action1<List<Long>>() {
                      @Override
                      public void call(List<Long> aLong) {
                          try {
                              Thread.sleep(1000);
                          } catch (InterruptedException e) {
                              e.printStackTrace();
                          }
                          Log.w("TAG","---->"+aLong.size());
                      }
                  });
                  
複製程式碼

兩個特殊操作符

對於不支援背壓的Observable除了使用上述兩類生硬的操作符之外,還有更好的選擇:onBackpressurebuffer,onBackpressureDrop

  • onBackpressurebuffer:把observable傳送出來的事件做快取,當request方法被呼叫的時候,給下層流傳送一個item(如果給這個快取區設定了大小,那麼超過了這個大小就會丟擲異常)。
  • onBackpressureDrop:將observable傳送的事件拋棄掉,直到subscriber再次呼叫request(n)方法的時候,就傳送給它這之後的n個事件。

下面,我們以onBackpressureDrop為例說說用法:

 Observable.interval(1, TimeUnit.MILLISECONDS)
                .onBackpressureDrop()
                .observeOn(Schedulers.newThread())
               .subscribe(new Subscriber<Long>() {

                    @Override
                    public void onStart() {
                        Log.w("TAG","start");
//                        request(1);
                    }

                    @Override
                      public void onCompleted() {

                      }
                      @Override
                      public void onError(Throwable e) {
                            Log.e("ERROR",e.toString());
                      }

                      @Override
                      public void onNext(Long aLong) {
                          Log.w("TAG","---->"+aLong);
                          try {
                              Thread.sleep(100);
                          } catch (InterruptedException e) {
                              e.printStackTrace();
                          }
                      }
                  });
複製程式碼

這段程式碼的輸出:

W/TAG: start
W/TAG: ---->0
W/TAG: ---->1
W/TAG: ---->2
W/TAG: ---->3
W/TAG: ---->4
W/TAG: ---->5
W/TAG: ---->6
W/TAG: ---->7
W/TAG: ---->8
W/TAG: ---->9
W/TAG: ---->10
W/TAG: ---->11
W/TAG: ---->12
W/TAG: ---->13
W/TAG: ---->14
W/TAG: ---->15
W/TAG: ---->1218
W/TAG: ---->1219
W/TAG: ---->1220
...
複製程式碼

之所以出現0-15這樣連貫的資料,就是是因為observeOn操作符內部有一個長度為16的快取區,它會首先請求16個事件快取起來....

你可能會覺得這兩個操作符和上面講的過濾和快取很類似,確實,功能上是有些類似,但是這兩個操作符提供了更多的特性,那就是可以響應下游觀察者的request(n)方法了,也就是說,使用了這兩種操作符,可以讓原本不支援背壓的Observable“支援”背壓了


勘誤

1, 本文之前對於Hot Observables和Cold observables的描述寫反了,是我太大意,目前已改正,你們現在看到的是正確的,感謝@jaychang0917的提醒


後記

講了這麼多終於要到尾聲了。

下面我們總結一下:

  • 背壓是一種策略,具體措施是下游觀察者通知上游的被觀察者傳送事件
  • 背壓策略很好的解決了非同步環境下被觀察者和觀察者速度不一致的問題
  • 在RxJava1.X中,同樣是Observable,有的不支援背壓策略,導致某些情況下,顯得特別麻煩,出了問題也很難排查,使得RxJava的學習曲線變得十份陡峭。

這篇文章並不是為了讓你學習在RxJava1.0中使用背壓(如果你之前不瞭解背壓的話),因為在1.0中,背壓的設計並不十分完美。而是希望你對背壓有一個全面清晰的認識,對於它在RxJava1.0中的設計缺陷有所瞭解即可。因為這篇文章本身是為了2.0做一個鋪墊,後續的文章中我會繼續談到背壓和使用背壓的正確姿勢。

相關文章