2023-09-15|閱讀時間 ‧ 約 19 分鐘

閒談軟體設計:Async everything?

暖身

開始之前,先來個小測驗,下方小程式中,大家覺得 helloworldsay 個別會是在哪個 thread 中執行呢?可以確定的是 main 會在 main thread 中,world 一定不在 main thread 中,但 hellosay 呢?

答案是可能因 JVM 而異 (也可能是因為 OS,但我沒繼續往下做實驗了),在我 2009 年版的 MacBook Pro (El Capitan) 中用 Java 8 Update 144 執行上述的程式會得到這樣的輸出畫面:

main run at main
world run at Thread-0
hello run at Thread-1
say run at main
hello world

但在 Windows 10 中用 Java 12 Update 2,則是得到不一樣的結果:

main run at main
world run at ForkJoinPool.commonPool-worker-3
hello run at ForkJoinPool.commonPool-worker-3
say run at main
hello world

發現差異了嗎?helloworld 都不在 main thread 執行,Java 8 讓 worldhello 在不同的 threads 執行,而 Java 12 讓兩者在同一個 thread 執行,這些微的差異會有什麼影響?先賣個關子,等等再說。

Java 生態圈的 Asynchronous

回到正題,會設計這實驗,主要是最近蠻常寫 JavaScript 程式,JavaScript 的生態圈大量使用 Promise 或 async-await 的機制處理 I/O 或是長時間等待的程式,避免 main thread 被卡住無法處理其他請求。那 Java 生態圈呢?

Servlet 3.0 新增了 startAsync,讓處理請求的 thread 能處理其他請求,真正要長時間處理的程式在其他 thread 執行,再透過 AsyncContext 將結果回傳給 client。加上 Java 8 新增 CompletableFuture 後,許多框架新增對應的支援,像 Spring Data 在 Repository 新增回傳 CompletableFuture 的介面。

但需要每件事都改成 async?首先,雖然已習慣 JavaScript 的 Promise,可是我始終覺得可讀性沒有比較好,這也是為什麼我比較喜歡寫成 async-await 的形式。再者,只要有函式使用 async,用到該函式的整個路徑通通都會受影響,除非強制使用 joinget,讓呼叫 async method 的 thread 等待結束或結果。這也是我當初在設計某個函式庫 (參閱閒談軟體設計:設計抉擇的因素 ) 時,仍然覺得設計成 synchronous API 會比較好的原因之一。

有人戲稱 CompletableFutureStreamOptional 是 Java 8 的三本柱,用 Java 8 卻沒用這三特性就跟沒用一樣,好像落伍似的,但任何語言特性用與不用,其實要看是否提升了生產力?是否提升了可讀性?是否提升了可維護性?這些都是在三個月甚至半年後回來修改程式時,才能明顯感受到的,而不是寫程式的當下。Java 8 這三個特性都很好,但用的不好我反而覺得畫蛇添足又沒提高可讀性。

來看段程式碼,下方是在傳統 Repository 常看到的 countsave 函式 (先別管用來追蹤 thread 的 requestId 參數),為了模擬長時間,這兩個函式都會讓 thread 睡上一秒再回傳。

而使用 repository 的程式就像下方一樣,從上往下一行一行讀,就可讀性來說沒什麼問題,很清楚易懂。

如果把 Repository 的 countsave 改成 asynchronous 的形式,這轉換其實蠻容易的,只要用 CompletableFuturesupplyAsyncrunAsync 靜態函式加上 lambda 很容易讓一段程式碼在另一個 thread 執行,並將回傳值封裝成一個 CompletableFuture

轉換很容易,但使用呢?剛剛提到過,只要有函式使用 async,用到該函式的整個路徑通通都會受影響,所以程式變複雜了,當然跟 callback 形式相比,可讀性變高了,但和一開始的寫法相比,可讀性是下降了 (好啦,如果有人反而覺得提高了,我只能說彼此的心智模型可能差異太大了)。

實驗設計

有人可能會說:為提高 server 的 throughput,些許的複雜度是值得的。真是如此?所以將剛剛兩個版本的函式接到 Spring framework 上,然後用 client 發出大量的 requests,試試看是不是真的比較好。

Spring framework 早就支援 async servlet,而且支援的方式也很直接,用 debugger 下中斷點去追蹤執行流程,會發現即使沒有使用 @EnableAsync@Async 等 annotation,Spring framework 在執行完 request handler 函式後,若回傳值是 CompletableFuture,就會自動呼叫 startAsync,以 async 的形式執行。

