Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Server Side Kotlin Meetup vol.16: 内部動作を理解して ハイパ...

Avatar for ternbusty ternbusty
October 10, 2025

Server Side Kotlin Meetup vol.16: 内部動作を理解して ハイパフォーマンスなサーバサイド Kotlin アプリケーションを書こう

Avatar for ternbusty

ternbusty

October 10, 2025
Tweet

More Decks by ternbusty

Other Decks in Programming

Transcript

  1. © LY Corporation 2 Ayako Hayasaka LINEヤフー株式会社 Software Engineer 2023

    年度⼊社 SWAT として Web バックエンド領域で全社横断的な 技術⽀援を実施 メインは Spring Boot + Java / Kotlin • RAG 技術を利⽤した業務効率化ツール SeekAI 開発 • Yahoo!知恵袋の AI 回答機能「みんなの知恵袋」開発 • ⽣成 AI を利⽤した QA 領域の⽣産性向上ツール開発 • Yahoo!きっずフィルタリング機能 AI 導⼊ © LY Corporation
  2. © LY Corporation アプリケーションの Scalability を決定する要素について ブロッキング or ノンブロッキング I/O

    パフォーマンス試験を通した仮説検証 同期 or ⾮同期処理 Kotlin Coroutine を⽤いる場合の注意点 01 03 02 04 05 3 Agenda
  3. © LY Corporation 1 リクエストごとに 1 OS スレッドを拘束するか I/O の間、OS

    スレッドがブロックされているか 4 アプリケーションの Scalability を決定する要素 (1 リクエストごとに 2 つ以上の処理があり、 かつ後の結果の処理が前の結果に依存しない場合) ⾮同期処理をするような実装になっているか
  4. © LY Corporation 1 リクエストごとに 1 OS スレッドを拘束するか I/O の間、OS

    スレッドがブロックされているか 5 アプリケーションの Scalability を決定する要素 (1 リクエストごとに 2 つ以上の処理があり、 かつ後の結果の処理が前の結果に依存しない場合) ⾮同期処理をするような実装になっているか
  5. © LY Corporation Spring MVC でのシンプルな実装 7 まずはクラシカルな実装から 1. Get

    request on thread#44 2. Start simulation on thread#44 3. End simulation on thread#44 4. Return response on thread#44 @GetMapping("/io-heavy") fun ioHeavy(): String { val result = classicService.ioHeavyTask() return result } fun ioHeavyTask(): String { Thread.sleep(1000) return "Simulating I/O finished" } クラシカルな MVC では 1 request は終始 1 thread で処理される
  6. © LY Corporation • リクエストが来ると、Tomcat のスレッドプール (default size 200) から

    1 つの OS スレッドが割り 当てられる • I/O 待ちの間 OS スレッドは block されている (ブロッキング I/O) Spring MVC でのシンプルな実装 8 スレッドの気持ちになって考えてみる #44 何もしない リクエスト受付 レスポンス返却 read() syscall read() 結果の受け取り
  7. © LY Corporation • リクエストが来ると、Tomcat のスレッドプール (default size 200) から

    1 つの OS スレッドが 割り当てられる。レスポンス返却まで同じスレッドが⾯倒を⾒る → このスレッドプールが枯渇したらもうリクエストが受け付けられない! → Async + CompletableFuture を使って別のスレッドプールに処理を委譲するなどすれば、 枯渇を防ぐことはできる • I/O 待ちの間 OS スレッドは block されている (ブロッキング I/O) → OS スレッドを block しているのはリソースの無駄! これをどうにかしたい! 9 クラシカルな実装の問題点を整理 Spring MVC でのシンプルな実装
  8. © LY Corporation • ソケットをノンブロッキングモードで利⽤ • read() syscall の結果が即座に返るようになり、データの到着後は epoll()

    による通知を受けて 結果を処理する • これらの処理は EventLoop スレッドが⾏う 12 ノンブロッキングな I/O とは EventLoop Thread read() read() epoll() 受け取った read() 結果の処理 read()
  9. © LY Corporation • read() を call するイベントループスレッドと、その結果を受け取って処理するイベントループスレッド は別になりうる 13

    Spring WebFlux での実装例 1. Get request on thread#43 2. Start simulation on thread#43 3. End simulation on thread#72 fun ioHeavyTask(): Mono<String> = mono { delay(1000) "Simulating I/O finished" } @GetMapping("/io-heavy") fun ioHeavy(): Mono<String> = mono { reactiveService.ioHeavyTask().awaitSingle() "Simulating I/O finished" }
  10. © LY Corporation ノンブロッキング I/O により⾮常に⾼い性能 を誇る • 書き⽅が特殊 (*1)

    • ThreadLocal が使えない • うっかりブロッキング処理をすると イベントループが⽌まって⼤変まずい • ノンブロッキングなライブラリ (R2DBC など) を使おう • どうしてもブロッキングしたい場合 は別スレッドプールに切り出そう メリット デメリット 14 Spring WebFlux を利⽤するメリット・デメリット (*1) Kotlin Coroutine と組み合わせれば少しわかりやすくはなります
  11. © LY Corporation • VT は必要に応じて OS スレッドにマウントされる。I/O 待ちになるとアンマウントされ、その間は別の タスクのマウントができる。

    • I/O 処理が完了すると、VT は再び OS スレッドにマウントされ、再開される。 16 Virtual Thread (VT) とは OS thread #44 (Carrier thread) read() Virtual thread #1 何もしない Virtual thread #2 何もしない VT1 の処理 VT2 の処理 VT1 の処理 何もしない
  12. © LY Corporation • クラシカルな実装とソースコードは全く⼀緒 17 Virtual Thread (VT) を⽤いた実装例

    @GetMapping("/io-heavy") suspend fun ioHeavy(): String { val result = virtualService.ioHeavyTask() return result } fun ioHeavyTask(): String { Thread.sleep(1000) return "Simulated I/O finished" } 1. Get request on VirtualThread#63 2. Start simulation on VirtualThread#63 3. End simulation on VirtualThread#63 4. Return response on VirtualThread#63 1 request ͸ऴ࢝ಉ͡ virtual thread Ͱॲཧ͞ΕΔ
  13. © LY Corporation • Blocking な syscall を呼んでいるが、実際 の I/O

    待ちの間はアンマウントされるので OS スレッドはブロックされない -> ⾮常に性能がよい • 何も考えずにブロッキング I/O なライブラ リを呼んでも問題ない • WebFlux の独特な記法を学習することな く、MVC チックな書き⽅が可能 • アプリケーションから⾒ると同⼀のスレッ ドなので ThreadLocal が使える • JDK21 から登場したため実装例が少ない • 利⽤しているライブラリに synchronized ブロックが存在すると、CT が解放されな くなる (pinning 問題) • JDK24 で修正済み • なので、つい先⽇ (2025年9⽉) リリースされた Java 25 (LTS 版) でも修正版が利⽤可能です! メリット デメリット 18 Virtual Thread を使うメリット・デメリット
  14. © LY Corporation • 環境 • 社内 k8s 基盤に⽴ち上げたアプリケーションに対して、社内基盤上の VM

    から負荷試験ツール vegeta を⽤いて⼤量リクエスト • 100-3000 rps、duration 5s • Rps を 100 ずつ上げていって、レスポンスに 30s 以上かかった時点で失敗とみなす • マシンスペック • CPU 500m、Memory 500M、replica 2 • アプリケーション • 各フレームワークについて、1s sleep してレスポンスを返す endpoint を実装 • Spring Boot 3.3.5 + JDK21 + Kotlin 1.9.25 20 性能試験: 概要
  15. © LY Corporation • WebFlux, Virtual Thread はノンブロッキング I/O なのでクラシカルな実装より性能がよいだろう

    • Virtual Thread は WebFlux と⽐較して性能的に劣りそう • ThreadLocal などを保持する必要があり、WebFlux 系ほどの軽量なタスク切り替えはできないだろう • WebFlux でうっかりブロッキングな処理を書くとかなり性能が落ちそう • イベントループを⽌めると⼤変なことになるため 21 性能試験: 仮説
  16. © LY Corporation 予想通り、MVC < MVC + Virtual Thread <

    WebFlux の順で性能が良かった 22 性能試験: Response time 99 percentile クラシカルな実装 Virtual Thread WebFlux rps Response time
  17. © LY Corporation WebFlux, Virtual Thread は RPS が増えてもスループットが落ちにくい 23

    性能試験: I/O Throughput クラシカルな実装 Virtual Thread WebFlux rps throughput
  18. © LY Corporation 24 性能試験: WebFlux でブロッキング処理をしてはダメ suspend fun ioHeavyTask():

    String { Thread.sleep(1000) return "Simulated I/O finished" } • 結果: 30rps も受け付けられなくなった • 教訓: eventloop を⽌めてはいけない。どうしても必要になったら 別のスレッドプールに切り出そう
  19. © LY Corporation 1 リクエストごとに 1 OS スレッドを拘束するか I/O の間、OS

    スレッドがブロックされているか 26 アプリケーションの Scalability を決定する要素 (1 リクエストごとに 2 つ以上の処理があり、 かつ後の結果の処理が前の結果に依存しない場合) ⾮同期処理をするような実装になっているか
  20. © LY Corporation • 1 リクエストごとに 2 つ以上の処理があり、かつ後続の処理内容が前の結果に依存しない場合 (異なる 2

    つの DB 上にあるデータを両⽅更新したいとき、など) • Async + CompletableFuture や、Kotlin Coroutine などで実現できる 27 ⾮同期処理が必要になるのはどういうとき? I/O 1 I/O 2 I/O 1 I/O 2 同期処理 ⾮同期処理 t t ⾮同期処理にした⽅がレスポンスタイムが短くなる場合がある
  21. © LY Corporation • I/O 待ちに⼊った時に、それを 実⾏していたコルーチンをいっ たん中断 (suspend) し、その

    間同じスレッドの上で他のコ ルーチンを実⾏できる • Dispatcher を指定して、タス クを実⾏するスレッドプールの 指定もできる • ⾮同期処理が簡潔に書ける 28 Kotlin Coroutine @GetMapping("/io-heavy") suspend fun ioHeavy(): String = coroutineScope { val job1 = async { coroutineService.ioHeavyTask() } val job2 = async { coroutineService.ioHeavyTask() } job1.await() job2.await() "Simulating I/O finished" } suspend fun ioHeavyTask(): String = withContext(Dispatchers.IO) { delay(1000) "Simulating I/O finished" }
  22. © LY Corporation • リクエストの受け取り • Tomcat のスレッドプール内のスレッド (デフォルトサイズ 200)

    • 何事もなければそのままレスポンス返却まで同⼀スレッドで⾯倒を⾒ることになる • ではスレッドが切り替わるのはどういうとき? • Coroutine の suspend 後に復帰するとき • withContext(Dispatchers.IO) 等で明⽰的なスレッドプール切り替えを⾏ったとき • ここで⽣じる疑問 • スレッドが切り替わった際、リクエストを受け取った元のスレッドはどうなる? • 解放されて他のリクエストを受け付けられるようになる or 拘束されたまま? • Suspend 後に復帰する時は、どこのスレッドに戻ってくる? 30 Spring MVC + Kotlin Coroutine のタスクは どのスレッドでどう実⾏される? → 答え: パフォーマンスにも関係するので次スライド以降で深掘り
  23. © LY Corporation 31 リクエストを受け取った元のスレッドはどうなる? 書き⽅によって挙動が異なる @GetMapping("/io-heavy") fun ioHeavySerial(): String

    = runBlocking { coroutineService.ioHeavyTask() "Simulating I/O finished" } @GetMapping("/io-heavy") suspend fun ioHeavy(): String { coroutineService.ioHeavyTask() return "Simulating I/O finished" } runBlocking を利⽤する場合 リクエストを受け付けたスレッドは (他のスレッド に処理を移譲したとしても、その処理が終わるま で) ブロックされ続ける Response も最終的には呼び出し元スレッドから 返却される Controller を suspend 関数にする場合 Spring 5.3 以降は、MVC の Controller でも suspend 関数が扱えるようになった (*1) Dispatch or suspend をした瞬間に呼び出し元の スレッドの⼿から離れ、完了までブロックされない (*1) org.jetbrains.kotlinx:kotlinx-coroutines-reactor を依存に追加する必要あり
  24. © LY Corporation 32 リクエストを受け取った元のスレッドはどうなる? 負荷試験での検証結果 (*1) 1 リクエスト 1

    スレッドという Spring MVC での原則から外れ、⼀度 suspend するとリクエストを受け付けたスレッドに戻ってこないことになります。 MDC なども吹き⾶んで戻ってきません。必要に応じて MDCContext などで引き継ぐようにしてください。 • 負荷試験の条件: 先述の通り • 結果 rps runBlocking Controller を Suspend 関数に • RPS を増やすにつれ、runBlocking を 利⽤している⽅では response time が 悪化していった • Controller を suspend 関数にする だけでスレッドのブロックが防⽌さ れ、性能が向上する可能性がある (*1) Response time
  25. © LY Corporation 33 Suspend 後にどこのスレッドに復帰する? パフォーマンスが出るのはどっち? suspend fun ioHeavyAfterDelayTask():

    String { delay(1000) Thread.sleep(1000) return "Simulated I/O finished" } suspend fun delayAfterIoHeavyTask(): String { Thread.sleep(1000) delay(1000) return "Simulated I/O finished" } @GetMapping("/test") suspend fun test(): String = coroutineScope { // ここで関数呼び出し } or
  26. © LY Corporation 34 Suspend 後にどこのスレッドに復帰する? パフォーマンスが出るのはどっち? suspend fun ioHeavyAfterDelayTask():

    String { delay(1000) Thread.sleep(1000) return "Simulated I/O finished" } suspend fun delayAfterIoHeavyTask(): String { Thread.sleep(1000) delay(1000) return "Simulated I/O finished" } @GetMapping("/test") suspend fun test(): String = coroutineScope { // ここで関数呼び出し } ❌ 実質的に 1rps しか受け付けられない ⭕ クラシカルな実装と同程度のパフォーマンスは出る 🙆 🙅
  27. © LY Corporation 35 Dispatchers.Unconfined の挙動に注意 Started task on thread:

    Thread[#45,http-nio- 8080-exec-1,5,main] Finished task on thread: Thread[#74,kotlinx.coroutines.DefaultExecutor, 5,main] Delay で suspend Suspend から coroutine を復帰 させるのはタイマー⽤のイベント ループ DefaultExecutor Spring の suspend ハンドラは、 Dispatchers.Unconfined で起動 している (ポイント!) Dispatchers.Unconfined は復帰後の thread pool を明⽰的に指定しないため、復 帰のために利⽤されたスレッドで復帰後の 処理が実⾏される ↓ 1 本しかない DefaultExecutor でその後の 処理が実⾏されてしまうので、 スループットが著しく低下する https://github.com/spring-projects/spring-framework/issues/33788 suspend fun ioHeavyAfterDelayTask(): String { logger.info("Started task on thread: ${Thread.currentThread()}") delay(1000) Thread.sleep(1000) logger.info("finished task on thread: ${Thread.currentThread()}") return "Simulating I/O finished" } 思わぬスレッドで処理が実⾏されうる
  28. © LY Corporation 36 Dispatchers.Unconfined の挙動に注意 Started task on thread:

    Thread[#45,http-nio- 8080-exec-1,5,main] Finished task on thread: Thread[#74,kotlinx.coroutines.DefaultExecutor, 5,main] Delay で suspend Suspend から coroutine を復帰 させるのはタイマー⽤のイベント ループ DefaultExecutor Spring の suspend ハンドラは、 Dispatchers.Unconfined で起動 している (ポイント!) Dispatchers.Unconfined は復帰後の thread pool を明⽰的に指定しないため、 復帰のために利⽤されたスレッドで復帰後の 処理が実⾏される ↓ 1 本しかない DefaultExecutor でその後の 処理が実⾏されてしまうので、 スループットが著しく低下する https://github.com/spring-projects/spring-framework/issues/33788 suspend fun ioHeavyAfterDelayTask(): String { logger.info("Started task on thread: ${Thread.currentThread()}") delay(1000) Thread.sleep(1000) logger.info("finished task on thread: ${Thread.currentThread()}") return "Simulating I/O finished" } 思わぬスレッドで処理が実⾏されうる
  29. © LY Corporation 37 Dispatchers.Unconfined の挙動に注意 これなら Thread.sleep は tomcat

    スレッドで実⾏される だけなので⼤きな問題にはならない ではどうしたらいい? suspend fun delayAfterIoHeavyTask(): String { Thread.sleep(1000) delay(1000) return "Simulating I/O finished" } suspend fun ioHeavyAfterDelayWithDispatcherTask() = withContext(Dispatchers.IO) { delay(1000) Thread.sleep(1000) "Simulating I/O finished" } Dispatcher を明⽰的に指定すれば 復帰後はそのスレッドプール内のス レッドに戻ってくるので問題ない
  30. © LY Corporation • ⾮同期処理をシンプルに書ける • WebFlux との併⽤もできる。その場合、 WebFlux 独特の書き⽅を避けて同期的な

    書き⽅ができる。 • Virtual Thread と違ってユーザレベルの タスク管理にすぎないので、ブロッキング I/O を呼びだすと OS スレッドはブロック される • Spring MVC と併⽤する際の注意 • runBlocking を利⽤するとリクエス トを受け取った tomcat スレッドを 拘束し、パフォーマンスに影響する • Dispatchers.Unconfined の挙動に 要注意。必要に応じて Dispatcher を利⽤するなどの⼯夫を メリット デメリット 38 Kotlin Coroutine を使う場合の メリット・デメリット
  31. © LY Corporation 39 まとめ • 各フレームワークがどのように I/O バウンドな処理を実⾏しているのかを理解しよう •

    Spring WebFlux や Virtual Thread を利⽤すればパフォーマンスは上がるが、その仕組みや、 採⽤によるメリット・デメリットを把握しておくことが重要 • 「ブロッキング or ノンブロッキング」と「同期 or ⾮同期」は別のレイヤの話 • Spring MVC と Kotlin Coroutine を併⽤する場合は、各タスクがどのスレッドで実⾏されているかに 注意しよう