C#において処理にかかる時間を短縮する方法についてParallel
を用いた方法については以前検証を行いました。
Parallel
を用いた方法だとループ処理がメインになるため「タスク A とタスク B を並列化したい」みたいなことは難しかったです。そのため Task/await を用いた並列処理について調査・検証しました。その結果使用方法によって挙動が大きく変わるため、適切な使い分けが重要ということがわかりました。
検証環境と測定方法
今回の検証では、CPU 集約的な処理として配列操作を含むループ処理を使用しました。同じ処理を 3 つ実行する場合の実行時間を比較しています。
測定対象の処理
// 同期用
void UseNormalForLoop(int loopNum, int arraySize)
{
Console.WriteLine("Normal For Loop");
for (int j = 0; j < loopNum; j++)
{
int[] values = new int[arraySize];
// Multiply each element by 2
for (int i = 0; i < values.Length; i++)
{
values[i] *= 2;
}
// Display the first 10 values
for (int i = 0; i < 10; i++)
{
var value = values[i];
}
}
Console.WriteLine("Normal For Loop Completed");
}
// 非同期用(計算量は同じ)
async Task UseNormalForLoopAwait(int loopNum, int arraySize)
{
Console.WriteLine("Normal For Loop");
for (int j = 0; j < loopNum; j++)
{
int[] values = new int[arraySize];
// 各要素を2倍にする
for (int i = 0; i < values.Length; i++)
{
values[i] *= 2;
}
// 最初の10個の値を参照
for (int i = 0; i < 10; i++)
{
var value = values[i];
}
}
Console.WriteLine("Normal For Loop Completed");
}
3 つの実行パターンの比較
パターン 1: 通常の同期処理
void NormalTask()
{
var loopNum = 10000;
var arraySize = 100000;
// 時間のかかる処理を3種類順次実行
UseNormalForLoop(loopNum, arraySize);
UseNormalForLoop(loopNum, arraySize);
UseNormalForLoop(loopNum, arraySize);
}
パターン 2: 非同期タスクの順次実行
async Task NormalTaskAwait()
{
var loopNum = 10000;
var arraySize = 100000;
// awaitで順次実行(並列化なし)
await UseNormalForLoopAwait(loopNum, arraySize);
await UseNormalForLoopAwait(loopNum, arraySize);
await UseNormalForLoopAwait(loopNum, arraySize);
}
パターン 3: 非同期タスクの並列実行
async Task ParallelTaskAwait()
{
var loopNum = 10000;
var arraySize = 100000;
// Task.Runを使用した物理的並列処理
var tasks = new[]
{
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize)),
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize)),
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize))
};
await Task.WhenAll(tasks);
}
測定結果
各パターンを 5 回実行した平均実行時間の結果は以下の通りです
実行パターン | 平均実行時間 |
---|---|
通常の同期処理 | 3271ms |
非同期タスクの順次実行 | 5371ms |
Task.Run 使用の並列実行 | 2405ms |
並列処理にしたことにより、通常の同期処理に比べて明らかに高速化していることを確認しました。
また非同期タスクの順次実行について今回対象の処理が完全な CPU 処理であり、内部で待機を行っていないためTask
の生成周りのコストのみ発生したため同期処理よりも時間がかかっているようです。ただしTask
生成まわりで正直2秒も時間がかかかるようになるとは思えないため追加で調査を使用と思います。
またTask.WhenAll
を用いて平行処理を実装したのですが実装方法によって内部的な挙動が全然異なるため合わせて紹介します。
Task.WhenAll を用いた並列処理
パッと思いついた実装方法が下記2通りで、最初は同じ処理時間になると思っていたのですが測定してみたところ全く異なる処理時間になりました。
// 方法1
var tasks = new[]
{
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize)),
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize)),
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize))
};
await Task.WhenAll(tasks);
// 方法2
await Task.WhenAll(
UseNormalForLoopAwait(loopNum, arraySize),
UseNormalForLoopAwait(loopNum, arraySize),
UseNormalForLoopAwait(loopNum, arraySize)
);
皆様は今回のケースでどちらの方が高速だったかわかりますでしょうか?
結論から言うと方法1のほうが高速で、方法2は非同期タスクを順次実行したときと同じ時間になりました。その理由をそれぞれの挙動とともに解説します。
方法 1: Task.Run を使用した物理的な並列処理
var tasks = new[]
{
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize)),
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize)),
Task.Run(() => UseNormalForLoopAwait(loopNum, arraySize))
};
await Task.WhenAll(tasks);
この方法は指定された数のスレッドが即座に割り当てられ、物理的に並列で処理が実行されます。そのためCPU 集約的な処理に向いています。
方法 2: 非同期メソッドの直接実行
// 並列で実行
await Task.WhenAll(
UseNormalForLoopAwait(loopNum, arraySize),
UseNormalForLoopAwait(loopNum, arraySize),
UseNormalForLoopAwait(loopNum, arraySize)
);
この方法は。基本的にはスレッドは 1 つのままで、CPU が空いたタイミングで他の処理を進めます。そのためIO 待機が多い処理を効率的に進めるのに向いています(例:Task.Delay
、ファイル読み込み、Web API 呼び出しなど)。
2 つの方法の違い
物理的な並列処理(方法 1)
- ✅ 複数スレッドを用いた物理的な並列処理
- 複数の人が同時に働いている状態
- CPU 集約的なタスクに適している
- スレッドプールから複数のスレッドを使用
論理的な並行処理(方法 2)
- ✅ 非同期 I/O を前提とした効率的なスレッド再利用による論理的な並行処理
- 1 人が手が空いたときに別のタスクもこなす状態
- I/O 待機が多いタスクに適している
- 主に 1 つのスレッドで効率的に処理
使い分けの指針
Task.Run を使用すべき場合
- CPU 集約的な処理(計算処理、画像処理など)
- 同期的な処理を非同期で実行したい場合
- 明示的に別スレッドで実行したい場合
直接実行すべき場合
- I/O 集約的な処理(ファイル読み込み、データベースアクセス、Web API 呼び出し)
- 既に非同期メソッドとして実装されている処理
- スレッドプールのスレッドを無駄に消費したくない場合
注意点
Task.Run の過度な使用は避ける
// ❌ 良くない例:既に非同期のメソッドをTask.Runで包む
await Task.Run(async () => await SomeAsyncMethod());
// ✅ 良い例:直接実行
await SomeAsyncMethod();
まとめ
Task を用いた処理の並列化について調査・検証しました。
Task.WhenAll を使った並列処理では、処理の性質に応じて適切な実装方法を選択することが重要です。
-
CPU 集約的処理 →
Task.Run
を使用した物理的並列処理 - I/O 集約的処理 → 非同期メソッドの直接実行による論理的並行処理
今回の検証により、CPU 集約的な処理においては Task.Run を使用した物理的並列処理が大幅な性能向上をもたらすことが確認できました。適切な使い分けにより、パフォーマンスの向上とリソースの効率的な利用を実現できます。
また並列化した処理の中で例外が発生したような場合については今回考えていなかったため、そちらも別途調査して記事にしようと思います。
作成したソースは下記に格納しました。
この記事が皆様のコーディングライフの助けになれば幸いです。
参考