深入理解 RxJava2:論 Parallel 與併發(5)

蝶翼的罪發表於2018-09-03

前言

歡迎來到深入理解 RxJava2 系列第五篇。在上一篇文章中,我們在一個例子裡用到了 parallel 操作符,本篇我們便是要介紹該操作符,並對比 RxJava 一些常見的併發手段,詳述 parallel 的優越性。

陳舊的 parallel

出生

Parallel 這個操作符首次在 RxJava 0.13.4 版本新增進去,作為一個實驗性質的 API,並在同一個版本為Scheduler新增了degreeOfParallelism方法為parallel獨用。

public abstract class Scheduler {

    public int degreeOfParallelism() {
        return Runtime.getRuntime().availableProcessors();
    }
    
    ...
}
複製程式碼

後來在 0.18.0 版本重構了一次Scheduler,並順帶把degreeOfParallelism簡化成了parallelism

遺棄

然而這個操作符當時的實現,並不是那麼恰當,和大家預期的用法不一致。類比 Java8 的 Stream API,開發者們期望的是在呼叫parallel後,後續的操作符都會併發執行,然而事實並不是這樣。

當時的parallel實現的有點半成品的意味,因此在 1.0.0-RC2 時被移除了。詳情見 Issue :github.com/ReactiveX/R…

與此同時Schedulerparallelism便不再有用了,隨即在 1.0.0-RC11 版本被移除。

GroupBy 與 FlatMap

parallel不在的日子裡,我們如果想併發的做一些操作,通常都會利用flatMap

...
.flatMap(new Function<Object, Publisher<?>>() {
    @Override
    public Publisher<?> apply(Object o) throws Exception {
        return Flowable
                .just(o)
                .subscribeOn(Schedulers.computation())
                ...;
    }
})
...
.subscribe();
複製程式碼

有些讀者會疑問為什麼要這樣寫,直接用observeOnsubscribeOn不行嗎。顯然不行,我們在《深入理解 RxJava2:Scheduler(2)》強調過,每個Worker的任務都是序列的,因此如果不用flatMap來生成多個Flowable,就無法達到並行的效果。

事實上上面的這種寫法吞吐量非常的差,因此我們還需要藉助groupByflatMap來配合:

Flowable.just("a", "b", "c", "d", "e")
        .groupBy(new Function<String, Integer>() {
            int i = 0;
            final int cpu = Runtime.getRuntime().availableProcessors();

            @Override
            public Integer apply(String s) throws Exception {
                return (i++) % cpu;
            }
        })
        .flatMap(new Function<GroupedFlowable<Integer, String>, Publisher<?>>() {
            @Override
            public Publisher<?> apply(GroupedFlowable<Integer, String> g) throws Exception {
                return g.observeOn(Schedulers.computation())
                        ... // do some job
            }
        })
        ...
        .subscribe();
複製程式碼

通過groupBy將資料分組,再將每組的資料通過flatMap排程至一個執行緒來執行。groupByflatMap的組合,可以任意控制併發數,由於避免了很多無用的損耗,效能較單獨的flatMap大大提升。

然而上面的程式碼表述力不太好,而且很多不熟悉這些操作符的開發者寫不出類似的程式碼,簡單的說就是不太好用。

於是一個能無縫的嵌入Flowable呼叫鏈的parallel迫在眉睫。

重生

在 RxJava 2.0.5 版本,parallel終於浴火重生。而這次重生後的parallel不再寄託於Flowable,而是自立門戶,通過獨立的ParallelFlowable來實現。

public abstract class ParallelFlowable<T> {
    public abstract void subscribe(@NonNull Subscriber<? super T>[] subscribers);
    public abstract int parallelism();
}
複製程式碼

從類的定義可以看出,這個物件的訂閱者是Subscriber陣列,且陣列的長度必須嚴格等於parallelism()返回值。由於subscribe介面的變化,併發的操作符編寫就簡單很多。

ParallelFlowable也類似Flowable內建了一些操作符,雖然數量有限,但是非常實用,且可以與Flowable無縫轉換。

操作符

Parallel

Flowable中, 可以通過Parallel操作符將Flowable物件轉變成ParallelFlowable物件:

public final ParallelFlowable<T> parallel(int parallelism) {
    return ParallelFlowable.from(this, parallelism);
}
複製程式碼

從一個Flowable轉變成ParallelFlowable並沒有執行緒相關的操作,從引數也可看出,並無Scheduler的參與。資料流的轉換也非常簡單:

Parallel

可見Parallel僅僅是將原本應該分發至一個Subscriber的資料流拆分開,“雨露均沾”了而已。

但是轉變成ParallelFlowable後,由於多個Subscriber的存在,併發就非常的簡單了,我們只需要提供一個執行緒操作符即可:

RunOn

RunOnParallelFlowable就像ObserveOnFlowable

public final ParallelFlowable<T> runOn(@NonNull Scheduler scheduler, int prefetch) {
    return new ParallelRunOn<T>(this, scheduler, prefetch);
}

public final Flowable<T> observeOn(Scheduler scheduler, boolean delayError, int bufferSize) {
    return new FlowableObserveOn<T>(this, scheduler, delayError, bufferSize);
}
複製程式碼

這兩者引數幾乎一致,唯一不同的是ObserveOn額外提供了一個delayError的引數。

他們的效果是非常相似的,都是對下游的onNext / onComplete / onError排程執行緒。不過RunOn對於下游的每個Subscriber都會獨立建立一個Worker來排程:

