JDK 23 功能預覽:JEP 480 結構化並行

JEP 480: Structured Concurrency

在現代軟體開發中,並行程式設計一直是項既重要又棘手的議題。隨著虛擬執行緒的引入,Java 平台在處理大量多執行緒任務時變得更加強大。然而,管理這些執行緒的生命週期和錯誤處理仍然是一項艱難的挑戰。

Java 平台透過 JDK 23JEP 480 提出了結構化並行(Structured Concurrency),試圖為這個問題提供優雅的解決方案。新的程式設計模型讓開發者能夠以更直觀、更可靠的方式來組織和管理並行任務,特別適合需要處理多個 I/O 操作的應用程式。

前言

我們通常會將任務拆分成多個小的子任務來管理複雜的工作流程。在普通的單執行程式中,子任務按照順序執行。如果子任務彼此之間足夠獨立,並且有足夠的硬體資源,那麼我們可以同時執行子任務使整體任務運行得更快。例如,如果每個 I/O 操作都在自己的執行緒中並行,那麼組合多個 I/O 操作結果的任務將運行得更快。虛擬執行緒(JEP 444)為此類 I/O 操作帶來了顯著效益,但管理大量執行緒仍然是一項挑戰。

在 Java 並行程式設計中,開發者經常使用 Java 5 中引入的 java.util.concurrent.ExecutorService API 來管理並行任務。然而,這種方式存在著一些根本性的問題。

當一個任務被分解為多個子任務時,這些子任務的生命週期管理變得相當複雜,特別是在處理錯誤和取消操作時。如果其中一個子任務失敗,其他正在執行的子任務會繼續運行而造成資源浪費。更糟糕的是,這可能導致執行緒洩漏(Thread Leak),並影響系統的整體效能和穩定性。

使用 ExecutorService 的非結構化並行

假設有個方法 handle() 表示伺服器應用程式中的一項任務,它通過向 ExecutorService 提交兩個子任務來處理傳入的請求。一個子任務執行方法 findUser(),另一個子任務執行方法 fetchOrder()ExecutorService 立即為每個子任務返回一個 Future,然後根據 Executor 的調度策略並行處理子任務。 handle() 方法透過阻塞呼叫其 futureget() 方法來等待子任務的結果,因此該任務被稱為「加入」其子任務。

Response handle() throws ExecutionException, InterruptedException {
    Future<String>  user  = esvc.submit(() -> findUser());
    Future<Integer> order = esvc.submit(() -> fetchOrder());
    String theUser  = user.get();   // Join findUser
    int    theOrder = order.get();  // Join fetchOrder
    return new Response(theUser, theOrder);
}

由於子任務是並行的,每個子任務都可以獨立地成功或失敗(意味著拋出一個異常)。通常,像 handle() 這樣的任務在其任何子任務失敗時都應該失敗。然而,當失敗發生時,執行緒的生命週期可能會變得出乎意料地複雜難懂:

  • 如果 findUser() 拋出一個異常,那麼 handle() 在調用 user.get() 時將拋出一個異常,但 fetchOrder() 將繼續在其自己的執行緒中運行。這是一個執行緒洩漏,不但浪費資源也同時干擾其他任務。
  • 如果執行 handle() 的執行緒被中斷,子任務不會被取消執行。 findUser()fetchOrder() 執行緒都會洩漏,即使在 handle() 失敗後仍會繼續運行。
  • 如果 findUser() 需要很長時間才能執行完成,但 fetchOrder() 已經失敗,那麼 handle() 將透過阻塞 user.get() 來不必要地等待 findUser(),而不是取消它。只有在 findUser() 完成並且 user.get() 返回後,order.get() 才會拋出異常,導致 handle() 失敗。

為什麼我們需要結構化並行?

上述情況不僅讓我們在錯誤發生時不易查覺,而且也使得診斷和排除此類錯誤變得非常困難。例如,可觀察性工具(如執行緒傾印 thread dump)將在不相關的執行緒呼叫堆疊上顯示 handle()findUser()fetchOrder(),而不會有任何任務/子任務之間關係的提示說明。

我們也許可以嘗試在發生錯誤時通過顯式取消其他子任務,例如,用 try-finally 包裝任務,並在失敗任務時的 catch 區塊中呼叫其他任務 futurecancel(boolean) 方法。但要完美地做到這一切可能會非常麻煩,而且它常常會使程式碼的邏輯變得更加難以辨讀。追踪任務之間的關係,並手動添加所需的任務取消條件,對開發人員來說要求很高。

此外,在除錯和監控方面,傳統的方法也面臨挑戰。由於缺乏明確的任務階層關係,開發者難以追蹤和理解執行中的任務狀態,這使得系統維護和問題診斷變得更加困難。

