計算機程式的思維邏輯 (94) - 組合式非同步程式設計

swiftma發表於2017-08-28

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (94) - 組合式非同步程式設計

前面兩節討論了Java 8中的函式式資料處理,那是對38節55節介紹的容器類的增強,它可以將對集合資料的多個操作以流水線的方式組合在一起。本節繼續討論Java 8的新功能,主要是一個新的類CompletableFuture,它是對65節83節介紹的併發程式設計的增強,它可以方便地將多個有一定依賴關係的非同步任務以流水線的方式組合在一起,大大簡化多非同步任務的開發。

之前介紹了那麼多併發程式設計的內容,還有什麼問題不能解決?CompletableFuture到底能解決什麼問題?與之前介紹的內容有什麼關係?具體如何使用?基本原理是什麼?本節進行詳細討論,我們先來看它要解決的問題。

非同步任務管理

在現代軟體開發中,系統功能越來越複雜,管理複雜度的方法就是分而治之,系統的很多功能可能會被切分為小的服務,對外提供Web API,單獨開發、部署和維護。比如,在一個電商系統中,可能有專門的產品服務、訂單服務、使用者服務、推薦服務、優惠服務、搜尋服務等,在對外具體展示一個頁面時,可能要呼叫多個服務,而多個呼叫之間可能還有一定的依賴,比如,顯示一個產品頁面,需要呼叫產品服務,也可能需要呼叫推薦服務獲取與該產品有關的其他推薦,還可能需要呼叫優惠服務獲取該產品相關的促銷優惠,而為了呼叫優惠服務,可能需要先呼叫使用者服務以獲取使用者的會員級別。

另外,現代軟體經常依賴很多第三方的服務,比如地圖服務、簡訊服務、天氣服務、匯率服務等,在實現一個具體功能時,可能要訪問多個這樣的服務,這些訪問之間可能存在著一定的依賴關係。

為了提高效能,充分利用系統資源,這些對外部服務的呼叫一般都應該是非同步的、儘量併發的。我們在77節介紹過非同步任務執行服務,使用ExecutorService可以方便地提交單個獨立的非同步任務,可以方便地在需要的時候通過Future介面獲取非同步任務的結果,但對於多個尤其是有一定依賴關係的非同步任務,這種支援就不夠了。

於是,就有了CompletableFuture,它是一個具體的類,實現了兩個介面,一個是Future,另一個是CompletionStage,Future表示非同步任務的結果,而CompletionStage字面意思是完成階段,多個CompletionStage可以以流水線的方式組合起來,對於其中一個CompletionStage,它有一個計算任務,但可能需要等待其他一個或多個階段完成才能開始,它完成後,可能會觸發其他階段開始執行。CompletionStage提供了大量方法,使用它們,可以方便地響應任務事件,構建任務流水線,實現組合式非同步程式設計。

具體怎麼使用呢?下面我們會逐步說明,CompletableFuture也是一個Future,我們先來看與Future類似的地方。

與Future/FutureTask對比

基本的任務執行服務

我們先通過示例來簡要回顧下非同步任務執行服務和Future,在非同步任務執行服務中,用Callable或Runnable表示任務,以Callable為例,一個模擬的外部任務為:

private static Random rnd = new Random();

static int delayRandom(int min, int max) {
    int milli = max > min ? rnd.nextInt(max - min) : 0;
    try {
        Thread.sleep(min + milli);
    } catch (InterruptedException e) {
    }
    return milli;
}

static Callable<Integer> externalTask = () -> {
    int time = delayRandom(20, 2000);
    return time;
};
複製程式碼

externalTask表示外部任務,我們使用了Lambda表示式,不熟悉可以參看91節,delayRandom用於模擬延時。

假定有一個非同步任務執行服務,其程式碼為:

private static ExecutorService executor =
        Executors.newFixedThreadPool(10);
複製程式碼

通過任務執行服務呼叫外部服務,一般返回Future,表示非同步結果,示例程式碼為:

public static Future<Integer> callExternalService(){
    return executor.submit(externalTask);
}
複製程式碼

在主程式中,結合非同步任務和本地呼叫的示例程式碼為:

public static void master() {
    // 執行非同步任務
    Future<Integer> asyncRet = callExternalService();

    // 執行其他任務 ...

    // 獲取非同步任務的結果,處理可能的異常
    try {
        Integer ret = asyncRet.get();
        System.out.println(ret);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
}
複製程式碼

基本的CompletableFuture

使用CompletableFuture可以實現類似功能,不過,它不支援使用Callable表示非同步任務,而支援Runnable和Supplier,Supplier替代Callable表示有返回結果的非同步任務,與Callale的區別是,它不能丟擲受檢異常,如果會發生異常,可以丟擲執行時異常。

使用Supplier表示非同步任務,程式碼與Callable類似,替換變數型別即可,即:

static Supplier<Integer> externalTask = () -> {
    int time = delayRandom(20, 2000);
    return time;
};
複製程式碼

使用CompletableFuture呼叫外部服務的程式碼可以為:

public static Future<Integer> callExternalService(){
    return CompletableFuture.supplyAsync(externalTask, executor);
}
複製程式碼

supplyAsync是一個靜態方法,其定義為:

public static <U> CompletableFuture<U> supplyAsync(
    Supplier<U> supplier, Executor executor)
複製程式碼

它接受兩個引數supplier和executor,內部,它使用executor執行supplier表示的任務,返回一個CompletableFuture,呼叫後,任務被非同步執行,這個方法立即返回。

supplyAsync還有一個不帶executor引數的方法:

public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
複製程式碼

沒有executor,任務被誰執行呢?與系統環境和配置有關,一般來說,如果可用的CPU核數大於2,會使用Java 7引入的Fork/Join任務執行服務,即ForkJoinPool.commonPool(),該任務執行服務背後的工作執行緒數一般為CPU核數減1,即Runtime.getRuntime().availableProcessors()-1,否則,會使用ThreadPerTaskExecutor,它會為每個任務建立一個執行緒。

對於CPU密集型的運算任務,使用Fork/Join任務執行服務是合適的,但對於一般的呼叫外部服務的非同步任務,Fork/Join可能是不合適的,因為它的並行度比較低,可能會讓本可以併發的多工序列執行,這時,應該提供Executor引數。

後面我們還會看到很多以Async結尾命名的方法,一般都有兩個版本,一個帶Executor引數,另一個不帶,其含義是相同的,就不再重複介紹了。

對於型別為Runnable的任務,構建CompletableFuture的方法為:

public static CompletableFuture<Void> runAsync(
    Runnable runnable)
public static CompletableFuture<Void> runAsync(
    Runnable runnable, Executor executor)
複製程式碼

它與supplyAsync是類似的,具體就不贅述了。

CompletableFuture對Future的基本增強

Future有的介面,CompletableFuture都是支援的,不過,CompletableFuture還有一些額外的相關方法,比如:

public T join()
public boolean isCompletedExceptionally()
public T getNow(T valueIfAbsent)
複製程式碼

join與get方法類似,也會等待任務結束,但它不會丟擲受檢異常,如果任務異常結束了,join會將異常包裝為執行時異常CompletionException丟擲。

Future有isDone方法檢查任務是否結束了,但不知道任務是正常結束還是異常結束,isCompletedExceptionally方法可以判斷任務是否是異常結束了。

getNow與join類似,區別是,如果任務還沒有結束,它不會等待,而是會返回傳入的引數valueIfAbsent。

進一步理解Future/CompletableFuture

前面例子都使用了任務執行服務,其實,任務執行服務與非同步結果Future不是綁在一起的,可以自己建立執行緒返回非同步結果,為進一步理解,我們看些示例。

使用FutureTask呼叫外部服務,程式碼可以為:

public static Future<Integer> callExternalService() {
    FutureTask<Integer> future = new FutureTask<>(externalTask);
    new Thread() {
        public void run() {
            future.run();
        }
    }.start();
    return future;
}
複製程式碼

內部自己建立了一個執行緒,執行緒呼叫FutureTask的run方法,我們在77節分析過FutureTask的程式碼,run方法會呼叫externalTask的call方法,並儲存結果或碰到的異常,喚醒等待結果的執行緒。

使用CompletableFuture,也可以直接建立執行緒,並返回非同步結果,程式碼可以為:

public static Future<Integer> callExternalService() {
    CompletableFuture<Integer> future = new CompletableFuture<>();
    new Thread() {
        public void run() {
            try {
                future.complete(externalTask.get());
            } catch (Exception e) {
                future.completeExceptionally(e);
            }
        }
    }.start();
    return future;
}
複製程式碼

這裡使用了CompletableFuture的兩個方法:

public boolean complete(T value)
public boolean completeExceptionally(Throwable ex) 
複製程式碼

這兩個方法顯式設定任務的狀態和結果,complete設定任務成功完成,結果為value,completeExceptionally設定任務異常結束,異常為ex。Future介面沒有對應的方法,FutureTask有相關方法但不是public的(是protected)。設定完後,它們都會觸發其他依賴它們的CompletionStage。具體會觸發什麼呢?我們接下來再看。

響應結果或異常

使用Future,我們只能通過get獲取結果,而get可能會需要阻塞等待,而通過CompletionStage,可以註冊回撥函式,當任務完成或異常結束時自動觸發執行,有兩類註冊方法,whenComplete和handle,我們分別來看下。

whenComplete

whenComplete的宣告為:

public CompletableFuture<T> whenComplete(
    BiConsumer<? super T, ? super Throwable> action)
複製程式碼

引數action表示回撥函式,不管前一個階段是正常結束還是異常結束,它都會被呼叫,函式型別是BiConsumer,接受兩個引數,第一個引數是正常結束時的結果值,第二個引數是異常結束時的異常,BiConsumer沒有返回值。whenComplete的返回值還是CompletableFuture,它不會改變原階段的結果,還可以在其上繼續呼叫其他函式。看個簡單的示例:

CompletableFuture.supplyAsync(externalTask).whenComplete((result, ex) -> {
    if (result != null) {
        System.out.println(result);
    }
    if (ex != null) {
        ex.printStackTrace();
    }
}).join();
複製程式碼

result表示前一個階段的結果,ex表示異常,只可能有一個不為null。

whenComplete註冊的函式具體由誰執行呢?一般而言,這要看註冊時任務的狀態,如果註冊時任務還沒有結束,則註冊的函式會由執行任務的執行緒執行,在該執行緒執行完任務後執行註冊的函式,如果註冊時任務已經結束了,則由當前執行緒(即呼叫註冊函式的執行緒)執行。

如果不希望當前執行緒執行,避免可能的同步阻塞,可以使用其他兩個非同步註冊方法:

public CompletableFuture<T> whenCompleteAsync(
    BiConsumer<? super T, ? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(
    BiConsumer<? super T, ? super Throwable> action, Executor executor)       
複製程式碼

與前面介紹的以Async結尾的方法一樣,對第一個方法,註冊函式action會由預設的任務執行服務(即ForkJoinPool.commonPool()或ThreadPerTaskExecutor執行),對第二個方法,會由引數中指定的executor執行。

handle

whenComplete只是註冊回撥函式,不改變結果,它返回了一個CompletableFuture,但這個CompletableFuture的結果與呼叫它的CompletableFuture是一樣的,還有一個類似的註冊方法handle,其宣告為:

public <U> CompletableFuture<U> handle(
    BiFunction<? super T, Throwable, ? extends U> fn)
複製程式碼

回撥函式是一個BiFunction,也是接受兩個引數,一個是正常結果,另一個是異常,但BiFunction有返回值,在handle返回的CompletableFuture中,結果會被BiFunction的返回值替代,即使原來有異常,也會被覆蓋,比如:

String ret =
    CompletableFuture.supplyAsync(()->{
        throw new RuntimeException("test");
    }).handle((result, ex)->{
        return "hello";
    }).join();
System.out.println(ret);
複製程式碼

輸出為"hello"。非同步任務丟擲了異常,但通過handle方法,改變了結果。

與whenComplete類似,handle也有對應的非同步註冊方法handleAsync,具體我們就不探討了。

exceptionally

whenComplete和handle都是既響應正常完成也響應異常,如果只對異常感興趣,可以使用exceptionally,其宣告為:

public CompletableFuture<T> exceptionally(
    Function<Throwable, ? extends T> fn)
複製程式碼

它註冊的回撥函式是Function,接受的引數為異常,返回一個值,與handle類似,它也會改變結果,具體就不舉例了。

除了響應結果和異常,使用CompletableFuture,可以方便地構建有多種依賴關係的任務流,我們先來看簡單的依賴單一階段的情況。

構建依賴單一階段的任務流

thenRun

在一個階段正常完成後,執行下一個任務,看個簡單示例:

Runnable taskA = () -> System.out.println("task A");
Runnable taskB = () -> System.out.println("task B");
Runnable taskC = () -> System.out.println("task C");

CompletableFuture.runAsync(taskA)
    .thenRun(taskB)
    .thenRun(taskC)
    .join();
複製程式碼

這裡,有三個非同步任務taskA, taskB和taskC,通過thenRun自然地描述了它們的依賴關係,thenRun是同步版本,有對應的非同步版本thenRunAsync:

public CompletableFuture<Void> thenRunAsync(Runnable action)
public CompletableFuture<Void> thenRunAsync(Runnable action, Executor executor)
複製程式碼

在thenRun構建的任務流中,只有前一個階段沒有異常結束,下一個階段的任務才會執行,如果前一個階段發生了異常,所有後續階段都不會執行,結果會被設為相同的異常,呼叫join會丟擲執行時異常CompletionException。

thenRun指定的下一個任務型別是Runnable,它不需要前一個階段的結果作為引數,也沒有返回值,所以,在thenRun返回的CompletableFuture中,結果型別為Void,即沒有結果。

thenAccept/thenApply

如果下一個任務需要前一個階段的結果作為引數,可以使用thenAccept或thenApply方法:

public CompletableFuture<Void> thenAccept(
    Consumer<? super T> action)
public <U> CompletableFuture<U> thenApply(
    Function<? super T,? extends U> fn)
複製程式碼

thenAccept的任務型別是Consumer,它接受前一個階段的結果作為引數,沒有返回值。thenApply的任務型別是Function,接受前一個階段的結果作為引數,返回一個新的值,這個值會成為thenApply返回的CompletableFuture的結果值。看個簡單示例:

Supplier<String> taskA = () -> "hello";
Function<String, String> taskB = (t) -> t.toUpperCase();
Consumer<String> taskC = (t) -> System.out.println("consume: " + t);

CompletableFuture.supplyAsync(taskA)
    .thenApply(taskB)
    .thenAccept(taskC)
    .join();
複製程式碼

taskA的結果是"hello",傳遞給了taskB,taskB轉換結果為"HELLO",再把結果給taskC,taskC進行了輸出,所以輸出為:

consume: HELLO
複製程式碼

CompletableFuture中有很多名稱帶有run, accept或apply的方法,它們一般與任務的型別相對應,run與Runnable對應,accept與Consumer對應,apply與Function對應,後續就不贅述了。

thenCompose

與thenApply類似,還有一個方法thenCompose,宣告為:

public <U> CompletableFuture<U> thenCompose(
    Function<? super T, ? extends CompletionStage<U>> fn)
複製程式碼

這個任務型別也是Function,也是接受前一個階段的結果,返回一個新的結果,不過,這個轉換函式fn的返回值型別是CompletionStage,也就是說,它的返回值也是一個階段,如果使用thenApply,結果就會變為CompletableFuture<CompletableFuture<U>>,而使用thenCompose,會直接返回fn返回的CompletionStage,thenCompose與thenApply的區別,就如同Stream API中flatMap與map的區別,看個簡單的示例:

Supplier<String> taskA = () -> "hello";
Function<String, CompletableFuture<String>> taskB = (t) ->
    CompletableFuture.supplyAsync(() -> t.toUpperCase());
Consumer<String> taskC = (t) -> System.out.println("consume: " + t);

CompletableFuture.supplyAsync(taskA)
    .thenCompose(taskB)
    .thenAccept(taskC)
    .join();
複製程式碼

以上程式碼中,taskB是一個轉換函式,但它自己也執行了非同步任務,返回型別也是CompletableFuture,所以使用了thenCompose。

構建依賴兩個階段的任務流

依賴兩個都完成

thenRun, thenAccept, thenApply和thenCompose用於在一個階段完成後執行另一個任務,CompletableFuture還有一些方法用於在兩個階段都完成後執行另一個任務,方法是:

public CompletableFuture<Void> runAfterBoth(
    CompletionStage<?> other, Runnable action
public <U,V> CompletableFuture<V> thenCombine(
    CompletionStage<? extends U> other,
    BiFunction<? super T,? super U,? extends V> fn)
public <U> CompletableFuture<Void> thenAcceptBoth(
    CompletionStage<? extends U> other,
    BiConsumer<? super T, ? super U> action) 
複製程式碼

runAfterBoth對應的任務型別是Runnable,thenCombine對應的任務型別是BiFunction,接受前兩個階段的結果作為引數,返回一個結果,thenAcceptBoth對應的任務型別是BiConsumer,接受前兩個階段的結果作為引數,但不返回結果。它們都有對應的非同步和帶Executor引數的版本,用於指定下一個任務由誰執行,具體就不贅述了。當前階段和引數指定的另一個階段other沒有依賴關係,併發執行,當兩個都執行結束後,開始執行指定的另一個任務。

看個簡單的示例,任務A和B執行結束後,執行任務C合併結果,程式碼為:

Supplier<String> taskA = () -> "taskA";
CompletableFuture<String> taskB = CompletableFuture.supplyAsync(() -> "taskB");
BiFunction<String, String, String> taskC = (a, b) -> a + "," + b;

String ret = CompletableFuture.supplyAsync(taskA)
        .thenCombineAsync(taskB, taskC)
        .join();
System.out.println(ret);
複製程式碼

輸出為:

taskA,taskB
複製程式碼

依賴兩個階段中的一個

前面的方法要求兩個階段都完成後才執行下一個任務,如果只需要其中任意一個階段完成,可以使用下面的方法:

public CompletableFuture<Void> runAfterEither(
    CompletionStage<?> other, Runnable action)

public <U> CompletableFuture<U> applyToEither(
    CompletionStage<? extends T> other, Function<? super T, U> fn)

public CompletableFuture<Void> acceptEither(
    CompletionStage<? extends T> other, Consumer<? super T> action)
複製程式碼

它們都有對應的非同步和帶Executor引數的版本,用於指定下一個任務由誰執行,具體就不贅述了。當前階段和引數指定的另一個階段other沒有依賴關係,併發執行,只要當其中一個執行完了,就會啟動引數指定的另一個任務,具體就不贅述了。

構建依賴多個階段的任務流

如果依賴的階段不止兩個,可以使用如下方法:

public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs)
public static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)
複製程式碼

它們是靜態方法,基於多個CompletableFuture構建了一個新的CompletableFuture。

對於allOf,當所有子CompletableFuture都完成時,它才完成,如果有的CompletableFuture異常結束了,則新的CompletableFuture的結果也是異常,不過,它並不會因為有異常就提前結束,而是會等待所有階段結束,如果有多個階段異常結束,新的CompletableFuture中儲存的異常是最後一個的。新的CompletableFuture會持有異常結果,但不會儲存正常結束的結果,如果需要,可以從每個階段中獲取。看個簡單的示例:

CompletableFuture<String> taskA = CompletableFuture.supplyAsync(() -> {
    delayRandom(100, 1000);
    return "helloA";
}, executor);

CompletableFuture<Void> taskB = CompletableFuture.runAsync(() -> {
    delayRandom(2000, 3000);
}, executor);

CompletableFuture<Void> taskC = CompletableFuture.runAsync(() -> {
    delayRandom(30, 100);
    throw new RuntimeException("task C exception");
}, executor);

CompletableFuture.allOf(taskA, taskB, taskC).whenComplete((result, ex) -> {
    if (ex != null) {
        System.out.println(ex.getMessage());
    }
    if (!taskA.isCompletedExceptionally()) {
        System.out.println("task A " + taskA.join());
    }
});
複製程式碼

taskC會首先異常結束,但新構建的CompletableFuture會等待其他兩個結束,都結束後,可以通過子階段(如taskA)的方法檢查子階段的狀態和結果。

對於anyOf返回的CompletableFuture,當第一個子CompletableFuture完成或異常結束時,它相應地完成或異常結束,結果與第一個結束的子CompletableFuture一樣,具體就不舉例了。

小結

本節介紹了Java 8中的組合式非同步程式設計CompletableFuture:

  • 它是對Future的增強,但可以響應結果或異常事件,有很多方法構建非同步任務流
  • 根據任務由誰執行,一般有三類對應方法,名稱不帶Async的方法由當前執行緒或前一個階段的執行緒執行,帶Async但沒有指定Executor的方法由預設Excecutor執行(ForkJoinPool.commonPool()或ThreadPerTaskExecutor),帶Async且指定Executor引數的方法由指定的Executor執行
  • 根據任務型別,一般也有三類對應方法,名稱帶run的對應Runnable,帶accept的對應Consumer,帶apply的對應Function

使用CompletableFuture,可以簡潔自然地表達多個非同步任務之間的依賴關係和執行流程,大大簡化程式碼,提高可讀性。

下一節,我們探討Java 8對日期和時間API的增強。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…,位於包shuo.laoma.java8.c94下)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (94) - 組合式非同步程式設計

相關文章