RunOn

因此多個Subscriber是可能併發的,這取決於選擇的Scheduler。我們在前文中強調過,每個Worker建立的任務僅與該Worker相關聯,但是這並不意味著每個Worker對應一個執行緒,不同的Scheduler的實現建立的Worker效果大相徑庭,更多細節可檢視《深入理解 RxJava2:Scheduler(2)》

Sequential

顧名思義,該操作符就是重新把ParallelFlowable轉回Flowable,但是資料是迴圈發射的,不保證遵循資料原始的發射順序:

Sequential

其他

以上三個操作符是最核心也是最常用的,除此之外,ParallelFlowable還有諸多操作符,效果與Flowable中類似,部分可根據實際情況與runOn結合使用,以達到最佳效果。

  • Map
  • Filter
  • FlatMap
  • doOnXXX / doAfterXXX
  • reduce
  • sorted
  • ...

對比

GroupBy 與 Parallel

上面我們舉例了通過groupByflatMap組合實現的併發效果。事實上,除了從感官上更加好用外,parallel的併發效果也是最好的。

Benchmark

在 GitHub RxJava 的倉庫中,其實已經內建了基於 OpenJDK JMH 的 Benchmark 的程式碼,均在 src/jmh 目錄中,對 JMH 不熟悉的同學可以自行去了解。

我們這裡對併發的效能做一次測試,使用倉庫中的ParallelPerf類即可,筆者機器的配置是 3 GHz Intel Core i7 4 核 + 16 GB 1600 MHz DDR3,效果如下:

Benchmark

我這裡解釋一下引數的含義:

  • Count:資料來源數目
  • Compute: 可以認為是 CPU 耗時的單位,隨著數值增大而接近線性增長
  • Parallelism:併發數目,這裡可以近似地認為是執行緒數目

另外圖表中表頭帶 error 的字樣是表示 99.9% 的置信區間,如 第一行的 GroupBy 置信區間為:[1539.814 - 41.88, 1539.814 + 41.88]。

根據圖中的結果,可見在Compute較小的情況下,parallelgroupBy是有著絕對的優勢的,說明parallel的效能損耗較小。

Compute較大時,操作符內部的效能損耗相對全域性的影響較小,因此這兩者效能則差不多。

SchedulerMultiWorkerSupport

不僅如此,runOn操作符在建立Worker時,有特別的優化:

public interface SchedulerMultiWorkerSupport {
    void createWorkers(int number, @NonNull WorkerCallback callback);

    interface WorkerCallback {
        void onWorker(int index, @NonNull Scheduler.Worker worker);
    }
}
複製程式碼

Scheduler通過實現這個介面,能夠針對一次建立多個Worker的情況做優化,目前僅ComputationScheduler支援。具體的原始碼不列出來了,優化後實際的效果就是儘可能的平均了執行緒和Worker的負載。

換言之,如果我們使用groupBy做併發時,對應的分組後的Flowable可能由於其他的操作符也在使用ComputationScheduler導致分下去的Worker對應的執行緒可能有重合和遺漏。

舉個例子,請看下面的程式碼:

Flowable.just(1, 2)
        .groupBy(new Function<Integer, Integer>() {
            @Override
            public Integer apply(Integer v) throws Exception {
                return v % 2;
            }
        })
        .subscribeOn(Schedulers.io())
        .flatMap(new Function<GroupedFlowable<Integer, Integer>, Publisher<Integer>>() {
            @Override
            public Publisher<Integer> apply(GroupedFlowable<Integer, Integer> g) throws Exception {
                Publisher<Integer> it = g.observeOn(Schedulers.computation()).doOnNext(i -> {
                    System.out.println(Thread.currentThread().getName());
                });
                Thread.sleep(1000);
                return it;
            }
        })
        .subscribe();

輸出:
RxComputationThreadPool-1
RxComputationThreadPool-2
複製程式碼

以上的結果是符合我們期望的,資料根據模 2 的剩餘類劃分了兩組,每組的資料的分發在不同的執行緒中,但是我們在上面的程式碼後面追加以下的程式碼執行:

...
Thread.sleep(1500);
int core = Runtime.getRuntime().availableProcessors();
for (int i = 0; i < core - 1; i++) {
    scheduler.createWorker();
}

輸出:
RxComputationThreadPool-1
RxComputationThreadPool-1
複製程式碼

為什麼發生這樣的情況呢,首先我們在每個資料來源observeOn後,休眠一秒,隨後這個Flowable會被立即訂閱,觸發createWorker,我們下面的程式碼休眠了 1.5 秒,即處於第一個Flowable被訂閱後觸發了createWorker,第二個Flowable尚未被訂閱時,我們又分配了core - 1個的Worker,因此groupBy分配的下個Worker的執行緒又和第一個分配的相同了。注意這裡我們說的是依賴的執行緒相同,但是每個Worker物件都是獨立的,具體原因在上面連結的系列第二篇中詳細講述過。

而在parallelWorker是連續分配的,因此不受這種情況的干擾,有興趣的讀者們可以自己嘗試一番。

結語

Parallel 在改版後,確實是 RxJava2 中併發的不二選擇。配合內建的操作符能夠讓大家收放自如,不再受併發的困擾。

深入理解 RxJava2 系列持續連載中,歡迎關注筆者公眾號隨時獲取更新。

深入理解 RxJava2:論 Parallel 與併發(5)

相關文章