注意: 以下は .NET 5.0 のコンソールアプリの例です。.NET 6.0 ではスレッドプールからのスレッドの取得に改善があったようで結果が大幅に異なります (Parallel Link は改善なし)。.NET 6.0 での結果を下の方に追記しておきます。
先の記事「タスク並列ライブラリ (TPL)」で、TPL に代えて、複数のタスクをスレッドプールで実行するように設定し、並列化については OS 任せにするコードを紹介しました。
TPL を使った場合は "使用可能なすべてのプロセッサを最も効率的に使用するようにコンカレンシーの程度を動的に拡大" するそうですが、それとの違いを調べてみました。(独断&自分流の調べ方なのでハズレがあるかも)
(1) TPL, PLINK を使わない場合
下の画像は、TPL, PLINK は使わないで、Task.Delay(3000).Wait() で 3 秒遅延するコードを含む同期メソッドを、Task.Run メソッド を使って 100 個キューに配置し、終了を await Task.WhenAll(...) で待機した結果です。
コードはこの記事の下の方に記載したサンプルコードを見てください。その中で、実行対象の同期メソッドが Work、それを 100 個キューに置いてスレッドプールで実行するのが TaskRunAsync メソッドです。
環境は Windows 10 Pro 64-bit、Core i7-9700K 8 コア 8 論理プロセッサ、Visual Studio 2019、.NET 5.0 のコンソールアプリです。
で、TPL を使った場合との比較ですが、同じ同期メソッド Work を 100 個 Parallel.Invoke, Parallel.For, Parallel.ForEach で実行した結果と比べると、全体の実行時間はどれも 25 秒前後でほとんど違いはなかったです。(なぜか Parallel LINK は後述するように期待外れでした)
唯一気になった違いは、上の画像の n = 2, n = 4, n = 18 のように必要以上の時間スレッドを解放できないケースがあるということです。TPL にはそれは無かったです。全体の実行時間が TPL と比べて 1 ~ 2 秒遅かったのはそのせいかもしれません。問題の種を含んでいるということなのでしょうか。
ほかに興味深かったのは、PC のコア数(画像の minWorker: 8 がそれ)まではスレッドプールから一気にスレッドを取得できるが(n = 0 ~ 7)、さらにスレッドを取得しようとすると少し時間がかかる(n = 8, 9, 10, 11)ということでした。
それは CLR スレッドプールの仕様らしいです。ネットで見つけた記事「ThreadPool Growth: Some Important Details」に書いてありましたが 500ms かかるとのことです。(Microsoft の公式文書は見つけられていませんが結果を見る限り間違いなさそう)
500ms の制限は TPL を使用しても同じらしいです。なので、TPL を使用するしないにかかわらず、スレッドプールのスレッドをバースト的に多数使用する場合は設定を変更するのが良さそうです。(設定方法は上に紹介した記事に書いてあります)
以下に、Parallel.Invoke メソッド、Parallel.For メソッド、Parallel.ForEach メソッド、Parallel LINK での結果の画像を貼っておきます。どのようにしたかは下に記載したサンプルコードを見てください。それぞれ実装が違うようで、結果もそれぞれ異なっています。(結果の違いが判るだけで、具体的に中の動きがどう違った結果そうなるのかは分かりませんが)
(2) Parallel.Invoke メソッド
(3) Parallel.For メソッド
(4) Parallel.ForEach メソッド
(5) Parallel LINK
Parallel LINK の場合、全体の実行時間が 42 秒前後となり、TPL と比べて 7 割弱増えてしまいました。実行中の挙動を見ていると、途中で一旦結果が表示されて止まってしまい、何秒かののち再開されて最後まで実行されるという感じです。
TPL とは実装が大きく異なるのでしょうか? 前のスレッドで書いたように await Task.Run(() => ... を使っても UI スレッドがブロックされるのは避けられませんでしたし。それとも自分の使い方が間違っているのでしょうか?
以下に上に書いた検証に使用したサンプルコードを記載しておきます。
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
namespace ConsoleAppWenAllParallelFor
{
class Program
{
static async Task Main(string[] args)
{
int minWorker; // ワーカースレッドの最小数(PC のコア数と同じ)
int minIOC; // 非同期 I/O スレッドの最小数
ThreadPool.GetMinThreads(out minWorker, out minIOC);
Console.WriteLine($"minWorker: {minWorker}, minIOC: {minIOC}");
int maxWorker; // ワーカー スレッドの最大数
int maxIOC; // 非同期 I/O スレッドの最大数
ThreadPool.GetMaxThreads(out maxWorker, out maxIOC);
Console.WriteLine($"maxWorker: {maxWorker}, maxIOC: {maxIOC}");
var prog = new Program();
await prog.TaskRunAsync();
//await prog.ParallelInvokeAsync();
//await prog.ParallelForAsync();
//await prog.ParralelForEachAsync();
//await prog.PLinkAsync();
}
// 同期メソッド
public string Work(int number)
{
int id = Thread.CurrentThread.ManagedThreadId;
DateTime start = DateTime.Now;
string retunVlaue = $"n = {number}, ThreadID = {id}" +
$", start: {start:ss.fff}, ";
// ここで 3 秒遅延
Task.Delay(3000).Wait();
DateTime end = DateTime.Now;
TimeSpan diff = start - end;
retunVlaue += $"end: {end:ss.fff}, timespan: {diff:s\\.fff}";
return retunVlaue;
}
// タスク (この記事の例では同期メソッド Work) を 100 個 Task.Run
// メソッドでキューに配置する。OS がスレッドプールから適宜スレッ
// ドを取得してキューのタスク実行。await Task.WhenAll ですべての
// タスクの完了を待機する
public async Task TaskRunAsync()
{
int id = Thread.CurrentThread.ManagedThreadId;
DateTime start = DateTime.Now;
Console.WriteLine($"Main Thread ID = {id}, " +
$"TaskRunAsync 開始: {start:ss.fff}");
string[] stringResults = new string[100];
var taskList = new List<Task>();
for (int i = 0; i < 100; i++)
{
int n = i;
taskList.Add(Task.Run(() => stringResults[n] = Work(n)));
}
await Task.WhenAll(taskList);
foreach (string result in stringResults)
{
Console.WriteLine(result);
}
DateTime end = DateTime.Now;
TimeSpan diff = start - end;
Console.WriteLine($"Main Thread ID = {id}, " +
$"終了: {end:ss.fff}, 所要時間: {diff:s\\.fff}");
}
// タスク (この記事の例では同期メソッド Work) を Parallel.Invoke
// を使って 100 個実行。Parallel.Invoke の機能により可能な限り
// 並列実行されるはず
public async Task ParallelInvokeAsync()
{
int id = Thread.CurrentThread.ManagedThreadId;
DateTime start = DateTime.Now;
Console.WriteLine($"Main Thread ID = {id}, " +
$"ParallelInvokeAsync 開始: {start:ss.fff}");
string[] stringResults = new string[100];
Action[] actions = new Action[100];
for (int i = 0; i < 100; i++)
{
int n = i;
actions[n] = () => stringResults[n] = Work(n);
}
await Task.Run(() => Parallel.Invoke(actions));
foreach (string result in stringResults)
{
Console.WriteLine(result);
}
DateTime end = DateTime.Now;
TimeSpan diff = start - end;
Console.WriteLine($"Main Thread ID = {id}, " +
$"終了: {end:ss.fff}, 所要時間: {diff:s\\.fff}");
}
// Parallel.For を使って 100 個実行
public async Task ParallelForAsync()
{
int id = Thread.CurrentThread.ManagedThreadId;
DateTime start = DateTime.Now;
Console.WriteLine($"Main Thread ID = {id}, " +
$"ParallelForAsync 開始: {start:ss.fff}");
string[] stringResults = new string[100];
await Task.Run(() => Parallel.For(0, 100,
(n) => stringResults[n] = Work(n)));
foreach (string result in stringResults)
{
Console.WriteLine(result);
}
DateTime end = DateTime.Now;
TimeSpan diff = start - end;
Console.WriteLine($"Main Thread ID = {id}, " +
$"終了: {end:ss.fff}, 所要時間: {diff:s\\.fff}");
}
// Parallel.ForEach を使って 100 個実行
public async Task ParralelForEachAsync()
{
int id = Thread.CurrentThread.ManagedThreadId;
DateTime start = DateTime.Now;
Console.WriteLine($"Main Thread ID = {id}, " +
$"ParallelForEachAsync 開始: {start:ss.fff}");
string[] stringResults = new string[100];
await Task.Run(() => Parallel.ForEach(Enumerable.Range(0, 100),
(n) => stringResults[n] = Work(n)));
foreach (string result in stringResults)
{
Console.WriteLine(result);
}
DateTime end = DateTime.Now;
TimeSpan diff = start - end;
Console.WriteLine($"Main Thread ID = {id}, " +
$"終了: {end:ss.fff}, 所要時間: {diff:s\\.fff}");
}
// PLINK を使って 100 個実行
public async Task PLinkAsync()
{
int id = Thread.CurrentThread.ManagedThreadId;
DateTime start = DateTime.Now;
Console.WriteLine($"Main Thread ID = {id}, " +
$"PLinkAsync 開始: {start:ss.fff}");
var results = await Task.Run(() =>
Enumerable.Range(0, 100).AsParallel()
.Select(n => Work(n)));
foreach (string result in results)
{
Console.WriteLine(result);
}
DateTime end = DateTime.Now;
TimeSpan diff = start - end;
Console.WriteLine($"Main Thread ID = {id}, " +
$"終了: {end:ss.fff}, 所要時間: {diff:s\\.fff}");
}
}
}
2022/8/9 追記
.NET 6.0 で上のコードと同じアプリを作成し「(1) TPL, PLINK を使わない場合」と「(2) Parallel.Invoke メソッド」を実行した結果を以下に載せておきます。
Parallel.For, Parallel.ForEach の結果もほぼ同じです。Parallel Link は、理由不明ですが、改善なしでした。
スレッドプールから一度に取得できるスレッド数とそれを超えて追加でスレッドを取得する際に要する時間が改善されたようで、全体の実行時間が .NET 5.0 では 25 秒前後かかっていたものが .NET 6.0 では 9 秒前後と大幅に短縮されています。
(1) TPL, PLINK を使わない場合 (.NET 6.0)
(2) Parallel.Invoke メソッド (.NET 6.0)