JEP 480 概觀

結構化並行是一種並行程式設計方法,它保留了任務和子任務之間的自然關係,從而產生更具可讀性、可維護性和可靠性的並行程式碼。藉由引入結構化並行處理的 API,我們可以將一組相關任務(運行在不同執行緒上)視為單一工作單元,並簡化錯誤處理和取消操作、提升可靠性,並加強可觀察性。本 API 提供的並行編程風格,可以消除因取消和關閉而產生的常見風險,如執行緒洩漏和取消延遲。

  • 簡化並行程式設計,特別是在使用虛擬執行緒時
  • 提供一種結構化、可預測且易於推理的並行程式模型
  • 引入 StructuredTaskScope 類別作為結構化並行任務的執行環境,以允許開發者將一個任務發展成一組並行的子任務且同時能彼此協調:
    • fork() 方法:創建和啟動子任務,並返回 Future 物件代表子任務的結果
    • join() 方法:等待所有子任務完成,並處理任何異常
    • 支援自動取消子任務:如果父任務被取消,則所有子任務也會被自動取消
    • 確保子任務在其父任務完成之前完成,並集中處理異常
  • 支援範圍值(JEP 481 Scoped Values)的繼承:子任務可以繼承父任務的範圍值綁定,從而簡化資料共享

優點

  • 簡化的並行程式設計提供了更高級別的抽象層,使開發人員能夠更輕鬆地編寫和管理並行任務
  • 透過明確的作用域與清晰的任務層次結構,使程式碼更易於理解和維護
  • 自動管理子任務的生命週期並確保子任務在其父任務完成之前完成,加上集中化的異常處理機制,減少了錯誤和資源洩漏的可能性
  • 在使用虛擬執行緒時可以有效地管理大量並行任務,提高系統的吞吐量和資源利用率

缺點

  • 目前 StructuredTaskScope 僅支援虛擬執行緒,對於平台執行緒(platform threads)的支援仍尚在探索中
  • 對於不熟悉結構化並行概念的開發人員來說,可能需要一定的學習成本

解析結構化並行

結構化並行的核心是 java.util.concurrent 套件中的 StructuredTaskScope 類別,它提供了一個受控的環境來管理並行任務。子任務透過個別「fork」(分叉)的方式在自己的執行緒中執行,然後作為一個單元「join」(連接)在一起,並且可能作為一個單元被取消。子任務的成功結果或異常會被彙總,並由父任務處理。StructuredTaskScope 將子任務的生命週期限制在一個明確的詞法作用域(lexical scope)內,期間父任務與其子任務的所有交互都會發生,包括分叉、連接、取消、處理錯誤和組合結果等等。

以下是一個實際的範例:

Response handle() throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<String> user = scope.fork(() -> findUser());
        Supplier<Integer> order = scope.fork(() -> fetchOrder());

        scope.join()  // 加入子任務
             .throwIfFailed();  // 並設定傳播錯誤

        return new Response(user.get(), order.get());  // 當子任務都成功時,組合其結果
    }
}

與前面的範例相比,可以很容易地理解這邊的執行緒生命週期:在所有條件下,它們的生命週期都被限制在一個詞法範圍內,也就是 try-with-resources 語句的主體。資源可以正確釋放,也能集中處理錯誤狀況。此外,StructuredTaskScope 的使用確保了許多有價值的特性:

  • 錯誤處理與短路:如果 findUser()fetchOrder() 子任務失敗,而另一個子任務如果尚未完成的話,則會被取消(由 ShutdownOnFailure 實現的關閉策略所管理)
  • 取消傳播:如果運行 handle() 的執行緒在調用 join() 之前或期間被中斷,則當執行緒退出作用域時,兩個子任務都會自動取消
  • 清晰的結構:程式碼中設置好子任務、等待它們完成或被取消、然後決定是成功(並處理已完成的子任務結果)或失敗(子任務已完成,所以沒有什麼需要清理的了)
  • 可觀察性:執行緒傾印可以清楚地顯示任務層次結構,運行 findUser()fetchOrder() 的執行緒顯示為作用域的子項。

結語

JEP 480 引入結構化並行,為 Java 開發者提供了一個更現代、更可靠的並行程式設計模型。它簡化了並行任務的撰寫與管理方式,不僅提高了程式碼的可讀性和可維護性,更重要的是它提供了更好的錯誤處理機制和執行緒生命週期管理。

隨著虛擬執行緒的普及,結構化並行將成為 Java 平台上開發高效能、高可靠性應用程式的重要工具。雖然這需要開發者投入時間學習新的概念和做法,但這項投資絕對值得,因為它能大幅提升程式的可維護性和可靠性。

本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

1 × four =

返回頂端