RxJava 系列-2:背壓和 Flowable

WngShhng發表於2019-03-04

背壓(Back Pressure)的概念最初並不是在響應式程式設計中提出的,它最初用在流體力學中,指的是後端的壓力,
通常用於描述系統排出的流體在出口處或二次側受到的與流動方向相反的壓力。

在響應式程式設計中,我們可以將產生資訊的部分叫做上游或者叫生產者,處理產生的資訊的部分叫做下游或者消費者。
試想如果在非同步的環境中,生產者的生產速度大於消費者的消費速度的時候,明顯會出現生產過剩的情景,這時候就需要消費者對多餘的資料進行快取,
但如果生產的資訊數量過多,以至於超出快取大小,就會出現快取溢位,甚至可能造成記憶體耗盡。

我們可以制定一個資料丟失的規則,來丟失那些“可以丟失的資料”,以減輕快取的壓力。
在之前我們介紹了一些方法,比如throttleXXXdebouncesample等,都是用來解決在生產速度過快的情況下的資料過濾的,它們指定了資料取捨的規則。
而在Flowable,我們可以通過onBackpressureXXX一系列的方法來制定當資料生產過快情況下的資料取捨的規則,

我們可以把這種處理方式理解成背壓,所謂背壓,在Rx中就是通過一種下游用來控制上游事件發射頻率的機制(就像流體在出口受到了阻力一樣)。
所以,如何理解背壓呢?筆者認為,在力學中它是一種現象,在Rx中它是一種機制。

在這篇文章中,我們會先介紹背壓的相關內容,然後我們再介紹一下onBackpressureXXX系列的方法。

關於RxJava2的基礎使用和方法梳理可以參考:RxJava2 系列 (1):一篇的比較全面的 RxJava2 方法總結

說明:以下文章部分翻譯自RxJava官方文件Backpressure (2.0)

1、背壓機制

如果將生產和消費整體看作一個管道,生成看作上游,消費看作下游;
那麼當非同步的應用場景下,當生產者生產過快而消費者消費很慢的時候,可以通過背壓來告知上游減慢生成的速度。

通常在進行非同步的操作的時候會通過快取來儲存發射出的資料。在早期的RxJava中,這些快取是無界的。
這意味著當需要快取的資料非常多的時候,它們可能會佔用非常多的儲存空間,並有可能因為虛擬機器不斷GC而導致程式執行過慢,甚至直接丟擲OOM。
在最新的RxJava中,大多數的非同步操作內部都存在一個有界的快取,當超出這個快取的時候就會丟擲MissingBackpressureException異常並結束整個序列。

然而,某些情況下的表現會有所不同,它們不會丟擲MissingBackpressureException異常。比如下面的range操作:

private static void compute(int i) throws InterruptedException {
    Thread.sleep(500);
    System.out.println("computing : " + i);
}

private static void testFlowable() throws InterruptedException {
    Flowable.range(1, MAX_LENGTH).observeOn(Schedulers.computation()).subscribe(FlowableTest::compute);

    Thread.sleep(500 * MAX_LENGTH);
}
複製程式碼

在這段程式碼中我們生成一段整數,然後每隔500毫秒執行依次計算操作。從輸出的結果來看,在程式的實際執行過程中,資料的發射是序列的。
也就是發射完一個資料之後進入compute進行計算,等待500毫秒之後才發射下一個。
因此,在程式的執行過程中沒有丟擲異常,也沒有過多的記憶體消耗。

而下面的這段程式碼就會在程式執行的時候立刻丟擲MissingBackpressureException異常:

PublishProcessor<Integer> source = PublishProcessor.create();
source.observeOn(Schedulers.computation()).subscribe(v -> compute(v), Throwable::printStackTrace);
for (int i = 0; i < 1_000_000; i++) source.onNext(i);
Thread.sleep(10_000);
複製程式碼

這是因為PublishProcessor底層會呼叫PublishSubscription,而後者實現了AtomicLong,它會通過判斷引用的long是否為0來丟擲異常,這個long型整數會在呼叫PublishSubscription.request()的時候被改寫。前面的一個例子的原理就是當每次呼叫了觀察者的onNext之後會呼叫PublishSubscription.request()來請求資料,這樣相當於消費者會在消費完事件之後向生產者請求,因此整個序列的執行看上去是序列的,從而不會丟擲異常。

2、onBackpressureXXX

大多數開發者在遇到MissingBackpressureException通常是因為使用observeOn方法監聽了非背壓的PublishProcessor, timer()interval()或者自定義的create()。我們有以下幾種方式來解決這個問題:

2.1 增加快取大小

observeOn方法的預設快取大小是16,當生產的速率過快的時候,那麼可能很快會超出該快取大小,從而導致快取溢位。
一種簡單的解決辦法是通過提升該快取的大小來防止快取溢位,我們可以使用observeOn的過載方法來設定快取的大小。比如:

PublishProcessor<Integer> source = PublishProcessor.create();
source.observeOn(Schedulers.computation(), 1024 * 1024)
      .subscribe(e -> { }, Throwable::printStackTrace);
複製程式碼

但是這種解決方案只能解決暫時的問題,當生產的速率過快的時候還是有可能造成快取溢位,所以這不是根本的解決辦法。

2.2 通過丟棄和過濾來減輕快取壓力

我們可以根據自己的應用的場景和資料的重要性,選擇使用一些方法來過濾和丟棄資料。
比如,丟棄的方式可以選擇throttleFirst, throttleLast, throttleWithTimeout等,還可以使用按照時間取樣的方式來減少接受的資料。

