2024年に100万同時タスクを実行するのに必要なメモリ容量
(hez2010.github.io)- 2024年末時点の最新の言語・ランタイムを基準に、1個から100万個までの同時タスクのメモリ使用量を比較したベンチマークであり、最新の結果については別の Take 2 ページを参照するよう案内している
- すべてのテストは、各タスクが10秒待機したあと全体の完了を待つ同一の構成にそろえられており、複数スレッドではなくコルーチン・非同期タスク・goroutine・仮想スレッドのメモリ特性を比較している
- 比較対象は Rust
tokio・async_std、C# と NativeAOT、NodeJS、Pythonasyncio、Go goroutine、Java virtual thread、Java GraalVM native image で、全コードは GitHub で公開されている - タスク数が増えるほどランタイムごとのメモリ増加幅には大きな差が出て、100万タスクでは C# が最も低いメモリ使用量を示し、Rust も効率的な結果を維持した
- 最新の .NET は大きな改善を見せ、NativeAOT は Rust と競合する水準だった一方、Go goroutine は100万タスクで優勝結果より13倍以上、Java より2倍以上多くのメモリを使用した
ベンチマーク方法と公開資料
- 2024年末時点の最新言語バージョンで、2023年の非同期プログラミングのメモリ消費比較を再実施した結果である
- 冒頭には、最新の結果を確認するには How Much Memory Do You Need in 2024 to Run 1 Million Concurrent Tasks? - Take 2 を見るよう案内がある
- テストプログラムはコマンドライン引数で受け取った
N個の同時タスクを作成し、各タスクが10秒間待機したあと、すべてのタスクが終了すると終了する - 比較の焦点は複数スレッドではなく、コルーチン系の並行性モデルにある
- ベンチマーク全体のコードは async-runtimes-benchmarks-2024 で公開されている
比較対象の言語とランタイム
- Rust は
tokioとasync_stdの2種類の非同期ランタイムで比較している- どちらも Rust で広く使われている非同期ランタイムである
- C# は
async/awaitを直接サポートし、Task.DelayとTask.WhenAllでタスクを実行する- .NET 7 から提供されている NativeAOT もあわせて比較している
- NativeAOT は、マネージドコードを VM なしで実行できるよう最終バイナリへ直接コンパイルする
- NodeJS は
setTimeoutをutil.promisifyでラップし、その後Promise.allで待機する - Python は
asyncio.sleepとasyncio.gatherを使用する - Go は並行性の構成要素として goroutine を使用し、個別の await の代わりに
WaitGroupですべてのタスク完了を待つ - Java は JDK 21 から提供される virtual thread を使用する
- GraalVM の native image もあわせて比較している
- GraalVM native image は .NET NativeAOT に似た概念として含まれている
テスト環境
- ハードウェア: 13th Gen Intel Core i7-13700K
- OS: Debian GNU/Linux 12(bookworm)
- Rust: 1.82.0
- .NET: 9.0.100
- Go: 1.23.3
- Java: openjdk 23.0.1 build 23.0.1+11-39
- Java(GraalVM): java 23.0.1 build 23.0.1+11-jvmci-b01
- NodeJS: v23.2.0
- Python: 3.13.0
- 可能な場合、すべてのプログラムは release mode で実行された
- テスト環境に
libicuがないため、国際化およびグローバリゼーション対応は無効化されている
タスク数が増えたときのメモリ変化
-
最小フットプリント: 1タスク
- ランタイム自体が必要とするメモリを見るため、まず1タスクだけを実行した
- Rust、C# NativeAOT、Go は静的なネイティブバイナリとしてコンパイルされており、非常に少ないメモリしか使わず、互いに近い結果を示した
- Java GraalVM native image も良好な結果だったが、他の静的コンパイル対象よりは少し多くのメモリを使用した
- マネージドプラットフォームやインタプリタ上で動作するプログラムは、より多くのメモリを消費した
- この区間では Go が最も小さいフットプリントを示した
- Java GraalVM は OpenJDK Java よりはるかに多くのメモリを使っており、設定で調整できる可能性がある
-
1万タスク
- Rust の2つのベンチマークは、1万タスクでも最小フットプリント比でメモリ使用量が大きく増えず、非常に少ないメモリを維持した
- C# NativeAOT も約 10MB のメモリしか使わず、Rust にほぼ続いた
- Go のメモリ使用量はこの区間で大きく増加した
- Java GraalVM native image の virtual thread は Go goroutine より軽量に見える
- Go と Java GraalVM native image は静的なネイティブバイナリとしてコンパイルされているが、VM 上で動作する C# より多くの RAM を使用した
-
10万タスク
- タスク数が10万個に増えると、すべての言語でメモリ消費が大きく増え始めた
- Rust と C# はこの区間でも良好な結果を出した
- C# NativeAOT は Rust より少ない RAM を使用し、すべての言語を上回った
- Go のプログラムはこの時点で Rust だけでなく Java、C#、NodeJS にも後れを取った
- 例外的に、GraalVM 上で実行した Java は Go を上回った対象から除外される
-
100万タスク
- 100万タスクでは C# が他のすべての言語を明確に上回った
- Rust は予想どおり、メモリ効率の面で良好な結果を維持した
- Go と他ランタイムの差はさらに広がった
- Go は優勝結果より13倍以上多くのメモリを使用した
- Java と比べても Go は2倍以上多くのメモリを使用し、JVM はメモリを多く使い Go は軽いという一般的な認識と異なる結果を示した
最終的な観察
- 同時タスク数が非常に多い場合、各タスクが複雑な計算をしなくても相当なメモリを使う可能性がある
- 言語ランタイムごとにトレードオフの現れ方は異なる
- タスク数が少ないときは軽量で効率的かもしれない
- 数十万タスクまで拡張すると、メモリ増加幅が大きくなる可能性がある
- 最新のコンパイラとランタイム基準では、.NET は大きな改善を見せた
- .NET NativeAOT は Rust と競争力のある結果を示した
- Java の GraalVM native image もメモリ効率の面で良好な結果を示した
- Go goroutine はリソース消費の面で引き続き非効率な結果を示した
まだコメントはありません。