閒談軟體設計:Java virtual thread

更新於 2024/09/21閱讀時間約 16 分鐘
Figure 1 — Amazing 20 cores in a PC

Figure 1 — Amazing 20 cores in a PC

引言

當初在《迎接 Java 19: 虛擬執行緒與平台執行緒》[拙譯] 的後記中提到,如果把 virtual thread 套用到閒談軟體設計:Async everything? 裡面的例子,不知道會蹦出甚麼新花樣,隔了半年多,終於把之前的例子拿出來修修改改,一跑下去,還真的跑出非常意外的結果,那就來分享一下 virtual thread 的威力吧!

直接上場

畢竟閒談軟體設計:Async everything?寫在 2019 年,看實驗結果之前,先來聊聊程式進行了那些修改吧!當時使用的還是 Java 8/12,想要使用 virtual thread,需要 Java 19 以上,程式碼勢必要做些調整。

往下閱讀前,建議先到閒談軟體設計:Async everything?複習一下程式邏輯與實驗內容,下面的說明比較簡單扼要一點。

第一個是 client 端,原本是用 ScheduledThreadPoolExecutor,並將要產生的 request 數量當成參數,希望能產生對應數量的執行緒,但 platform thread 的成本很高,這次換成 Executors.newVirtualThreadPerTaskExecutor() 提供的 executor service [註:要用 @SuppressWarnings(“preview”) 是因為 virtual thread 仍在 preview 階段],意思是每個 task 都用單獨的 virtual thread:

回到 server 端,先前的例子中,替 SleepyRepository 提供 countAsync 和 saveAsync 兩個回傳 CompletableFuture 的函式,實作很簡單,只是利用 supplyAsync 和 runAsync 簡單地轉成 async 模式,兩個函式皆能指定執行的 Executor 實體,若無指定,則使用預設的 ForkJoinPool.commonPool(),使用的是 platform thread。這次會方便實驗,修改成根據設定,使用預設的 Executor 或是 Executors.newVirtualThreadPerTaskExecutor()

同樣,在 saveAsync 函式也是根據設定來使用對應的 Executor

然後,在 Spring framework 的 controller 中,有一個 wrapped 的版本,將 sync 版的 hello 轉成 async 版的 helloWrapped,這邊也是一樣,為了實驗方便,用條件判斷要使用哪一種 Executor

到這邊為止,都是 application code 的修改,把原本 sync 轉成 async 的地方套用新的 Executor,但有沒有一種可能,是在框架層就換掉呢?這樣是不是就更完美地不改變 application code 就能切換到 virtual thread?答案是有的,以 Spring framework 來說,文件上說只需要加入以下幾行。

程式客製 Spring framework 的初始化,第一個函式提供 AsyncTaskExecutor 實體,當 Spring framework 發現 controller 回傳值是一個 Future 時,便會將該 Future 丟到我們提供的 Executor 中執行。

第二個函式則是一個回傳一個 TomcatProtocolHandlerCustomer,去客製化底層 Tomcat,當 acceptor 接受一個 request 進來時,用什麼 Executor 執行,交出去後,acceptor thread 就繼續等待下個 request 進來。

最後就是開關面板,將所有的 flag 集中在一起,方便實驗時調整:

就這樣,只調整了這幾行程式,對了,這次的修改一樣有放到 GitHub 上。

這次程式修改其實沒有花太多的時間,反倒是環境設定花比較多的時間,因為 Eclipse 能同時開發多個 maven 專案,並處理彼此的 dependencies,相較 IntelliJ 我仍然比較喜歡使用 Eclipse。雖然 Eclipse 和 Java 一樣,都採取更快的更新頻率,卻還是常常要用 patch 的方式支援最新的 Java 版本,這次也是如此,Eclipse 2023–03 的版本並不支援 Java 20,安裝 patch 後,project 仍然無法切到 Java 20,後來就放棄了,反正 Java 19 也能開啟 virtual thread,但奇摩子就是不太好。

結果揭曉

當初寫閒談軟體設計:Async everything?的時候,用的電腦已經退役了,新組的電腦在各方面都遠比舊電腦要好很多,例如 CPU 核心數從 4 核心變成 Intel i5 13500 的 6 大核加 8 小核,加上大核支援 hyper threading,對作業系統來說就好像有 20 個核心 (Figure 1),因此無法跟之前的數據比較。

