閒談軟體設計:Java virtual thread

2023/09/22閱讀時間約 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 實體才解決問題。

51會員
100內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
發表第一個留言支持創作者!