這裡有個突發奇想,如果既有程式碼 (legacy code) 都是 synchronous,難道要從底層重寫上來嗎?有沒有可能在 request handler 用 supplyAsyncrunAsync 方式轉成 asynchronous 而不用修改既有的程式碼呢?於是多了一個 helloWrapped 的 request handler 作為待測目標。

有了 server 後,需要能併發很多 requests 的 client,網路上其實有很多現成的服務可以做壓測,像是 loader.io,不過這只是個小實驗,實在不想花太多時間把程式部署到網路上然後再用雲端服務去測,所以寫了個小程式,啟動多個 threads 發出 request (這裡就用到許多 CompletableFuture 的功能),等全部結束後統計並顯示結果。

這是修改後的版本,一開始用 Java 8,觀察統計資料,能在極短的時間內發出 requests (106 ms 內發出 400 個 requests),改用 Java 12,併發量瞬間減少,才發現 Java 8 和 Java 12 處理 supplyAsyncrunAsync 的方式不同,Java 12 用 ForkJoinPool.commonPool 處理所有的 supplyAsyncrunAsync,最多三個 threads,而 Java 8 是一律建立新的 thread。為了測試 server 的能耐,第 5 行根據請求數量建立 ScheduledThreadPoolExecutor 指定最小的 threads 數量,如此一來不管是用 Java 8 或是 Java 12 都能以同樣的方式執行 (這是一開始小測驗的由來)。

實驗數據

Sync 模式

先實驗 sync 的模式,假設接受 requests 的 thread 只有一個,所以當併發的 requests 超過 1 個,B request 須等到 A request 完成才被處理,response time 會增加到 4 秒 (A request 睡 2 秒,B request 再睡 2 秒),但在一開始的小量測試卻沒有觀察到這個現象。

用 debugger 模式啟動 server 後,發現這假設是錯的 (隨便想也知道不會是這樣,畢竟 Spring framework 效能一直很不錯),因為 Spring framework 預設使用的 Tomcat server 在啟動時會建立 acceptor thread 及 10 個 NIO executor threads,acceptor 先把 request 接起來避免 connection time out,然後交給 executor 處理,acceptor 繼續等待下個 request。

Figure 1 — Default 10 worker threads

於是增加併發的 request 到 20 個,應該能觀察到有些 requests 的 response time 會增加的現象,結果又沒有,從下面的數據可以發現,最小 2.087 秒,最大不過 2.108 秒,再次從 debugger 觀察 server,結果,executor threads 增加到 20 個,閒置一段時間後,executor threads 開始減少,最後回到 10 個。好吧,那到底 executor threads 會增加到多少個呢?requests 以兩倍的速度增加,大概到 200 以上後,executor threads 就一直維持在 200 個 (閒置一段時間仍會回到 10 個)。

Figure 2 — Sync with Java 12

Figure 3 — Sync with Java 8

從 Figure 2 和 Figure 3可發現,不管是用 Java 8 或是 Java 12,當 requests 數量在 200 以內,平均的 response time 都有 2.5 秒內的水準,但 requests 數量到 400 時,很明顯 response time 最大值到 4.2 秒,平均值約 3.4 秒,時間明顯隨著數量越長。甚至當 requests 數量到 2000 個,由於 OkHttp 預設 10 秒 time out,近半的 requests 因為超過 10 秒沒有回應而失敗。

題外話,由於 Java 8 和 Java 12 的實驗在不同的機器上進行,一個是只有兩個實體核心的 MacBook Pro,一個是有四個實體核心的桌機,所以把數據放在一起比較有點不太恰當。建議單看一個平台,比較不同 requests 數量帶來的差異。另外,為了避免冷啟動帶來的影響,所有實驗數據都是執行多次後取最好 (失敗次數最少或平均時間最少) 的數據。

如果是這樣,那先執行的 request 的 response time 會比較短?答案恐怕也不是這樣,Figure 4 是 400 個 requests 的 response time 依照啟動執行的時間順序的分布圖,Figure 5 是 800 個 requests 的 response time 分佈圖,橫軸是時間順序,縱軸是 response time,單位都是 ms。會發現到,即使先啟動,在資源爭奪戰也不見會佔到優勢,先執行的 response time 也可能超過 4 秒。

Figure 4— response time of 400 requests

Figure 5 — response time of 800 requests

在這樣的配置 (synchronous handler 和每個 request 都是需要 2 秒的長時間運算)下,Spring framework 大概能應付 800 個 concurrent requests,不會有 time out 問題,超過這個量就難說了,於是,該是用 asynchronous 的時機了嗎?馬上來試試。

Async 模式

從 Figure 6 和 Figure 7 可以看到結果,只能說很難說,若是 Java 8 的環境 (Figure 7),數據就超完美,即使是 2000 個 requests,平均時間拉長到 6.2 秒,但沒有任何 time out,但 Java 12 就超淒慘,當 request 超過 20 個,成功的 requests 數都只有 12 個,比用 synchronous handler 還要糟。