這裡補充一下這次實驗使用的環境,先前用了 12 年的桌機陣亡後,其實曾經一度想買 Mac Studio,但偶爾還是想玩個遊戲,Mac 系統要玩遊戲不是不行,有點麻煩,效能也不太理想,所以在小年夜那一天組裝新桌機,CPU 是 Intel i5 13500,用小機殼與 ITX 的主機板,所以直上兩條 16GB DDR 5 記憶體,合計 32 GB,顯卡是 AMD Radeon RX 6600,一顆 PCI Gen 4 M.2 1TB 的SSD 做系統碟,並把前一台主機的 SATA 1TB SSD 做為資料碟,作業系統則是 Windows 11,這也是我首次買盒裝 Windows (笑)。

作為對照組,即在不開啟任何 virtual threads,Figure 2 是 sync、async 和 wrapped 的執行結果,畢竟核心數這麼多,直接測 2000 個 requests 開始。

Figure 2 — new baseline

Figure 2 — new baseline

真不愧是 20 個核心,不論是 sync、async 還是 wrapped,2000 個 requests 都沒有失敗,雖然 response time 表現還是不理想,sync 模式,等最久的要 20 秒,async 模式是 31 秒,wrapped 模式則是 32 秒。

Figure 3 — Initial NIO-threads

Figure 3 — Initial NIO-threads

NIO thread 和之前的測試結果一樣,一開始會有 10 個 (Figure 3),最多曾經到 200 個,但等到測試結束一段時間後會降至 10 個。worker thread 和上次不同,最多到 19 個 (Figure 4)。

Figure 4 — Worker threads (ForkJoinPool)

Figure 4 — Worker threads (ForkJoinPool)

程式調整後,共有四個開關,在取得對照組後,要先嘗試哪個呢?在《迎接 Java 19: 虛擬執行緒與平台執行緒》中有句話很有趣:

虛擬執行緒真正擅長的是等待。

所以我們先調整 Tomcat,讓每個 request 都用一個 virtual thread 去等,讓我們看一下 sync、async 和 wrapped 的執行結果。

Figure 5 — Virtual threads enabled for Tomcat

Figure 5 — Virtual threads enabled for Tomcat

驚豔的 Sync 模式

sync 讓人十分意外,幾乎每個 request 都只需要兩秒多一點點就回傳結果,處理完所有 requests 的總時間也只需 3.5 秒,這和對照組相比,只用了 17% 的時間就跑完了。可是,有趣的是 async 和 wrapped 卻完全沒有改善。

用 debugger 觀察,會發現執行過程中 NIO executor threads 數量都是 0,因為已經換成 Executors.newVirtualThreadPerTaskExecutor(),然後會看到超多 thread 被啟動,很快又被終止掉,直等所有 requests 處理完,都沒有任何 NIO executor threads 被建立。

Figure 6 — No NIO executor threads

Figure 6 — No NIO executor threads

另一個有趣的點是,雖然 debugger 看不到 worker threads,但從 server 的 log 可以看到,有 19 個 worker threads 被建立,因此 Spring framework 仍然使用數量有限的 platform thread 執行交付的 async task,如果有 task 卡住了,會讓後面的 task 等待有限的 threads。

