RxJava 中的多執行緒

PhxNirvana發表於2017-04-25

RxJava 中的多執行緒

RxJava 中的多執行緒

大多數情況下,我寫的 Android 程式碼都是可以流暢執行的。直到上幾周編寫一個需要讀取和分析大型檔案的 app 之前,我從未關心過 app 執行速度的問題。

儘管我期望使用者明白檔案越大,耗時越長的道理,有時候他們仍會放棄我的應用。他們可能認為應用卡住了,也可能是因為他們就不想等那麼久。所以如果我能把時間縮短至少一半的話,一定會大有裨益的。

第一次嘗試

因為我所有後臺任務都用 RxJava 重寫了,所以繼續用 RxJava 來解決這個問題也是自然而然的。尤其是我還有一些如下所示的程式碼:

List<String> dataList;
//這裡是資料列表

List<DataModel> result = new ArrayList<>();
for (String data : dataList) {
    result.add(DataParser.createData(data));
}複製程式碼

所以我只是想把迴圈的每個操作放到一個後臺執行緒中。如下所示:

List<String> dataList;
//這裡是資料列表

List<Observable<DataModel>> tasks = new ArrayList<>();

for (String data : dataList) {
    tasks.add(Observable.just(data).subscribeOn(Schedulers.io()).map(s -> {
        // 返回一個 DataModel 物件
        return DataParser.createData(s);
    }));
}

List<DataModel> result = new ArrayList<>();

// 等待執行結束並收集結果
for (DataModel dataModel : Observable.merge(tasks).toBlocking().toIterable()) {
    result.add(dataModel);
}複製程式碼

的確起作用了,時間減少了近一半。但也導致大量垃圾回收(GC),這使得載入時的 UI 又卡又慢。為了搞清楚問題的原因,我加了一句 log 列印如下資訊 Thread.currentThread().getName()。 這樣我就搞清楚了,我在處理每一段資料時都新建了執行緒。正如結果所示,建立上千個執行緒並不是什麼好主意。

第二次嘗試

我已經完成了加速資料處理的目標,但執行起來並不那麼流暢。我想知道如果不觸發這麼多 GC 的話還能不能跑得再快點。所以我自己寫了一個執行緒池並指定了最大執行緒數來供 RxJava 呼叫,省的每次處理資料都要建立新執行緒:

List<String> dataList;
//這裡是資料列表

List<Observable<DataModel>> tasks = new ArrayList<>();

// 取得能夠使用的最大執行緒數
int threadCount = Runtime.getRuntime().availableProcessors();
ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(threadCount);
Scheduler scheduler = Schedulers.from(threadPoolExecutor);

for (String data : dataList) {
    tasks.add(Observable.just(data).subscribeOn(scheduler).map(s -> {
        // 返回一個 DataModel 物件
        return DataParser.createData(s);
    }));
}

List<DataModel> result = new ArrayList<>();

// 等待執行結束並收集結果
for (DataModel dataModel : Observable.merge(tasks).toBlocking().toIterable()) {
    result.add(dataModel);
}複製程式碼

對於單個資料都很大的資料集來說,這樣減少了約 10% 的資料處理時間。然而,對於單個資料都很小的資料集就減少了約 30% 的時間。同時也減少了 GC 的呼叫次數,但 GC 還是太頻繁。

第三次嘗試

我有一個新想法——如果效能的瓶頸是頻繁的切換和呼叫執行緒呢?為了克服這個問題,我可以將資料集根據執行緒的數目平均分成總數量相等的子集合,每個子合集丟給一個執行緒處理。這樣雖然是併發執行,但是每個執行緒被呼叫的次數將被降低到最小。我嘗試使用 這裡 的解決方法來實現我的想法:

List<String> dataList;
//這裡是資料列表


// 取得能夠使用的最大執行緒數
int threadCount = Runtime.getRuntime().availableProcessors();
ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(threadCount);
Scheduler scheduler = Schedulers.from(threadPoolExecutor);

AtomicInteger groupIndex = new AtomicInteger();

// 以執行緒數量為依據分組資料,將每組資料放到它們自己的執行緒中
Iterable<List<DataModel>> resultGroups = 
    Observable.from(dataList).groupBy(k -> groupIndex.getAndIncrement() % threadCount)
        .flatMap(group -> group.observeOn(scheduler).toList().map(sublist -> {
            List<DataModel> dataModels = new ArrayList<>();
            for (String data : sublist) {
                dataModels.add(DataParser.createData(data));
            }
            return dataModels;
        })).toBlocking().toIterable();

