はじめに
非同期通信ってややこしいですよね。僕は頭がこんがらがります。なんとか同期通信だけで性能のいいアプリを作れないかと思う訳なのですが、Java21から 仮想スレッド(Virtual Threads) なるものが導入されました。仮想スレッドを使うと、ブロッキングI/O処理中にOSスレッドを解放して、他の仮想スレッドの処理を実施可能となり、主にアプリケーションのスループットを向上させる。そうです。
参考:https://docs.oracle.com/javase/jp/21/core/virtual-threads.html#GUID-DC4306FC-D6C1-4BCC-AECE-48C32C1A8DAA
そして、Spring Boot 3.2にて、仮想スレッドがサポートされました(リリースノート)。しかも利用にあたってはspring.threads.virtual.enabled=true
の設定をいれるだけだそうです。
2024/12時点でSpring Bootの最新は3.4ですが、対応範囲が徐々に増えている模様です。これはもう試すしかないだろうということで、本記事では仮想スレッドを利用して、同期通信および非同期通信を比較し、負荷条件が異なる場合の性能について検証した結果を紹介します。
実験設定
1. アプリケーション構成
今回は通信にフォーカスするということで、同期または非同期リクエストを送信するアプリケーションと、固定レスポンスを同期または非同期に打ち返すだけのアプリケーションを用意します。
送信アプリケーション(sender-app):
クライアントからのリクエストを受け取り、受信アプリケーションに対して同期または非同期でリクエストを送信します。レスポンスはそのままクライアントに流します。
上述のリリースノートにて、WebClientのブロック通信でのサポートが明言されているのでそちらを使っていきます。(HttpClientの場合も試しましたが、仮想スレッドの効果は限定的でした。)
コードはこんな感じ
@GetMapping("/sync-request")
public String syncRequest() throws Exception {
return webClient.get()
.uri(URI.create("http://proxy:12345/sync"))
.retrieve()
.bodyToMono(String.class).block();
}
@GetMapping("/async-request")
public Mono<String> asyncRequest() {
return webClient.get()
.uri(URI.create("http://proxy:12345/async"))
.retrieve()
.bodyToMono(String.class);
}
※ 宛先がプロキシになっていますが、プロキシについては後述します。
受信アプリケーション(fixed-response-app):
固定されたレスポンスを返すバックエンドアプリケーション。同期エンドポイントと非同期エンドポイントを持ちます。
コードはこんな感じ
@GetMapping("/sync")
public String syncResponse() {
return "{\"status\": \"ok\", \"type\": \"sync\"}";
}
@GetMapping("/async")
public Mono<String> asyncResponse() {
return Mono.just("{\"status\": \"ok\", \"type\": \"async\" }");
}
2. ネットワーク遅延の設定
今回、通信にかかるブロッキングI/Oが標的なのですが、localhostの通信では通信のオーバーヘッドがなさすぎるので、疑似的なネットワーク遅延を導入します。Toxiproxy を使用しました。以下のコマンドでプロキシサーバとネットワーク遅延を設定しています。上り下りでそれぞれ5ms遅延させています。
toxiproxy-cli create --listen 0.0.0.0:12345 --upstream fixed-response-app:8080 myproxy
toxiproxy-cli toxic add -t latency -a latency=5 -u myproxy
toxiproxy-cli toxic add -t latency -a latency=5 -d myproxy
3. アプリケーションの実行環境
アプリケーションは以下のdocker composeを使用して実行しました。toxiproxyを介して通信を行います。なお、コンテナはjibプラグインでビルドしました。はじめて使いましたが便利ですね。
ymlはこんな感じ
services:
fixed-response-app:
image: fixed-response-app:latest
ports:
- "8080:8080"
networks:
- custom_net
request-sender-app:
image: request-sender-app:latest
ports:
- "8081:8080"
networks:
- custom_net
proxy:
image: ghcr.io/shopify/toxiproxy:latest
networks:
- custom_net
ports:
- "8474:8474"
- "12345:12345"
networks:
custom_net:
driver: bridge
4. テストの条件
コンテナリソース(limits)
- メモリ: 2G
- CPU: 1.0
JVMオプション
- -XX:+UseG1GC: G1GCを使用
- -Dspring.threads.virtual.enabled=true: 仮想スレッドを利用する。(該当するケースのみ設定)
負荷
- 負荷ツール: JMeter
- ランプアップ期間: 60秒
- 並列スレッド数: 100および500スレッド
- 負荷期間: それぞれのスレッド数、通信パターンで300秒
- 暖気: 100スレッドで同期通信、非同期通信を300秒
結果と考察
負荷の並列スレッド数毎に結果をまとめていきます。
スループットおよび、『仮想スレッドなし』に対して、『仮想スレッドあり』でどれだけ向上しているかを改善率
として表記しています。
1. 100並列の負荷での性能比較
仮想スレッドありの場合、同期通信、非同期通信の双方で大幅にスループットを向上させています。
スループット
モデル | 仮想スレッドなし | 仮想スレッドあり | 改善率 |
---|---|---|---|
同期通信 | 1225.8 req/s | 2102.8 req/s | 71.6% |
非同期通信 | 1328.1 req/s | 2211.0 req/s | 66.5% |
サマリ
-
同期通信:
- 仮想スレッドによってスループットが71.6%向上。
- 仮想スレッドありの同期通信(2102.8 req/s)が、仮想スレッドなしの非同期通信(1328.1 req/s)を大きく上回った。
- 仮想スレッドありの同期通信(2102.8 req/s)が、仮想スレッドありの非同期通信(2211.0 req/s)に迫るスループットとなった。
-
非同期通信:
- 仮想スレッドによってスループットが66.5%向上。
2. 500並列の負荷での性能比較
高負荷の設定では、仮想スレッドによるスループットの向上は限定的なものとなりました。
スループット
モデル | 仮想スレッドなし | 仮想スレッドあり | 改善率 |
---|---|---|---|
同期通信 | 2017.2 req/s | 2693.4 req/s | 33.5% |
非同期通信 | 2148.8 req/s | 2804.6 req/s | 30.5% |
サマリ
-
同期通信:
- 仮想スレッドありでスループットが33.5%向上。
- 仮想スレッドありの同期通信(2693.4 req/s)が、仮想スレッドなしの非同期通信(2148.8 req/s)を大きく上回った。
- 仮想スレッドありの同期通信(2693.4 req/s)が、仮想スレッドありの非同期通信(2804.6 req/s)に迫るスループットとなった。
-
非同期通信:
- 仮想スレッドありでスループットが30.5%向上。
結論
あくまで今回のリソース設定で、と前置きはつきますが、仮想スレッドを利用することで、同期通信でも従来の非同期通信と同等以上の性能となりました。仮想スレッドありの非同期通信に匹敵する程のスループットとなっています。同期通信の方がアプリケーションの実装がシンプルになりますし、とてもいい結果かなと思います。ただし、あくまで性能を追い求める場合、実装が大変でも非同期通信を選択したほうがよさそうですね。頑張ろう。
高負荷時に改善率が減衰しているのは、スレッド以外の要因がボトルネックになっているものと推測しています。それでも高い改善率は維持していますし、拡張しにくいリソースであるスレッド数の制約を解放できることの恩恵は大きいなと感じます。積極的に使っていきたいですね。