某個網站的資料來自Facebook、Twitter和Google,這就需要網站與網際網路上的多個Web服務通訊。可是,你並不希望因為等待某些服務的響應,阻塞應用程式的執行,浪費數十億寶貴的CPU時鐘週期。比如,不要因為等待Facebook的資料,暫停對來自Twitter的資料處理。
第7章中介紹的分支/合併框架以及並行流是實現並行處理的寶貴工具;它們將一個操作切分為多個子操作,在多個不同的核、CPU甚至是機器上並行地執行這些子操作。與此相反,如果你的意圖是實現併發,而非並行,或者你的主要目標是在同一個CPU上執行幾個鬆耦合的任務,充分利用CPU的核,讓其足夠忙碌,從而最大化程式的吞吐量,那麼你其實真正想做的是避免因為等待遠端服務的返回,或者對資料庫的查詢,而阻塞執行緒的執行,浪費寶貴的計算資源,因為這種等待的時間很可能相當長。
1. Future介面
Future介面在Java 5中被引入,設計初衷是對將來某個時刻會發生的結果進行建模。它建模了一種非同步計算,返回一個執行運算結果的引用,當運算結束後,這個引用被返回給呼叫方。在Future中觸發那些潛在耗時的操作把呼叫執行緒解放出來,讓它能繼續執行其他有價值的工作,不再需要等待耗時的操作完成。Future的另一個優點是它比更底層的Thread更易用。要使用Future,通常你只需要將耗時的操作封裝在一個Callable物件中,再將它提交給ExecutorService。使用Future以非同步的方式執行一個耗時的操作:
執行緒可以在ExecutorService以併發方式呼叫另一個執行緒執行耗時操作的同時,去執行一些其他的任務。接著,如果你已經執行到沒有非同步操作的結果就無法繼續任何有意義的工作時,可以呼叫它的get方法去獲取操作的結果。如果操作已經完成,該方法會立刻返回操作的結果,否則它會阻塞你的執行緒,直到操作完成,返回相應的結果。如果該長時間執行的操作永遠不返回了會怎樣?Future提供了一個無需任何引數的get方法,推薦使用過載版本的get方法,它接受一個超時的引數,可以定義執行緒等待Future結果的最長時間,避免無休止的等待。下圖是Future非同步執行執行緒原理圖。
2. 使用CompletableFuture構建非同步應用
Future介面有一定的侷限性,比如,我們很難表述Future結果之間的依賴性。因此我們引入了CompletableFuture。接下來通過一個“最佳價格查詢器“的應用,它會查詢多個線上商店,依據給定的產品或服務找出最低的價格,來展現CompletableFuture實現非同步應用。通過此例你能學到這些:
- 如何編寫非同步API
- 如何讓使用同步API的程式碼變為非阻塞程式碼
- 如何使用流水線將兩個接續的非同步操作合併為一個非同步計算操作
- 如何以響應式的方式處理非同步操作的完成事件
同步API和非同步API:
- 同步API其實只是對傳統方法呼叫的另一種稱呼:你呼叫了某個方法,呼叫方在被呼叫方執行的過程中會等待,被呼叫方執行結束返回,呼叫方取得被呼叫方的返回值並繼續執行。即使呼叫方和被呼叫方在不同的執行緒中執行,呼叫方還是需要等待被呼叫方結束執行,這就是阻塞式呼叫這個名詞的由來。
- 非同步API會直接返回,或者至少在被呼叫方計算完成之前,將它剩餘的計算任務交給另一個執行緒去做,該執行緒和呼叫方是非同步的——這就是非阻塞式呼叫的由來。執行剩餘計算任務的執行緒會將它的計算結果返回給呼叫方。返回的方式要麼是通過回撥函式,要麼是由呼叫方再次執行一個“等待,直到計算完成”的方法呼叫。
2.1 實戰:實現非同步API
2.1.1 同步方法
同步操作中會為等待同步事件完成而等待1s,這種是無法接受的,對於程式體驗來說是非常不好的。
2.1.2 將同步方法轉換為非同步方法
Java 5引入了java.util.concurrent.Future介面表示一個非同步計算(即呼叫執行緒可以繼續執行,不會因為呼叫方法而阻塞)的結果。這意味著Future是一個暫時還不可知值的處理器,這個值在計算完成後,可以通過呼叫它的get方法取得。這種方式下,在進行價格查詢的同時,還能執行一些其他的任務,比如查詢其他商店中商品的價格,不會阻塞在那裡等待第一家商店返回請求的結果。最後,如果所有有意義的工作都已經完成,所有要執行的工作都依賴於商品價格時,再呼叫Future的get方法。執行了這個操作後,要麼獲得Future中封裝的值(如果非同步任務已經完成),要麼發生阻塞,直到該非同步任務完成,期望的值能夠訪問。同時,如果某個商品價格計算髮生異常,會將當前執行緒殺死,從而導致等待get方法返回結果的客戶端永久地被阻塞。客戶端可以使用過載版本的get方法,設定超時引數來避免。為了讓客戶端能瞭解無法提供請求商品價格的原因,你需要使用CompletableFuture的completeExceptionally方法將導致CompletableFuture內發生問題的異常丟擲。
2.1.3 使用工廠方法supplyAsync建立CompletableFuture物件
supplyAsync方法接受一個生產者(Supplier)作為引數,返回一個CompletableFuture物件,該物件完成非同步執行後會讀取呼叫生產者方法的返回值。生產者方法會交由ForkJoinPool池中的某個執行執行緒(Executor)執行,但是你也可以使用supplyAsync方法的過載版本,傳遞第二個引數指定不同的執行執行緒執行生產者方法。
3. 消除程式碼阻塞問題
3.1 順序同步請求
3.2 使用並行流對請求進行並行操作
3.3 使用CompletableFuture發起非同步請求
CompletableFuture版本的程式似乎比並行流版本的程式還快那麼一點兒。但是最後這個版本也不太令人滿意。它們看起來不相伯仲,究其原因都一樣:它們內部採用的是同樣的通用執行緒池,預設都使用固定數目的執行緒,具體執行緒數取決於Runtime.getRuntime().availableProcessors()的返回值。然而,CompletableFuture具有一定的優勢,因為它允許你對執行器(Executor)進行配置,尤其是執行緒池的大小,讓它以更適合應用需求的方式進行配置,滿足程式的要求,而這是並行流API無法提供的。 順序執行和並行執行的原理對比:
圖11-4的上半部分展示了使用單一流水線處理流的過程,我們看到,執行的流程(以虛線標識)是順序的。事實上,新的CompletableFuture物件只有在前一個操作完全結束之後,才能建立。與此相反,圖的下半部分展示瞭如何先將CompletableFutures物件聚集到一個列表中(即圖中以橢圓表示的部分),讓物件們可以在等待其他物件完成操作之前就能啟動。
3.4 使用CompletableFuture發起非同步請求WithExecutor
3.5 呼叫結果:
3.6 並行——使用流還是CompletableFutures
目前為止,你已經知道對集合進行平行計算有兩種方式:要麼將其轉化為並行流,利用map這樣的操作開展工作,要麼列舉出集合中的每一個元素,建立新的執行緒,在CompletableFuture內對其進行操作。後者提供了更多的靈活性,你可以調整執行緒池的大小,而這能幫助你確保整體的計算不會因為執行緒都在等待I/O而發生阻塞。
- 如果你進行的是計算密集型的操作,並且沒有I/O,那麼推薦使用Stream介面,因為實現簡單,同時效率也可能是最高的(如果所有的執行緒都是計算密集型的,那就沒有必要建立比處理器核數更多的執行緒)。
- 如果你並行的工作單元還涉及等待I/O的操作(包括網路連線等待),那麼使用CompletableFuture靈活性更好,你可以像前文討論的那樣,依據等待/計算,或者 W/C的比率設定需要使用的執行緒數。這種情況不使用並行流的另一個原因是,處理流的流水線中如果發生I/O等待,流的延遲性會讓我們很難判斷到底什麼時候觸發了等待。
4. 對多個非同步任務進行流水線操作
4.1 案例
通過在shop構成的流上採用流水線方式執行三次map操作,我們得到了結果。
- 第一個操作將每個shop物件轉換成了一個字串,該字串包含了該 shop中指定商品的價格和折扣程式碼。
- 第二個操作對這些字串進行了解析,在Quote物件中對它們進行轉換。
- 第三個map會操作聯絡遠端的Discount服務,計算出最終的折扣價格,並返回該價格及提供該價格商品的shop。
程式碼如圖:
原理圖:
Java 8的CompletableFuture API提供了名為thenCompose的方法,它就是專門為這一目的而設計的,thenCompose方法允許你對兩個非同步操作進行流水線,第一個操作完成時,將其結果作為引數傳遞給第二個操作。換句話說,你可以建立兩個CompletableFutures物件,對第一個CompletableFuture物件呼叫thenCompose,並向其傳遞一個函式。當第一個 CompletableFuture執行完畢後,它的結果將作為該函式的引數,這個函式的返回值是以第一 個CompletableFuture的返回做輸入計算出的第二個CompletableFuture物件。thenCompose方法像CompletableFuture類中的其他方法一樣,也提供了一個以Async字尾結尾的版本thenComposeAsync。通常而言,名稱中不帶Async的方法和它的前一個任務一樣,在同一個執行緒中執行;而名稱以Async結尾的方法會將後續的任務提交到一個執行緒池,所以每個任務是由不同的執行緒處理的。
4.2 thenCombine方法
將兩個CompletableFuture物件結合起來,無論他們是否存在依賴。thenCombine方法,它接收名為BiFunction的第二引數,這個引數 定義了當兩個CompletableFuture物件完成計算後,結果如何合併。同thenCompose方法一樣, thenCombine方法也提供有一個Async的版本。這裡,如果使用thenCombineAsync會導致BiFunction中定義的合併操作被提交到執行緒池中,由另一個任務以非同步的方式執行。
程式碼圖:
原理圖:
4.3 響應CompletableFuture的completion事件
Java 8的CompletableFuture通過thenAccept方法提供了這一功能,它接收 CompletableFuture執行完畢後的返回值做引數。thenAccept方法也提供 了一個非同步版本,名為thenAcceptAsync。非同步版本的方法會對處理結果的消費者進行排程, 從執行緒池中選擇一個新的執行緒繼續執行,不再由同一個執行緒完成CompletableFuture的所有任 務。因為你想要避免不必要的上下文切換,更重要的是你希望避免在等待執行緒上浪費時間,儘快響應CompletableFuture的completion事件,所以這裡沒有采用非同步版本。
4.3.1 實戰
5. 小結
- 執行比較操作時,尤其是那些依賴一個或多個遠端服務的操作,使用非同步任務可以改善程式的效能,加快程式的響應速度。
- 你應該儘可能地為客戶提供非同步API。使用CompletableFuture類提供的特性,你能夠輕鬆地實現這一目標。
- CompletableFuture類還提供了異常管理的機制,讓你有機會丟擲/管理非同步任務執行中發生的異常。
- 將同步API的呼叫封裝到一個CompletableFuture中,你能夠以非同步的方式使用其結果。
- 如果非同步任務之間相互獨立,或者它們之間某一些的結果是另一些的輸入,你可以將這些非同步任務構造或者合併成一個。
- 你可以為CompletableFuture註冊一個回撥函式,在Future執行完畢或者它們計算的結果可用時,針對性地執行一些程式。
- 你可以決定在什麼時候結束程式的執行,是等待由CompletableFuture物件構成的列表中所有的物件都執行完畢,還是隻要其中任何一個首先完成就中止程式的執行。
Tips
本文同步發表在公眾號,歡迎大家關注!? 後續筆記歡迎關注獲取第一時間更新!