PublishProcessor<Integer> source = PublishProcessor.create();
source.sample(1, TimeUnit.MILLISECONDS)
      .observeOn(Schedulers.computation(), 1024)
      .subscribe(v -> compute(v), Throwable::printStackTrace);
複製程式碼

但是,這種方式僅僅用來減少下游接收的資料,當快取的資料不斷增加的時候還是有可能導致快取溢位,所以,這也不是一種根本的解決辦法。

2.3 onBackpressureBuffer()

這種無參的方法會使用一個無界的快取,只要虛擬機器沒有丟擲OOM異常,它就會把所有的資料快取起來。

 Flowable.range(1, 1_000_000)
           .onBackpressureBuffer()
           .observeOn(Schedulers.computation(), 8)
           .subscribe(e -> { }, Throwable::printStackTrace);
複製程式碼

上面的例子即使使用了很小的快取也不會有異常丟擲,因為onBackpressureBuffer會將發射的所有資料快取起來,只會將一小部分的資料傳遞給observeOn

這種處理方式實際上是不存在背壓的,因為onBackpressureBuffer快取了所有的資料,我們可以使用該方法的4個過載方法來對背壓進行個性化設定。

2.4 onBackpressureBuffer(int capacity)

這個方法使用一個有界的快取,當達到了快取大小的時候會丟擲一個BufferOverflowError錯誤。
通過這種方法可以增加預設的快取大小,但是通過observeOn方法一樣可以指定快取的大小,因此,這個方法的應用變得越來越少。

2.5 onBackpressureBuffer(int capacity, Action onOverflow)

這方法除了可以指定一個有界的快取還提供了一個,當快取溢位的時候還會回撥指定的Action。
但是這種回撥的用途比較有限,因為它除了提供當前回撥的棧資訊以外提供不了任何有用的資訊。

2.6 onBackpressureBuffer(int capacity, Action onOverflow, BackpressureOverflowStrategy strategy)

這個過載方法相對比較實用一些,它除了上面的那些功能之外,還指定了當快取到達指定的快取時的行為。
這裡的BackpressureOverflowStrategy顧名思義是一個策略,它是一個列舉型別,預定義了三種列舉值,最終會在FlowableOnBackpressureBufferStrategy中根據指定的列舉型別選擇不同的實現策略,因此,我們可以使用它來指定快取溢位時候的行為。

下面是該列舉型別的三個值及其含義:

  1. ERROR:當快取溢位的時候會丟擲一個異常;
  2. DROP_OLDEST:當快取發生溢位的時候,會丟棄最老的值,並將新的值插入到快取中;
  3. DROP_LATEST:當快取發生溢位的時候,最新的值會被忽略,只有比較老的值會被傳遞給下游使用;

需要注意的地方是,後面的兩種策略會造成下游獲取到的值是不連續的,因為有一部分值會因為快取不夠被丟棄,但是它們不會丟擲BufferOverflowException

2.7 onBackpressureDrop()

這個方法會在資料達到快取大小的時候丟棄最新的資料。可以將其看成是onBackpressureBuffer+0 capacity+DROP_LATEST的組合。

這個方法特別適用於那種可以忽略從源中發射出值的那種場景,比如GPS定位問題,定位資料會不斷髮射出來,即使丟失當前資料,等會兒一樣能拿到最新的資料。

component.mouseMoves()
    .onBackpressureDrop()
    .observeOn(Schedulers.computation(), 1)
    .subscribe(event -> compute(event.x, event.y));
複製程式碼

該方法還存在一個過載方法onBackpressureDrop(Consumer<? super T> onDrop),它允許我們傳入一個介面來指定當某個資料被丟失時的行為。

2.8 onBackpressureLatest()

對應於onBackpressureDrop()的,還有onBackpressureLatest()方法,該方法只會保留最新的資料並會覆蓋較老、沒有分發的資料。
我們可以將其看成是onBackpressureBuffer+1 capacity+DROP_OLDEST的組合。

onBackpressureDrop()不同的地方在於,當下遊消費過慢的時候,這種方式總會存在一個快取的值。
這種特別適用於那種資料的生產非常頻繁,但是隻有最新的資料會被消費的那種情形。比如,當使用者點選了螢幕,那麼我們傾向於只處理最新按下的位置的事件。

component.mouseClicks()
    .onBackpressureLatest()
    .observeOn(Schedulers.computation())
    .subscribe(event -> compute(event.x, event.y), Throwable::printStackTrace);
複製程式碼

所以,總結一下:

  1. onBackpressureDrop():不會快取任何資料,專注於當下,新來的資料來不及處理就丟掉,以後會有更好的;
  2. onBackpressureLatest():會快取一個資料,當正在執行某個任務的時候有新的資料過來,會把它快取起來,如果又有新的資料過來,那就把之前的替換掉,快取裡面的總是最新的。

3、總結

以上就是背壓機制的一些內容,以及我們介紹了Flowable中的幾個背壓相關的方法。
實際上,RxJava的官方文件也有說明——Flowable適用於資料量比較大的情景,因為它的一些建立方法本身就使用了背壓機制。
這部分方法我們就不再一一進行說明,因為,它們的方法簽名和Observable基本一致,只是多了一層背壓機制。

比較匆匆地整理完了背壓的內容,但是我想這塊還會有更加豐富的內容值得我們去發現和探索。

以上。

RxJava2 系列文章

相關文章