JAVA語言系列:組合式異步編程


1. 導論

同步API和異步API:同步/異步關注的是消息通知的機制。

  • 同步:調用了某個方法,調用方在被調用方運行的過程中會等待,被調用方運行結束返回,調用方取得被調用方的返回值并繼續運行。
  • 異步:過程調用發出后,調用者在沒有得到結果之前,就可以繼續執行后續操作。返回的方式:
  1. 輪詢:即監聽被調用者的狀態,調用者需要每隔一定時間檢查一次,效率會很低。
  2. 回調:當被調用者執行完成后,會調用調用者提供的回調函數。

阻塞和非阻塞:阻塞和非阻塞關注的是程序在等待調用結果(消息,返回值)時的狀態、

  • 阻塞調用是指調用結果返回之前,當前線程會被掛起。調用線程只有在得到結果之后才會返回。
  • 非阻塞調用指在不能立刻得到結果之前,該調用不會阻塞當前線程。

并行和并發

  • 并行是多個cpu,一個CPU執行一個線程時,另一個CPU可以執行另一個線程,兩個線程互不搶占CPU資源。
  • 并發是個程序在同一CPU上運行,同一時刻只能有一個程序在CPU上運行。

異步和多線程:異步是目的,而多線程是實現異步的一個手段。多線程需要考慮線程上下文切換帶來的負擔,并需要考慮死鎖的可能。


2. Future 接口:

目的:實現異步計算:把調用線程從潛在耗時的操作中解放出來,讓它能繼續執行其他工作,不再需要等待耗時操作完成。
原理:返回一個執行運算結果的引用,當運算結束后,這個引用被返回給調用方。
使用

  • 只需要將耗時的操作封裝在一個Callable對象中,再將它提交給ExecutorService,讓線程可以在ExecutorService以并發方式調用另一個線程執行耗時操作的同時,去執行一些其他的任務。
  • 如果你已經運行到沒有異步操作的結果就無法繼續任何有意義的工作時,可以調用它的get方法去獲取操作的結果。如果操作已經完成,該方法會立刻返回操作的結果,否則它會阻塞你的線程,直到操作完成。
  ExecutorService executor = Executors.newCachedThreadPool();
  Future<Double> future = executor.submit(new Callable<Double>() {
        public Double call() {
        return doSomeLongComputation();
  }});
  doSomethingElse();
  try { 
    // 獲取異步操作的結果,如果最終被阻塞,無法得到結果,那么在最多等待1秒鐘之后退出
      Double result = future.get(1, TimeUnit.SECONDS);
  } catch (ExecutionException ee) {
    // 計算拋出一個異常
  } catch (InterruptedException ie) {
    // 當前線程在等待過程中被中斷
  } catch (TimeoutException te) {
    // 在Future對象完成之前超過已過期
  }

局限性:很難表述Future結果之間的依賴性,如:

  • 將兩個異步計算合并為一個——這兩個異步計算之間相互獨立,同時第二個又依賴于第一個的結果。
  • 等待Future集合中的所有任務都完成。
  • 僅等待Future集合中最快結束的任務完成。
  • 通過編程方式完成一個Future任務的執行(即以手工設定異步操作結果的方式,手工結束)。
  • 應對Future的完成事件(即當Future的完成事件發生時會收到通知,并能使用Future計算的結果進行下一步的操作,不只是簡單地阻塞等待操作的結果)。

3. CompletableFuture:Java 8 提供的Future 實現

使用CompletableFuture實現異步方法: 將一個耗時的產品價格查詢異步化

public Future<Double> getPriceAsync(String product) {
  // 創建CompletableFuture對象,它會包含計算的結果
  CompletableFuture<Double> futurePrice = new CompletableFuture<>();
  new Thread( () -> {
    // 假設calculatePrice是一個耗時任務
    double price = calculatePrice(product);
    // 需長時間計算的任務結束并得出結果時,設置Future的返回值
    futurePrice.complete(price);
  }).start();
  return futurePrice;
}

上述代碼問題:異常被限制,如果異步執行計算過程中產生了錯誤,用于提示錯誤的異常會被限制在試圖計算商品價格的當前線程的范圍內,最終會殺死該線程,而這會導致等待get方法返回結果的客戶端永久地被阻塞。

