迎接 Java 19: 虛擬執行緒與平台執行緒
作業系統無法增加平台執行緒的效率,JDK 切斷平台執行緒與作業系統執行緒的應對更有效率利用平台執行緒
Translated from “Coming to Java 19: Virtual threads and platform threads” by Nicolai Parlog, Java Magazine, May 21, 2022. Copyright Oracle Corporation.
Java 19,Loom (JEP 425) 的虛擬執行緒 (virtual threads) 正式進入預覽階段,現在正是時候仔細看看:排程、記憶體管理、掛載、卸載、捕捉 (capturing)、釘選 (pinning)、可觀測性 (observability),以及有什麼能讓您最佳化擴展性 (scalability)。
在進入虛擬執行緒之前,我要重新檢視傳統執行緒 (classic threads),或從此開始,稱之為平台執行緒 (platform threads)。
JDK 透過一層薄薄的封裝將昂貴的作業系統執行緒實作成平台執行緒,因此您無法擁有太多執行緒。事實上,在耗盡像 CPU 或網路連線等資源之前,執行緒的數量往往成為限制的因素。
換句話說,應用程式的吞吐量常被平台執行緒限制,遠低於硬體能支援的程度。這是虛擬執行序想解決的.
虛擬執行緒
作業系統無法增加平台執行緒的效率,JDK 切斷平台執行緒與作業系統執行緒的應對更有效率利用平台執行緒。
虛擬執行緒是 java.lang.Thread
的實體,它需要作業系統執行緒讓 CPU 完成工作,但在等待其他資源時不會佔用作業系統執行緒。您會發現,當在虛擬執行緒中執行的程式用 JDK API 呼叫一個阻塞式 I/O 操作 [譯註:blocking 確實也有點難翻,目前「阻塞」個人也覺得怪怪的],系統會執行作業系統層的非阻塞式操作,並自動暫停虛擬執行緒直到該操作完成。
在此期間,其他虛擬執行緒能在作業系統執行緒進行運算,他們有效率地共用作業系統執行緒。重要的是,Java 的虛擬執行緒帶來的開銷極小,因此,可以有很多很多很多 [譯註:原文也在玩很重要所以講三次的梗?] 的虛擬執行緒。
就像是作業系統將大的虛擬記憶體空間映射到數量有限的實體記憶體製造出有大量記憶體的假象,JDK 將大量虛擬執行緒映射到少量作業系統執行緒,提供有大量的虛擬執行緒的假象。
就如同程式鮮少在意虛擬或是實體記憶體,Java 的並行程式碼很少需要關心它在虛擬執行緒或是平台執行緒上執行。
您專注在撰寫直觀但潛在可能阻塞的程式碼,運行環境會處理作業系統執行緒的共享,讓阻塞的成本接近於零。
虛擬執行緒支援執行緒區域變數 (thread-local variables)、同步區塊及中斷,因此,使用 Thread
和 currentThread
的程式碼不需調整。實際上,這意味既有的 Java 程式很容易在虛擬執行緒上執行,不需要修改或重新編譯!
當伺服器框架提供為每個請求啟動一個新的虛擬執行緒的選項,您只需升級框架和 JDK,然後轉開開關。
速度、擴展和結構
了解虛擬執行緒是什麼以及不是什麼是很重要的。
千萬別忘記,虛擬執行緒不是更快的執行緒,虛擬執行緒不會神奇地比平台執行緒每秒執行更多指定。
虛擬執行緒真正擅長的是等待。
因為虛擬執行緒不會佔用或阻塞作業系統執行序,數以萬計的虛擬執行緒可以耐心等待檔案系統、資料庫或 Web 服務的請求結束。
透過最大化外部資源的利用率,虛擬執行緒提供更大的規模而非速度,換句話說,虛擬執行緒提高吞吐量。
除了硬數字,虛擬執行緒能改善程式碼品質。
廉價的虛擬執行緒開啟一個新的並行程式設計典範 (concurrent programming paradigm),我已在 Inside Java Newscast #17 探討,稱為結構化並行 [譯註:猶豫很久,曾用過多工這個詞,但會跟 multitasking 混淆,這應該比「平行 (parallel)」好一些]。
現在該來解釋虛擬執行緒如何運作。
排程與記憶體
作業系統執行緒,即平台執行緒,由作業系統排程,但虛擬記憶體由 JDK 負責排程,JDK 間接地將虛擬執行緒指派給程序中的平台執行緒,稱為掛載,JDK 之後解除指派平台執行緒,稱之為卸載。
運行虛擬執行緒的平台執行緒稱為載體執行緒 (carrier thread),虛擬執行緒與其載體執行緒分享一個作業系統執行緒,從 Java 程式的觀點是看不見的。例如,堆疊 (stack traces) 與區域變數 (thread-local variables) 完全分開。
一如往常,載體執行緒仍由作業系統排程,對作業系統來說,載體執行緒只是一個平台執行緒 [譯註:有點撓口]。
為實現這程序,JDK 使用一個專用的 ForkJoinPool
,以先進先出 (FIFO) 模式作為虛擬執行緒的排程器 (注意:這和 parallel streams 使用的共用池是分開的)。
預設情況下,JDK 排程器會使用盡可能和處理器核心一樣多的平台執行緒,但這是可以用系統屬性控制的。
被卸載的虛擬執行緒,它們的堆疊 (stack frames) 到哪去了?它們作為堆疊物件存放到堆積 (heap) 中 [譯註:過去都沒認真思考過 stack 和 heap 該怎麼翻譯,目前用的是維基百科上的版本,但覺得很容易混淆,所以還是把原文放上來]。
某些虛擬執行緒會有較深的呼叫堆疊 (call stacks),例如在網頁框架中一個請求的處理程序,但由他們產生的呼叫堆疊通常較淺,像是讀取檔案的函式。
JDK 在掛載一個虛擬執行緒時,會將堆疊從堆積 (heap) 複製回來,當卸載一個虛擬執行緒時,大部分的堆疊仍保留在堆積中,只有需要時才進行複製。
因此,堆疊隨著應用程式的執行消長,這是讓虛擬執行緒足以便宜到可以有許多實體並頻繁切換的關鍵,且未來有機會更近一步減少記憶體的需求量。
阻塞與卸載
通常,當被 I/O 阻塞 (例如從一個 socket 讀取資料) 或是呼叫另一個會阻塞的函式 (像是 BlockingQueue
的 take
),虛擬執行緒會被卸載。
當阻塞的操作已經完成 (socket 讀取到足夠多的位元能拼湊成元件),它會將虛擬執行緒加回排程器,然後以先進先出的順序,終究會掛載並繼續執行。
然而,儘管先前 JDK 有如 JEP 353 (重新實作 Socket
API) 和 JEP 373 (重新實作 DatagramSocket
API) 等提案增強,但 JDK 不是所有阻塞的操作都會卸載虛擬執行緒,相反,部分會捕捉載體執行緒與其底層的平台執行緒,二者都會被阻塞。
這令人遺憾的行為是由作業系統的限制 (影響許多檔案系統的操作) 或是 JDK 本身 (例如呼叫 Object.wait()
) 導致。
作業系統執行緒的捕捉可由暫時增加一個平台執行緒到排程器中補償,因此,偶而會超出可用的處理器數量 (由系統參數決定的最大值)。
可惜的是,當初虛擬執行緒的提案仍有不完美之處:當一個虛擬執行緒執行原生函式、外部函式、同步 (synchronized) 區間或函式中的程式,虛擬執行緒會被釘選在其載體執行緒上,一個被釘選的執行緒無法在本可以卸載的情況下卸載。
這情況下,不會有額外的平台執行緒被加到排程器中,然而,可以做些事來減輕釘選造成的影響,稍後會詳細介紹。
這意味著捕捉的操作或被釘選的執行會讓平台執行緒重新等待某些事結束,這不會造成應用程式出錯,但會阻礙其擴展性。
慶幸的是,未來的努力可能讓同步 (synchronization) 不再需要訂選執行緒,且 java.io
套件內部的重構以及實作作業系統層級的 API (像是 Linux 上的 io_uring
) 可能減少捕捉的操作。
虛擬執行緒的可觀測性
虛擬執行緒已完整地與既有用於觀測、分析、除錯與優化 Java 應用程式的工具整合,例如,Java Flight Recorder (JFR) 可以在虛擬執行緒啟動、結束、因某些原因無法啟動或是因釘選被阻塞時發出事件。
為了凸顯後者的情況,透過系統參數設定,運行環境可以在一個執行序因釘選被阻塞時印出堆疊軌跡,並凸顯造成釘選的堆疊內容。
因為虛擬執行緒就是單純的執行緒,除錯器 (debugger) 可像平台執行緒那樣步進,當然,除錯器的使用者介面可能需要更新以應付數以萬計的執行緒,不然您會得到一個非常小的捲軸!
虛擬執行緒自然地以階層組織自己,這行為及它們的數量,讓傳統執行緒的平面傾印 (dump) 格式不再適合 (它們仍適合傾印傳統的平台執行緒),jcmd
提供一個新的執行緒傾印,能以純文字和 JSON 格式,將虛擬執行緒與平台執行序並列,已有意義的方式分組呈現。
三個實務建議
清單上的第一個:不要共用虛擬執行緒!
共用昂貴的資源很合理,但虛擬執行緒一點也不昂貴。反之,當您需要多功處裡事情時,建立新的虛擬執行緒。您可能使用執行緒池來限制某些資源的存取,例如資料庫。千萬不要。取而代之,使用號誌 (semaphore) 限制特定數量的執行緒可以存取該資源,如下:
下一個,在使用虛擬執行緒時有更好的可擴展性,避免頻繁或長期釘選執行緒,修改經常執行且包含 (特別是需要長時間) I/O 操作的同步區塊或函式,這情況下,一個好的同步替代方案是使用 ReentrantLock
,如下:
最後,雖然能正確在虛擬執行緒中運作,但為了較佳的擴展性,值得重新檢視的另一個面向是執行緒區域變數 (thread-local variables),包含常規與可繼承。虛擬執行緒能支援與平台執行緒一樣的區域變數行為,但因為虛擬執行緒的量可以很巨大,應在仔細地思考後才使用區域變數。
事實上,作為 Loom 專案的一部分,java.base
模組中移除許多執行緒區域變數的使用,以減少數以萬計的執行緒運行時的記憶體使用量,一個適用某些情境的有趣替代方案的草案正在討論中:JEP for scope-local variables。
[本文章摘自 Nicolai Parlog 的 Inside Java Newscast #23, “Virtual thread deep dive.” — Ed.]
深入探討
想看本文的 TouTube 版可到下方觀看。
另外想嘗試數以萬計的執行緒是什麼感覺,可以參考這個視頻。
譯者的告白:個人非常喜歡這次 Java 提出的 virtual threads,優雅地解決並行與非同步的程式撰寫,JavaScript 的 async/await 已經算是非常簡潔的寫法,但有個令人討厭的地方,只要有一個函式被宣告成 async,它就像是病毒般蔓延到所有呼叫它的子子孫孫。
Java 選擇另一種做法,阻塞就讓它阻塞,沒必要硬轉成非同步的寫法,重點在排程的優化 (這也是教科書中的理想做法),讓 CPU 的運作更有效率,既有的程式只要切換到 virtual threads 就能得到改善。找個有空的時間,把之前《閒談軟體架構:Async everything?》裡的範例用 virtual threads 改寫看看會有什麼驚奇的結果。
已經改寫囉,效果驚人,請看《閒談軟體架構:Java virtual thread》。