閒談軟體設計:Async everything?

更新於 2024/09/14閱讀時間約 18 分鐘

暖身

開始之前,先來個小測驗,下方小程式中,大家覺得 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

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 2 — Sync with Java 12

Figure 3 — Sync with Java 8

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 4— response time of 400 requests

Figure 5 — response time of 800 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 — 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

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

Figure 8 — Wrap sync with Java 12

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

Figure 9 — Wrap sync with Java 8

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 效率吧。

avatar-img
53會員
104內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
Spirit的沙龍 的其他內容
不管用哪種語言開發軟體,除非是那種一個 function 寫個幾萬行的人 (來人啊,把這種人拖去砍了),不然,一般都會根據某些因素,切割成模組或是特定功能的區塊 (一個 class 或是一個 function),但要完成一個特定功能,這些模組或區塊勢必要一起合作,因此這些模組與區塊就發生了關係。
不重新造輪子,我們使用第三方函式庫,聽起來很合理,但每個被引入的函式庫意味著一種 coupling,看到套件管理工具下載眾多第三方函式庫,意味著不用重寫這些東西,開發效率能提升數倍甚至數百倍,但我們真的都能掌握這些 coupling 嗎?當這其中任何一個環節出錯,我們的系統架構真的很優雅地應付嗎?
Offline first 的設計最近有越來越多的感覺,但好的 Offline first 設計要解決蠻多的問題,是否使用 offline first 設計真的需要好好思考,不然可能得不到好處,反而還引起一堆 bug,本篇先探討在 client 端可能會遇到的問題與一些可能的解法。
Immutable interface 讓封裝更有彈性,不用擔心 setter 的過度開放。當不希望物件被不允許的對象修改時,只需讓對方取得 getter 的介面即可,反之,讓能夠允許修改的對象取得有 setter 的物件即可。
不管用哪種語言開發軟體,除非是那種一個 function 寫個幾萬行的人 (來人啊,把這種人拖去砍了),不然,一般都會根據某些因素,切割成模組或是特定功能的區塊 (一個 class 或是一個 function),但要完成一個特定功能,這些模組或區塊勢必要一起合作,因此這些模組與區塊就發生了關係。
不重新造輪子,我們使用第三方函式庫,聽起來很合理,但每個被引入的函式庫意味著一種 coupling,看到套件管理工具下載眾多第三方函式庫,意味著不用重寫這些東西,開發效率能提升數倍甚至數百倍,但我們真的都能掌握這些 coupling 嗎?當這其中任何一個環節出錯,我們的系統架構真的很優雅地應付嗎?
Offline first 的設計最近有越來越多的感覺,但好的 Offline first 設計要解決蠻多的問題,是否使用 offline first 設計真的需要好好思考,不然可能得不到好處,反而還引起一堆 bug,本篇先探討在 client 端可能會遇到的問題與一些可能的解法。
Immutable interface 讓封裝更有彈性,不用擔心 setter 的過度開放。當不希望物件被不允許的對象修改時,只需讓對方取得 getter 的介面即可,反之,讓能夠允許修改的對象取得有 setter 的物件即可。
你可能也想看
Google News 追蹤
Thumbnail
徵的就是你 🫵 超ㄅㄧㄤˋ 獎品搭配超瞎趴的四大主題,等你踹共啦!還有機會獲得經典的「偉士牌樂高」喔!馬上來參加本次的活動吧!
Thumbnail
隨著理財資訊的普及,越來越多台灣人不再將資產侷限於台股,而是將視野拓展到國際市場。特別是美國市場,其豐富的理財選擇,讓不少人開始思考將資金配置於海外市場的可能性。 然而,要參與美國市場並不只是盲目跟隨標的這麼簡單,而是需要策略和方式,尤其對新手而言,除了選股以外還會遇到語言、開戶流程、Ap
Thumbnail
這篇文章探討了在軟體開發中的技術債可能來自哪些原因,以及如何自動化偵測與修復技術債。作者透過分享不同情境下的技術債選擇,提供了對於技術債的思考與建議,針對開發人員在需要做出無奈的技術決策時,提供了一些建議。此外,還提供了一些在做出技術決策時的方法,如保留抽象層和避免vendor lock-in。
Thumbnail
今天來聊個最近很夯的主題 DDD,但不是 DDD 的本尊 Domain Driven Design,而是無所不在的 Database Driven Design,Database Driven Design 不是不好,只是你的模型容易變成貧血模型,邏輯都集中在 service 層等等。
Thumbnail
有趣的是,Model 其實沒什麼嚴格的定義,所以每個人對 Model 的解讀也不盡相同,有人覺得資料怎麼儲存屬於 Model 的一部份 (受 ORM 工具的影響),有人覺得工作流程 (workflow) 是 Model 的一部份,我個人也有自己的想法,而且隨專案的規模和特性,也不是總是一樣的。
Thumbnail
起源是當時 Facebook 有篇文章討論不少人分不清楚上述二者的差別,當時寫了首部曲《閒談軟體設計:API Naming Style》,接著是《閒談軟體設計:內部函式庫》,但始終沒談到 library 和 framework 的差別,主要是沒有好的例子,這次這例子還蠻不錯的。
Thumbnail
我自己偏好用 Repository 搭配 decorator 來管理 cache,而不是在 controller 層或是到處都有快取的邏輯,如果程式都是透過 Repository 更新資料,Repository 就會是一個不錯的地方更新快取,邏輯也就不會散亂在各處了。
Thumbnail
最近身旁有幾位正在懷孕、或剛生產完的朋友,讓我想起自己在懷孕期間印象最深刻的三件「怪事」,其中又以第三件事最誇張。
Thumbnail
不知道大家在買房之前是不是都會參考親朋好友的意見,或是上網看一些買房注意事項,有時候考慮了這塊就忘了那塊,考慮的那塊又忘了這塊.......
Thumbnail
塔西佗陷阱(Tacitus Trap),一個得名於古羅馬歷史學家塔西佗的政治學理論,意指倘若公權力失去其公信力,無論如何發言或是處事,社會均將給予其負面評價。 當然信著恆信、不信者恆不信,這就是真實的人生。
Thumbnail
關於片名   台灣片名《花漾女子》,原文片名《Promising Young Woman》,台灣譯名將時間定格在悲劇發生前,而原文片名則進一步帶我們看見另一個可能性結果
Thumbnail
前幾年因為身體的關係,當了幾年的律師逃兵,當時開了之前的事務所以後,一時間也沒有特別想要做甚麼事情,所以就邊讀一點書、早晚運動一下,剛好聽到當年同梯朋友進去金融業工作,因此也抱著嘗(ㄊㄠˊ)試(ㄅㄧˋ)的心態,找了份銀行法令遵循的工作
Thumbnail
徵的就是你 🫵 超ㄅㄧㄤˋ 獎品搭配超瞎趴的四大主題,等你踹共啦!還有機會獲得經典的「偉士牌樂高」喔!馬上來參加本次的活動吧!
Thumbnail
隨著理財資訊的普及,越來越多台灣人不再將資產侷限於台股,而是將視野拓展到國際市場。特別是美國市場,其豐富的理財選擇,讓不少人開始思考將資金配置於海外市場的可能性。 然而,要參與美國市場並不只是盲目跟隨標的這麼簡單,而是需要策略和方式,尤其對新手而言,除了選股以外還會遇到語言、開戶流程、Ap
Thumbnail
這篇文章探討了在軟體開發中的技術債可能來自哪些原因,以及如何自動化偵測與修復技術債。作者透過分享不同情境下的技術債選擇,提供了對於技術債的思考與建議,針對開發人員在需要做出無奈的技術決策時,提供了一些建議。此外,還提供了一些在做出技術決策時的方法,如保留抽象層和避免vendor lock-in。
Thumbnail
今天來聊個最近很夯的主題 DDD,但不是 DDD 的本尊 Domain Driven Design,而是無所不在的 Database Driven Design,Database Driven Design 不是不好,只是你的模型容易變成貧血模型,邏輯都集中在 service 層等等。
Thumbnail
有趣的是,Model 其實沒什麼嚴格的定義,所以每個人對 Model 的解讀也不盡相同,有人覺得資料怎麼儲存屬於 Model 的一部份 (受 ORM 工具的影響),有人覺得工作流程 (workflow) 是 Model 的一部份,我個人也有自己的想法,而且隨專案的規模和特性,也不是總是一樣的。
Thumbnail
起源是當時 Facebook 有篇文章討論不少人分不清楚上述二者的差別,當時寫了首部曲《閒談軟體設計:API Naming Style》,接著是《閒談軟體設計:內部函式庫》,但始終沒談到 library 和 framework 的差別,主要是沒有好的例子,這次這例子還蠻不錯的。
Thumbnail
我自己偏好用 Repository 搭配 decorator 來管理 cache,而不是在 controller 層或是到處都有快取的邏輯,如果程式都是透過 Repository 更新資料,Repository 就會是一個不錯的地方更新快取,邏輯也就不會散亂在各處了。
Thumbnail
最近身旁有幾位正在懷孕、或剛生產完的朋友,讓我想起自己在懷孕期間印象最深刻的三件「怪事」,其中又以第三件事最誇張。
Thumbnail
不知道大家在買房之前是不是都會參考親朋好友的意見,或是上網看一些買房注意事項,有時候考慮了這塊就忘了那塊,考慮的那塊又忘了這塊.......
Thumbnail
塔西佗陷阱(Tacitus Trap),一個得名於古羅馬歷史學家塔西佗的政治學理論,意指倘若公權力失去其公信力,無論如何發言或是處事,社會均將給予其負面評價。 當然信著恆信、不信者恆不信,這就是真實的人生。
Thumbnail
關於片名   台灣片名《花漾女子》,原文片名《Promising Young Woman》,台灣譯名將時間定格在悲劇發生前,而原文片名則進一步帶我們看見另一個可能性結果
Thumbnail
前幾年因為身體的關係,當了幾年的律師逃兵,當時開了之前的事務所以後,一時間也沒有特別想要做甚麼事情,所以就邊讀一點書、早晚運動一下,剛好聽到當年同梯朋友進去金融業工作,因此也抱著嘗(ㄊㄠˊ)試(ㄅㄧˋ)的心態,找了份銀行法令遵循的工作