解決

  • 使用重載版本的get方法,它使用一個超時參數來避免發生這樣的情況。
  • 使用CompletableFuture的completeExceptionally方法將導致CompletableFuture內發生問題的異常拋出。
public Future<Double> getPriceAsync(String product) {
  CompletableFuture<Double> futurePrice = new CompletableFuture<>();
  new Thread( () -> {
  try {
    double price = calculatePrice(product);
    futurePrice.complete(price);
  } catch (Exception ex) {
  futurePrice.completeExceptionally(ex);
  }
  }).start();
  return futurePrice;
}

更加簡潔地使用:使用工廠方法supplyAsync創建CompletableFuture

public Future<Double> getPriceAsync(String product) {
  return CompletableFuture.supplyAsync(() -> calculatePrice(product));
}
  • supplyAsync方法接受一個生產者(Supplier)作為參數,返回一個CompletableFuture對象,該對象完成異步執行后會讀取調用生產者方法的返回值。
  • 生產者方法會交由ForkJoinPool池中的某個執行線程(Executor)運行,但是你也可以使用supplyAsync方法的重載版本,傳遞第二個參數指定不同的執行線程執行生產者方法。
  • 自動提供了錯誤管理機制。

4. 多個異步任務

需求:來自4個商店的指定產品的異步查詢,返回4個商店的產品價格List<String> findPrices(String product); CPU為4個線程

實現一:使用順序流,查詢耗時大概為4*delay

public List<String> findPrices(String product) {
  return shops.stream()
    .map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
    .collect(toList());
}

實現二:使用并行流,查詢耗時為 delay

public List<String> findPrices(String product) {
  return shops.parallelStream()
    .map(shop -> String.format("%s price is %.2f",shop.getName(), shop.getPrice(product)))
    .collect(toList());
}

實現三:使用CompletableFuture。查詢耗時為 2* delay。join方法類似于get,但不會拋出檢測到的異常。注意這里這里使用了兩個不同的Stream流水線,而不是在同一個處理流的流水線上一個接一個地放置兩個map操作——這其實是有緣由的。考慮流操作之間的延遲特性,如果你在單一流水線中處理流,發向不同商家的請求只能以同步、順序執行的方式才會成功。因此,每個創建CompletableFuture對象只能在前一個操作結束之后執行查詢指定商家的動作、通知join方法返回計算結果。先將CompletableFutures對象聚集到一個列表中,讓對象們可以在等待其他對象完成操作之前就能啟動。

public List<String> findPrices(String product) {
  List<CompletableFuture<String>> priceFutures =
    shops.stream()
    .map(shop -> CompletableFuture.supplyAsync(() -> shop.getName() + " price is " + shop.getPrice(product)))
    .collect(Collectors.toList());
  return priceFutures.stream().map(CompletableFuture::join).collect(toList());
}

總結

  • 順序流隨著商店的增加,耗時線性增長。
  • 并行流能夠并行執行4個任務,充分利用了cpu,但是再增加一個任務時,由于cpu全部被占用,最后一個任務只能等到前面某一個操作完成釋放出空閑線程才能繼續,因此需要 2 *delay。Stream底層依賴的是線程數量固定的通用線程池,因此在數目增多時擴展性不好。
  • CompletableFuture有一個線程用于主線程,三個用于異步執行,因此需要2 *delay
  • 如果是9個商店,則并行流耗時3 * delay,CompletableFuture也是耗時 3*delay
  • 并行流和CompletableFuture采用的是同樣的通用線程池,默認都使用固定數目的線程,具體線程數取決于Runtime.getRuntime().availableProcessors()的返回值。
  • CompletableFuture具有一定的優勢,允許你對執行器(Executor)進行配置,尤其是線程池的大小。

改進CompletableFuture:使用定制的執行器。

《JAVA并發編程實戰》建議:
線程池大小與處理器的利用率之比可以使用下面的公式進行估算:
Nthreads = NCPU * UCPU * (1 + W/C)
?NCPU是處理器的核的數目,可以通過Runtime.getRuntime().availableProcessors()得到
?UCPU是期望的CPU利用率(該值應該介于0和1之間)
?W/C是等待時間與計算時間的比率

