前言
歡迎來到深入理解 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…
與此同時Scheduler
的parallelism
便不再有用了,隨即在 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();
複製程式碼
有些讀者會疑問為什麼要這樣寫,直接用observeOn
與subscribeOn
不行嗎。顯然不行,我們在《深入理解 RxJava2:Scheduler(2)》強調過,每個Worker
的任務都是序列的,因此如果不用flatMap
來生成多個Flowable
,就無法達到並行的效果。
事實上上面的這種寫法吞吐量非常的差,因此我們還需要藉助groupBy
和 flatMap
來配合:
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
排程至一個執行緒來執行。groupBy
與flatMap
的組合,可以任意控制併發數,由於避免了很多無用的損耗,效能較單獨的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
僅僅是將原本應該分發至一個Subscriber
的資料流拆分開,“雨露均沾”了而已。
但是轉變成ParallelFlowable
後,由於多個Subscriber
的存在,併發就非常的簡單了,我們只需要提供一個執行緒操作符即可:
RunOn
RunOn
於ParallelFlowable
就像ObserveOn
於Flowable
:
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
來排程:
因此多個Subscriber
是可能併發的,這取決於選擇的Scheduler
。我們在前文中強調過,每個Worker
建立的任務僅與該Worker
相關聯,但是這並不意味著每個Worker
對應一個執行緒,不同的Scheduler
的實現建立的Worker
效果大相徑庭,更多細節可檢視《深入理解 RxJava2:Scheduler(2)》。
Sequential
顧名思義,該操作符就是重新把ParallelFlowable
轉回Flowable
,但是資料是迴圈發射的,不保證遵循資料原始的發射順序:
其他
以上三個操作符是最核心也是最常用的,除此之外,ParallelFlowable
還有諸多操作符,效果與Flowable
中類似,部分可根據實際情況與runOn
結合使用,以達到最佳效果。
- Map
- Filter
- FlatMap
- doOnXXX / doAfterXXX
- reduce
- sorted
- ...
對比
GroupBy 與 Parallel
上面我們舉例了通過groupBy
與flatMap
組合實現的併發效果。事實上,除了從感官上更加好用外,parallel
的併發效果也是最好的。
Benchmark
在 GitHub RxJava 的倉庫中,其實已經內建了基於 OpenJDK JMH 的 Benchmark 的程式碼,均在 src/jmh 目錄中,對 JMH 不熟悉的同學可以自行去了解。
我們這裡對併發的效能做一次測試,使用倉庫中的ParallelPerf
類即可,筆者機器的配置是 3 GHz Intel Core i7 4 核 + 16 GB 1600 MHz DDR3,效果如下:
我這裡解釋一下引數的含義:
- Count:資料來源數目
- Compute: 可以認為是 CPU 耗時的單位,隨著數值增大而接近線性增長
- Parallelism:併發數目,這裡可以近似地認為是執行緒數目
另外圖表中表頭帶 error 的字樣是表示 99.9% 的置信區間,如 第一行的 GroupBy 置信區間為:[1539.814 - 41.88, 1539.814 + 41.88]。
根據圖中的結果,可見在Compute
較小的情況下,parallel
比groupBy
是有著絕對的優勢的,說明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
物件都是獨立的,具體原因在上面連結的系列第二篇中詳細講述過。
而在parallel
中Worker
是連續分配的,因此不受這種情況的干擾,有興趣的讀者們可以自己嘗試一番。
結語
Parallel 在改版後,確實是 RxJava2 中併發的不二選擇。配合內建的操作符能夠讓大家收放自如,不再受併發的困擾。
深入理解 RxJava2 系列持續連載中,歡迎關注筆者公眾號隨時獲取更新。