... do sleep at VirtualThread[#5990]/runnable@ForkJoinPool-1-worker-19

所以該怎麼改善呢?我心中大概有答案,直接做實驗驗證吧,下面為了呈現方便,把四個開關分別用 T (on/off) 代表 Tomcat Handler;A (on/off) 代表 Spring AsyncTask Executor;W (on/off) 代表 Wrapper 及 R (on/off) 代表 Repository 是否開啟 virtual thread。

Async 模式

針對 Async 模式,可能影響的開關有兩個 A 與 R,組合起來就是四種,當兩種都關閉時,就是 Figure 5 的 async 欄,剩下三種結果如 Figure 7 左側,可以看到真正有作用的是 Repository 開啟 virtual thread,Spring framework 的 async task executor 開或關都沒有甚麼影響,甚至從 server 的 log 也看不出 thread 的建立與管理邏輯,感覺似乎還有待 Spring framework 優化?

這邊可以注意到,virtual threads 被建立了 6000 個,然後很快地又消滅了,但為什麼是 6000 個?因為 Tomcat 為每個 request 建立 2000 個,等到呼叫 count 與 save 時,又各別建立 2000 個,總計就有 6000 個 virtual thread 被建立。那共用會比較好嗎?
Figure 7 — Async mode with different flags

Figure 7 — Async mode with different flags

Wrapped 模式

同樣,針對 wrapped 模式,影響的開關是 A 與 W,兩個都是關閉時結果就是 Figure 5 的 wrapped 欄,剩下的結果如 Figure 8 左側。很明顯可以看到 Spring framework 的 async task executor 並沒有作用,重點是 wrapped 的 task 要能在 virtual thread 中執行。

Figure 8 — Wrapped mode with different flags

Figure 8 — Wrapped mode with different flags

如果是這樣,即使關閉 Tomcat 的 virtual thread 支援,只要在對的地方開啟 virtual thread 就該有改善的效果?看實驗結果,在 Figure 7 與 Figure 8 最右側一欄,便是 T (off) + A (off) + R (on) 以及 T (off) + A (off) + W (on) 的結果,確實如預期般,獲得顯著改善。唯一的差別是 NIO Executor Threads 在最高峰的時期會增加到 200 個。

分析結果

這邊先為實驗結果先小節一下,Figure 9 把整個 request 從進來到 repository 的過程,用的是什麼 thread 給整理出來,先看 sync 模式,若沒開啟 Tomcat 的 virtual thread,整段都是靠 NIO Executor Thread (圖中縮寫成 NIO),開啟後就整段用 virtual thread (縮寫成 VT),這也是為什麼開啟後 sync 模式立即有顯著改善。

Figure 9 — Threads running in each component

Figure 9 — Threads running in each component

那 Async 模式呢?沒開啟 Tomcat 的 virtual thread,一開始使用 NIO,後段使用 Worker thread (縮寫 Worker),開啟 Repository 的 virtual thread 後,會在最後一段進入 VT。比較特別的是,一旦為 Tomact 開啟 virtual thread 後,debugger 會看不到 worker threads,可是 log 仍能看到 worker thread 的名字,這也許是 JVM 保留的,圖中是以 PT (platform thread) 標註,從圖中可以看出,只要最後會呼叫 sleep 的 repository 不是以 VT 執行,即便前面可能有使用 VT,也無法得顯著的改善。Wrapped 模式也可以看到一樣的結果。

總結

最後作個總結,這次的 Java Virtual Thread 讓我非常喜歡,原因不完全只是效能上顯著的改善,而是不用為了改善效能,硬是要把程式碼改成違反心智模型的寫法,是的,我就是在指 Reactive 或 Promise 等寫法。

Virtual Thread 提供和 Platform Thread 一樣的抽象,因此既有的專案,可以快速且少量的修改就能立即使用。只是在使用時,要注意的是,virtual thread 要用在刀口上,也就是真正會需要等待的地方,否則無法發揮效果。

對我來說,因為無法保證第三方的 Reactive 或 Promise 是用什麼方式實作,最單純的寫法就是應用程式的部分都是以 sync 的方式撰寫,也只使用 sync 的第三方套件,只要在最外層 (Tomcat) 開啟 virtual thread 就能獲得顯著的改善。

這也是我覺得好架構迷人的地方,以下純粹是我個人想法,不代表 Clean Architecture 的立場。在使用 Clean Architecture 時,越是核心的地方,越應該是單純,只有 business logic 與 application logic,盡可能沒有與框架或是第三方套件的相依,這些相依應該都推到外層。

為什麼?因為框架或第三方套件都是可以換的,現在用 Spring framework,未來想使用 Play framework 可不可以?可以,只要換掉外層的 controller,但核心呢?business logic不應該因為換框架而變動。

如果,第三方套件用了 Promise 或是 Reactive,導致所有 business logic 都要做調整,這就違反「只能有對內的相依方向」的原則。business logic 大多數情況下與效能優化無關,通常需要優化的是 I/O 的存取,這些既然都在外層,就應該在外層做優化,外層的優化不該影響核心,這才是好架構。


後記

這次修改除了使用 virtual thread 外,也用 Java 11 引入的 HttpClient 取代 OkHttp 的套件,讓 dependency 少了一個,使用上也蠻容易的,至於為什麼當時沒有考慮使用呢?嗯... 老實說我忘了。不過,因為真的可以建立非常多 virtual thread,當超過 1000 個 virtual threads 時,只用一個 client 實體發送請求時會遇到 ConnectException 或是 ConnectionClosedException,最後變成每一個 request 都是搭配一個獨立的 clinet 實體才解決問題。

avatar-img
53會員
104內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
Spirit的沙龍 的其他內容
如果您以為上一篇 已經是所有需要考慮的眉角,那可就錯了,實作 offline first 不是只有 client 要注意,server 也需要下功夫的。
任何語言特性用與不用,其實要看是否提升了生產力?是否提升可讀性?是否提升可維護性?這些都是在三個月甚至半年後回來修改程式時,才能明顯感受到的,而不是寫程式的當下。Java 8 的 CompletableFuture、Stream 和 Optional 都很好,但用的不好反而畫蛇添足又沒提高可讀性。
不管用哪種語言開發軟體,除非是那種一個 function 寫個幾萬行的人 (來人啊,把這種人拖去砍了),不然,一般都會根據某些因素,切割成模組或是特定功能的區塊 (一個 class 或是一個 function),但要完成一個特定功能,這些模組或區塊勢必要一起合作,因此這些模組與區塊就發生了關係。
Offline first 的設計最近有越來越多的感覺,但好的 Offline first 設計要解決蠻多的問題,是否使用 offline first 設計真的需要好好思考,不然可能得不到好處,反而還引起一堆 bug,本篇先探討在 client 端可能會遇到的問題與一些可能的解法。
如果您以為上一篇 已經是所有需要考慮的眉角,那可就錯了,實作 offline first 不是只有 client 要注意,server 也需要下功夫的。
任何語言特性用與不用,其實要看是否提升了生產力?是否提升可讀性?是否提升可維護性?這些都是在三個月甚至半年後回來修改程式時,才能明顯感受到的,而不是寫程式的當下。Java 8 的 CompletableFuture、Stream 和 Optional 都很好,但用的不好反而畫蛇添足又沒提高可讀性。
不管用哪種語言開發軟體,除非是那種一個 function 寫個幾萬行的人 (來人啊,把這種人拖去砍了),不然,一般都會根據某些因素,切割成模組或是特定功能的區塊 (一個 class 或是一個 function),但要完成一個特定功能,這些模組或區塊勢必要一起合作,因此這些模組與區塊就發生了關係。
Offline first 的設計最近有越來越多的感覺,但好的 Offline first 設計要解決蠻多的問題,是否使用 offline first 設計真的需要好好思考,不然可能得不到好處,反而還引起一堆 bug,本篇先探討在 client 端可能會遇到的問題與一些可能的解法。
你可能也想看
Google News 追蹤
Thumbnail
徵的就是你 🫵 超ㄅㄧㄤˋ 獎品搭配超瞎趴的四大主題,等你踹共啦!還有機會獲得經典的「偉士牌樂高」喔!馬上來參加本次的活動吧!
Thumbnail
隨著理財資訊的普及,越來越多台灣人不再將資產侷限於台股,而是將視野拓展到國際市場。特別是美國市場,其豐富的理財選擇,讓不少人開始思考將資金配置於海外市場的可能性。 然而,要參與美國市場並不只是盲目跟隨標的這麼簡單,而是需要策略和方式,尤其對新手而言,除了選股以外還會遇到語言、開戶流程、Ap
“所有人寫的程式會變成指令 每一道指令是由CPU執行 而CPU所能理解的指令類型有限”
Thumbnail
簡要說明 JavaScript 的 Event Loop JavaScript 是單執行緒 (single-threaded) 語言,這意味著它一次只能執行一件事,因此所有函式都需要排隊等待執行,這被稱為同步 (synchronous)。在同步操作中,若函式過多或過於複雜,會導致程式阻塞 (blo
※ 非同步概念總複習 為什麼要使用 Promise? 在 JavaScript 開發中,處理非同步操作是常見需求,涉及如文件讀寫、數據庫查詢或網路請求等耗時任務。傳統的回調方式可能導致代碼結構混亂,稱為「回調地獄」,難以維護和理解。 Promise 是解決這問題的方法。它是一個物件(objec
認識 async/await基本概念: async 的本質是 promise 的語法糖 ,只要 function 標記為 async,就表示裡頭可以撰寫 await 的同步語法,而 await 顧名思義就是「等待」,它會確保一個 promise 物件都解決 ( resolve ) 或出錯 ( re
什麼是 Promise.all? 在有多個 Promise 的時候,使用 Promise.all 可以確保「所有的 Promise 都執行完以後,才進入 then」。 Promise.all 語法結構: Promise.all 接受的參數是陣列形式。 什麼時候要使用 Promise.all?
※ 同步概念: 單純地「由上而下」執行程式碼,而且一次只執行一件事,也就是「按順序執行,一個動作結束才能切換到下一個」。缺點是你需要「等待」事情執行完畢,才能繼續往下走。 ※ 非同步概念: 盡可能讓主要的執行程序不需要停下來等待,若遇到要等待的事情,就發起一個「非同步處理」,讓主程序繼續執行,
Thumbnail
當你需要在 Python 中執行多個任務,但又不希望它們相互阻塞時,可以使用 threading 模組。 threading 模組允許你在單個程序中創建多個執行緒,這些執行緒可以同時運行,從而實現並行執行多個任務的效果。
Thumbnail
非同步程式設計(Asynchronous programming) 或是簡單的稱之為 async,它是一種並發程式模型(concurrent programming model),其目的就是讓多個任務能同時在作業系統的執行緒上執行,並透過 async/.await 保留同步。
Thumbnail
為什麼需要非同步? 我們在「【Web微知識系列】 Web Workers」有介紹到在瀏覽器可執行腳本Javascript環境底下如何完成非同步的操作, 主要是為了讓任務更有效率的進行, 不會因為一個非常耗時的工作堵塞住整個服務, 導致無法服務他人的窘境。 大家應該經常在餐廳裡會看到服務員協
Thumbnail
關於多執行緒/多行程的使用方式 在Python 3.2版本之後加入了「concurrent.futures」啟動平行任務, 它可以更好的讓我們管理多執行緒/多行程的應用場景,讓我們在面對這種併發問題時可以不必害怕, 用一個非常簡單的方式就能夠處裡, 底下我們將為您展示一段程式碼: imp
Thumbnail
徵的就是你 🫵 超ㄅㄧㄤˋ 獎品搭配超瞎趴的四大主題,等你踹共啦!還有機會獲得經典的「偉士牌樂高」喔!馬上來參加本次的活動吧!
Thumbnail
隨著理財資訊的普及,越來越多台灣人不再將資產侷限於台股,而是將視野拓展到國際市場。特別是美國市場,其豐富的理財選擇,讓不少人開始思考將資金配置於海外市場的可能性。 然而,要參與美國市場並不只是盲目跟隨標的這麼簡單,而是需要策略和方式,尤其對新手而言,除了選股以外還會遇到語言、開戶流程、Ap
“所有人寫的程式會變成指令 每一道指令是由CPU執行 而CPU所能理解的指令類型有限”
Thumbnail
簡要說明 JavaScript 的 Event Loop JavaScript 是單執行緒 (single-threaded) 語言,這意味著它一次只能執行一件事,因此所有函式都需要排隊等待執行,這被稱為同步 (synchronous)。在同步操作中,若函式過多或過於複雜,會導致程式阻塞 (blo
※ 非同步概念總複習 為什麼要使用 Promise? 在 JavaScript 開發中,處理非同步操作是常見需求,涉及如文件讀寫、數據庫查詢或網路請求等耗時任務。傳統的回調方式可能導致代碼結構混亂,稱為「回調地獄」,難以維護和理解。 Promise 是解決這問題的方法。它是一個物件(objec
認識 async/await基本概念: async 的本質是 promise 的語法糖 ,只要 function 標記為 async,就表示裡頭可以撰寫 await 的同步語法,而 await 顧名思義就是「等待」,它會確保一個 promise 物件都解決 ( resolve ) 或出錯 ( re
什麼是 Promise.all? 在有多個 Promise 的時候,使用 Promise.all 可以確保「所有的 Promise 都執行完以後,才進入 then」。 Promise.all 語法結構: Promise.all 接受的參數是陣列形式。 什麼時候要使用 Promise.all?
※ 同步概念: 單純地「由上而下」執行程式碼,而且一次只執行一件事,也就是「按順序執行,一個動作結束才能切換到下一個」。缺點是你需要「等待」事情執行完畢,才能繼續往下走。 ※ 非同步概念: 盡可能讓主要的執行程序不需要停下來等待,若遇到要等待的事情,就發起一個「非同步處理」,讓主程序繼續執行,
Thumbnail
當你需要在 Python 中執行多個任務,但又不希望它們相互阻塞時,可以使用 threading 模組。 threading 模組允許你在單個程序中創建多個執行緒,這些執行緒可以同時運行,從而實現並行執行多個任務的效果。
Thumbnail
非同步程式設計(Asynchronous programming) 或是簡單的稱之為 async,它是一種並發程式模型(concurrent programming model),其目的就是讓多個任務能同時在作業系統的執行緒上執行,並透過 async/.await 保留同步。
Thumbnail
為什麼需要非同步? 我們在「【Web微知識系列】 Web Workers」有介紹到在瀏覽器可執行腳本Javascript環境底下如何完成非同步的操作, 主要是為了讓任務更有效率的進行, 不會因為一個非常耗時的工作堵塞住整個服務, 導致無法服務他人的窘境。 大家應該經常在餐廳裡會看到服務員協
Thumbnail
關於多執行緒/多行程的使用方式 在Python 3.2版本之後加入了「concurrent.futures」啟動平行任務, 它可以更好的讓我們管理多執行緒/多行程的應用場景,讓我們在面對這種併發問題時可以不必害怕, 用一個非常簡單的方式就能夠處裡, 底下我們將為您展示一段程式碼: imp