private final Executor executor = Executors.newFixedThreadPool(Math.min(shops.size(), 100),
  new ThreadFactory() {
    public Thread newThread(Runnable r) {
      Thread t = new Thread(r);
      t.setDaemon(true);//使用守護線程——這種方式不會阻止程序的關停
      return t;
  }
});
// 傳遞執行器
CompletableFuture.supplyAsync(() -> shop.getName() + " price is " + shop.getPrice(product), executor);

并行流還是CompletableFuture

  • CompletableFuture提供了更多的靈活性,你可以調整線程池的大小,而這能幫助你確保整體的計算不會因為線程都在等待I/O而發生阻塞。
  • 如果進行的是計算密集型的操作,并且沒有I/O,那么推薦使用Stream接口,因為實現簡單,同時效率也可能是最高的(如果所有的線程都是計算密集型的,那就沒有必要創建比處理器核數更多的線程)。
  • 如果并行的工作單元還涉及等待I/O的操作(包括網絡連接等待),那么使用CompletableFuture靈活性更好,可以依據等待/計算,或者W/C的比率設定需要使用的線程數。另一個原因是:處理流的流水線中如果發生I/O等待,流的延遲特性會讓我們很難判斷到底什么時候觸發了等待。

5. 多個異步任務的流水線操作

兩個相關的異步任務的組合

需求:假設需要在獲取到產品價格后還需要進一步獲取折扣價格,這是兩個異步的遠程任務,如何組合?

實現一:流水線方式,耗時隨著商店增多線性增長。

public List<String> findPrices(String product) {
  return shops.stream()
    .map(shop -> shop.getPrice(product))  //延遲1秒
    .map(Quote::parse)
    .map(Discount::applyDiscount)  //延遲1秒
    .collect(toList());
}

實現二:同步組合和異步組合

public List<String> findPrices(String product) {
  List<CompletableFuture<String>> priceFutures =
  shops.stream()
  .map(shop -> CompletableFuture.supplyAsync(() -> shop.getPrice(product), executor))
  .map(future -> future.thenApply(Quote::parse))
  .map(future -> future.thenCompose(quote ->CompletableFuture.supplyAsync(() ->  Discount.applyDiscount(quote), executor)))
  .collect(toList());
  return priceFutures.stream().map(CompletableFuture::join).collect(toList());
}

thenApply方法:由于這里不涉及IO操作,因此采用同步執行。thenApply方法用于對第一步中CompletableFuture連接同步方法。這意味著CompletableFuture最終結束運行時,希望傳遞Lambda表達式給thenApply方法,將Stream中的每個CompletableFuture<String>對象轉換為對應的CompletableFuture<Quote>對象。
thenCompose方法:這里涉及到遠程操作,因此希望能夠異步執行。thenCompose方法允許對兩個異步操作進行流水線,第一個操作完成時,將其結果作為參數傳遞給第二個操作。意味著可以創建兩個CompletableFutures對象,對第一個CompletableFuture 對象調用thenCompose , 并向其傳遞一個函數。當第一個CompletableFuture執行完畢后,它的結果將作為該函數的參數,這個函數的返回值是以第一個CompletableFuture的返回做輸入計算出的第二個CompletableFuture對象。
thenComposeAsync方法:以Async結尾的方法會將后續的任務提交到一個線程池,所以每個任務是由不同的線程處理的。不帶Async的方法和它的前一個任務一樣,在同一個線程中運行。

兩個不相關的異步任務的組合

需求:不希望等到第一個任務完全結束才開始第二項任務。
實現:合并兩個獨立的CompletableFuture對象

Future<Double> futurePriceInUSD =
  CompletableFuture.supplyAsync(() -> shop.getPrice(product)).thenCombine(
    CompletableFuture.supplyAsync(() -> exchangeService.getRate(Money.EUR, Money.USD)),
    (price, rate) -> price * rate
);

thenCombine方法:接收名為BiFunction的第二參數,這個參數定義了當兩個CompletableFuture對象完成計算后,結果如何合并。
thenCombineAsync會導致BiFunction中定義的合并操作被提交到線程池中,由另一個任務以異步的方式執行。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容