List<DataModel> result = new ArrayList<>();

// 等待執行結束並收集結果
for (List<DataModel> dataModels : resultGroups) {
    result.addAll(dataModels);
}複製程式碼

上文中我提到用兩類資料集進行測試,一類的資料本身是大檔案,但是資料集裡包含的資料個數很少;另一類資料集裡的每一個資料並不是很大,但是包含資料的總量很多。當我再次測試時,第一組資料幾乎沒差別,而第二組改變相當大。之前幾乎要 20秒,現在只需 5秒。

第二類資料集執行時間改進了如此大的原因,是因為每個執行緒不再處理一個資料(而是處理一個從總體資料集裡拆分下來的小資料集)。之前每一個資料,都需要呼叫一個執行緒來處理。現在我減少了呼叫執行緒的次數,從而提升了效能。

整理

上面的程式碼要執行併發還有一些地方需要修改,所以我整理了程式碼並放到工具類中,使其更具有通用性。

/**
 * 將資料集拆分成子集並指派給規定數量的執行緒,並傳入回撥來進行具體業務邏輯處理。
 * <b>T</b> 是要被處理的資料型別,<b>U</b> 是返回的資料型別
 */
public static <T, U> Iterable<U> parseDataInParallel(List<T> data, Func1<List<T>, U> worker) {
    int threadCount = Runtime.getRuntime().availableProcessors();
    ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(threadCount);
    Scheduler scheduler = Schedulers.from(threadPoolExecutor);

    AtomicInteger groupIndex = new AtomicInteger();

    return Observable.from(data).groupBy(k -> groupIndex.getAndIncrement() % threadCount)
            .flatMap(group -> group.observeOn(scheduler).toList().map(worker)).toBlocking().toIterable();

}



//***EXAMPLE USAGE***
Iterable<List<DataModel>> resultGroups = Util.parseDataInParallel(dataList,
    (sublist) -> {
        List<DataModel> dataModels = new ArrayList<>();
        for (String data : sublist) {
            dataModels.add(DataParser.createData(data));
        }
        return dataModels;
    });

List<DataModel> results = new ArrayList<>();
for (List<DataModel> dataModels : resultGroups) {
    results.addAll(dataModels);
}複製程式碼

這裡 T 是被處理的資料型別,樣例中是DataModel。傳入待處理的 List<T> 並期望結果是 U。在我的樣例中 UList<DataModel>,但它可以是任何東西,並不一定是一個 list。傳入的回撥函式負責資料子列表具體的業務處理並返回結果。

可以再快點麼?

事實上影響執行速度的因素有許多。比如執行緒管理方式,執行緒數,裝置等。大多數因素我無法控制,但總有一些是我沒有考慮到的。

如果每個資料大小不相等會怎麼樣?舉個例子,如果有 4 個執行緒,每個被指派給第 4 執行緒的資料大小是被指派給其他執行緒的十倍會怎麼樣?這時第四個執行緒的耗時就是其他執行緒的大約 10 倍。這種情況下使用多執行緒就不會減少多少時間。我的第二次嘗試基本解決了這個問題,因為執行緒只在需要時才初始化。但這個方法太慢了。

我也試過改變資料分組方式。作為隨意分配的取代,我可以跟蹤每一組資料的總量,然後將資料分配給最少的那組。這樣每個執行緒的工作量就接近平均了。倒黴的是,測試之後發現這樣做增加的時間遠大於它節省的時間。

資料被分配的大小越平均,處理速度就越快。但大多數情況下,隨機分配看起來更快些。理想情況下是每個執行緒一有空就分配任務,同時執行分配所消耗的資源也少,這是最高效的。但我找不到一個足夠高效的可以減少分配瓶頸的方法。

總結

所以如果你想用多執行緒,這是我的建議。如果你有什麼好想法,請務必告訴我。得到一個最優解(如果有的話)總是很難的。以及,用多執行緒並不意味著必須用多執行緒。。

如果有收穫的話,輕輕扎一下小紅心吧老鐵。想閱讀更多,在 Medium 關注我。謝謝!(順便關注一下 譯者 233)

相關文章