はじめに
Goのgoroutineは軽量スレッドモデルであり、従来の言語のスレッドモデルよりも小さな単位で実行単位を分け、コンテキストスイッチングのコストとブロッキングタイムを低減する概念です。Kotlinではcoroutineという名前で導入され、JDK21からはVirtual Threadが正式にリリースされました。
従来のJavaスレッドモデル
従来のJavaのスレッドモデルはNative Threadモデルで、JavaのユーザースレッドからJNIを通じてカーネル領域を呼び出し、カーネルスレッドを生成してマッピングして作業を行う形態です。つまり、ユーザースレッドとカーネルスレッドの関係は1:1です。
このとき、JavaのスレッドはI/O、インタラプト、スリープなどの状況でblock/waiting状態になりますが、この時に他のスレッドがカーネルスレッドを占有して作業を行うことをコンテキストスイッチングと言います。
スレッドモデルは従来のプロセスモデルを細かく分け、プロセスの共通部分を共有しながら小さな複数の実行単位を交互に実行できるようにしました。しかし、Spring MVC / Tomcatを使用する環境ではリクエストごとに1スレッドを使用し、リクエスト量が増えるほどコンテキストスイッチングのコストも指数関数的に増加しました。
https://github.com/openjdk/jdk21/blob/master/src/hotspot/share/runtime/javaThread.hpp#L78
Virtualスレッドモデル
Virtualスレッドはプラットフォームスレッドと仮想スレッドに分かれます。つまり、プラットフォームスレッド上で複数の仮想スレッドが交互に実行されます。ここで、仮想スレッドはコンテキストスイッチングコストが従来のJavaスレッドモデルよりも安価です。 つまり、仮想スレッドとプラットフォームスレッドの関係は1:Nです。
Thread | Virtual Thread | |
---|---|---|
Stack Size | ~ 2MB | ~10KB |
Creating time | ~1ms | ~1µs |
Context Switching | ~100µs | ~10µs |
Threadは基本的に最大2MBのスタックメモリサイズを持つため、コンテキストスイッチング時のメモリ移動量が大きいです。また、生成するためにカーネルと通信してスケジューリングするため、生成コストも大きいです。
反対に、Virtual ThreadはJVMによって生成されるため、システムコールなどのカーネル領域の呼び出しが少なく、メモリサイズが一般のスレッドの1%に過ぎません。
プラットフォームスレッドの基本スケジューラはForkJoinPoolを使用します。これにより、プラットフォームスレッドプールを管理し、Virtual Threadの作業分配を行います。
つまり、JVMが直接アクセスするスレッドはプラットフォームスレッドであり、プラットフォームスレッドにマウントして実行する過程はcarrierThreadに実行対象仮想スレッドを割り当てる方式です。
Virtual Threadの動作原理
-
実行されるVirtual Threadの作業であるrunContinuationをcarrier threadのworkQueueにpushします。
-
Work queueにあるrunContinuationはforkJoinPoolによってwork stealing方式でcarrier threadによって処理されます。
-
処理されたrunContinuationはI/O、スリープによる割り込みや作業完了時にwork queueからpopされ、park過程によって再びヒープメモリに戻ります。
JDK21からLockSupportにVirtual Thread判断ロジックを追加し、現在のスレッドがVirtual Threadの場合、park/unparkするようにして、従来のThreadモデルと完全に互換性のあるコンテキストスイッチングをサポートします。
Kotlin Coroutineとの比較
Kotlinでは関数をsuspendとして宣言すると、軽量スレッドのように動作します。しかし、Coroutineはメソッド単位で必要な部分にだけ軽量スレッドを使用できます。また、JDK21以前のバージョンでも軽量スレッドを適用できる利点もあります。
ただし、Coroutineに入る前までは通常のThreadで処理され、Kotlinが作成したsuspend拡張関数を使用する必要があるため、プロダクションコードに変更が必要です。
また、suspend関数は特定の地点でrunBlockingを使用したり、suspend Controllerを作成する必要がありますが、これは応答をリアクティブ応答に変換することになります。
注意事項
- プーリング禁止:
仮想スレッドは安価な使い捨て品なので、スレッドプールを作る行為は無駄になる可能性があります。 - CPUバウンド作業には非効率:
I/O作業には有効ですが、CPU作業のみを行う場合、プラットフォームスレッドのみを使用するより性能が低下します。つまり、コンテキストスイッチングが頻繁に発生しない状況では従来のスレッドが適しています。 - Pinned問題:
Virtual Thread内でSynchronizedやParallelStream、またはネイティブメソッドを使用すると、parkできない状態になり、これが性能低下を引き起こす可能性があります。この部分はSpringも内部でSynchronizedを使用している場合があるため、ReentrantLockに変換する動きがあります。 - Thread local:
Virtual Threadは頻繁に生成・削除されるため、常に小さく保つことが重要です。