Figure 6 — Async with Java 12

觀察 Figure 6 和 Figure 7 可以發現,主要的差異是 worker threads 的數量,這同時也會影響到 executor threads 的數量,在 Java 12 環境 (Figure 6),最多就 3 個 worker threads,requests 都被 queue 在這三個 worker 身上,即使 asynchronous handler 很快回傳 CompletableFuture,讓 executor threads 的增加速度放緩,但 response time 仍舊降不下來。

在 Java 8 環境 (Figure 7),executor threads 的增加速度也放緩,但 worker threads 的數量則是 requests 的兩倍 (因為 countAsyncsaveAsync 各建立一個 thread),執行完後,worker threads 也很快降到 0,response time 表現明顯比 Figure 6 好很多。

Figure 7 — Async with Java 8

所以說如果使用 asynchronous handler 時,改變 JVM 預設的 thread pool 模式可以獲得最好的效果?就像為測試 client 所做的調整,這恐怕也不一定,畢竟測試 client 是事先知道有多少 requests 要發,在 server 端,並不知道有多少 requests 要進來,很難抓數量,更不用說在寫 Repository 時要根據什麼來評估 thread 數量呢?

如果把 Java 12 async 模式的 worker threads 想成是 connections (最多只有三個),executor threads 再多也不會減少 response time。故資源數量已知,那 thread 數量和資源數量對齊是個合適的配置,例如 database connections 已知是 10,那為存取 database 的程式 (Repository) 配置的 thread 數量就不用超過 10 (還要保留給非 async 方式存取的程式)。

若是變成 Java 8 的模式呢?也很難說,實驗中沒有觀測 CPU 和記憶體使用數量,建立大量的 threads 也會消耗大量的記憶體,雖然使用完就消滅掉,但建立和消滅 thread 都會有效能上的損耗。

Wrap 模式

既然如此,那我先前的突發奇想呢?比較 Figure 8 和 Figure 6 會發現,在只有 3 個 worker threads 的情況下,對 response time 沒有任何幫助,所有的 requests 都被 queue 住了,但比較 Figure 9 和 Figure 7,在 request handler 將 synchronous 的函式包起來,效果和從底層就用 asynchronous 改寫是一樣的,若看 2000 個 requests 的數據,平均 response time 是 5.6 秒對 6.2 秒,甚至還比較好一些。

Figure 8 — Wrap sync with Java 12

雖然沒有數字可以證明 (因為沒有量測),但我猜是因為 executor threads 成長速度較慢,同時 worker threads 的數量也少一半,整體上,對效能的損耗也相對較少。

Figure 9 — Wrap sync with Java 8

實驗到這全部結束,差不多來個總結了。但在進入總結前,再來個小測驗:Spring framework 在呼叫 startAsync 後,讓 request handler 在別的 thread 執行,但執行完後,將結果 serialize 寫入 response 這件事是在哪個 thread 執行呢 (答案在最後揭曉)?

總結

總結一下,若 framework 只支援單執行緒,asynchronous 有其必要,不然唯一的 thread 會被卡住,無法繼續服務其他的 request;如果 framework 本身就支援多執行緒,那 asynchronous 就不見得是必要的,配置的不好,效能反而更差。

配置執行緒數量要考量到許多因素:CPU、記憶體和資源數量,CPU 和記憶體就不用說了,有些資源數量是固定的,像是 database connection pool 有連線數上限,這時候建立再多的 threads 也沒有意義。

由於 asynchronous 非必要,因此對既有的程式碼,花大量時間全部改寫,就經濟效益來說恐怕不是很划算,但如果已知某些 API 會出現長時間的等待,想避免其他 API 受其影響,可以配置一定數量的 threads,用 wrap 的方式轉成 asynchronous,不用改寫既有程式也能隔離長時間的 API。


後記

對了,這次實驗用的程式碼有放在 GitHub 上,之前想說文章寫完程式應該就沒有用了,所以都沒有保留下來,蠻可惜的,想玩玩看,可到這裡抓 (ReadMe 等文件有空再補吧),順便測測看其他作業系統或是 JVM 是否有其他有趣的行為。


謎底揭曉

最後測驗的答案是 NIO executor thread,用 debugger 觀察,會發現 Spring framework 呼叫 startAsync 後,會用 AsyncManager 管理 request 的生命週期,在取得 CompletableFuture 的結果後,會將結果再次 dispatch 回 NIO executor thread,這可能是想利用 Java NIO 較好的 I/O 效率吧。

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.