タスクによって実行される操作は、実行する前にスケジュールする必要があります。.NET はデフォルトでスレッド プール ベースのスケジューラを使用するため、タスクはデフォルトでスレッド プール スレッドで実行されます。ただし、一部の操作はスレッド プールの使用に適していません。たとえば、ASP.NET Core アプリケーションで長時間実行する必要があるバックグラウンド操作をホストします。スレッド プールは HTTP 要求の処理に使用されるため、これらのバックグラウンド操作もスレッド プールを使用してスケジュールするため、相互に影響を及ぼします。この場合、これらのバックグラウンド操作を実行するために別のスレッドを使用する方が良い選択となる可能性があります。
1. スレッドプールに基づくスケジューリング
次の簡単なプログラムを使用して、スレッド プールに基づいたデフォルトのタスク スケジュールを確認します。Task 型の静的プロパティ Factory を呼び出して TaskFactory オブジェクトを返し、その StartNew メソッドを呼び出して Task オブジェクトを開始します。この Task が指す Run メソッドはループで Do メソッドを呼び出します。Do メソッドは、スピン待機によって 2 秒かかる操作をシミュレートし、現在のスレッドの IsThreadPoolThread プロパティをコンソールに出力して、スレッド プール スレッドであるかどうかを判断します。
Task.Factory.StartNew(Run);
Console.Read();
void Run()
{
while (true)
{
Do();
}
}
void Do()
{
var end = DateTime.UtcNow.AddSeconds(2);
SpinWait.SpinUntil(() => DateTimeOffset.UtcNow > end);
var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
Console.WriteLine($"[{DateTimeOffset.Now}]Is thread pool thread: {isThreadPoolThread}");
}
以下に示す出力結果から、TaskFactory によって作成されたタスクは、デフォルトでスレッド プールを通じて実際にスケジュールされるという答えが得られました。
二、TaskCreationOptions.LongRunning
当然のことながら、上記の Run メソッドは永続的に実行する必要がある LongRunning オペレーションであり、スレッド プールを使用した実行には適していませんが、実際、TaskFactory ではこの点を考慮して設計されています。タスクの場合、対応する TaskCreationOptions オプションを指定できます。そのうちの 1 つは LongRuning です。上記のプログラムを次のように修正し、StartNew メソッドを呼び出すときにこのオプションを指定しました。
Task.Factory.StartNew(Run, TaskCreationOptions.LongRunning);
Console.Read();
void Run()
{
while (true)
{
Do();
}
}
void Do()
{
var end = DateTime.UtcNow.AddSeconds(2);
SpinWait.SpinUntil(() => DateTimeOffset.UtcNow > end);
var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
Console.WriteLine($"[{DateTimeOffset.Now}]Is thread pool thread: {isThreadPoolThread}");
}
プログラムを再度実行すると、次の出力から、Do メソッドがスレッド プール スレッドで実行されないことがわかります。
3 番目に、非同期操作についてはどうでしょうか?
LongRunning 操作には IO 操作が含まれることが多いため、実行メソッドは非同期形式で記述されることがよくあります。以下に示すコードでは、Do メソッドを DoAsync に置き換え、2 秒のスピン待機を Task.Delay に置き換えます。DoAsync は非同期形式で記述されているため、Run も対応する RunAsync に置き換えられます。
Task.Factory.StartNew(RunAsync, TaskCreationOptions.LongRunning);
Console.Read();
async Task RunAsync()
{
while (true)
{
await DoAsync();
}
}
async Task DoAsync()
{
await Task.Delay(2000);
var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
Console.WriteLine($"[{DateTimeOffset.Now}]Is thread pool thread: {isThreadPoolThread}");
}
プログラムを再起動すると、再びスレッド プール スケジューリングに切り替わったことがわかりました。なぜそうなるのでしょうか? 実際、これは簡単に理解できます。void を返す元の Run メソッドが Task を返す RunAsync に置き換えられたため、StartNew メソッドに渡される委託の種類は、実行操作が Action から Func<Task> に切り替わったことを示しています。 LongRunning オプションを指定しましたが、StartNew メソッドはこのモードを使用して Func<Task> デリゲート オブジェクトを実行するだけであり、このデリゲートは await が発生すると戻ります。返された Task オブジェクトに関しては、引き続きデフォルトの方法でスケジュールされ、実行されます。
4番目に、別の書き方はどうでしょうか?
上記ではデリゲート オブジェクトをパラメータとして表すメソッドを使用していると言う人もいますが、次のように async/await ベースの Lambda 式を使用したらどうなるでしょうか? 実際、このような Lambda 式は、Func<Task> の別のプログラミング手法にすぎません。
Task.Factory.StartNew(async () => { while (true) await DoAsync();}, TaskCreationOptions.LongRunning);
Console.Read();
async Task DoAsync()
{
await Task.Delay(2000);
var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
Console.WriteLine($"[{DateTimeOffset.Now}]Is thread pool thread: {isThreadPoolThread}");
}
5. Wait メソッドを呼び出す
実際、この問題は簡単に解決できます。次の方法で DoAsync メソッドを同期 Do に置き換え、await ベースの待機を Wait メソッドの呼び出しに置き換えます。Task に触れるとき、多くの人が、現在のスレッドをブロックしてしまうため、Wait メソッドを使用する際は注意してくださいと注意し続けると思います。実際、現在のアプリケーション シナリオでは、Wait メソッドを呼び出すのが正しい選択です。これは、本来の目的が独立したスレッドを使用して排他的な方法でバックグラウンド操作を実行することであるためです。
Task.Factory.StartNew(() => { while (true) Do(); }, TaskCreationOptions.LongRunning);
Console.Read();
void Do()
{
Task.Delay(2000).Wait();
var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
Console.WriteLine($"[{DateTimeOffset.Now}]Is thread pool thread: {isThreadPoolThread}");
}
6、カスタム TaskScheduler
スレッドプールの使用は「タスクのスケジューリング」が原因なので、TaskSchedulerを書き換えれば当然解決できます。次のカスタム DendedThreadTaskScheduler は、独立したスレッドを使用してスケジュールされたタスクを実行します。スレッドの数はパラメーターで指定できます。
internal sealed class DedicatedThreadTaskScheduler : TaskScheduler
{
private readonly BlockingCollection<Task> _tasks = new();
private readonly Thread[] _threads;
protected override IEnumerable<Task>? GetScheduledTasks() => _tasks;
protected override void QueueTask(Task task) => _tasks.Add(task);
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false;
public DedicatedThreadTaskScheduler(int threadCount)
{
_threads = new Thread[threadCount];
for (int index = 0; index < threadCount; index++)
{
_threads[index] = new Thread(_ =>
{
while (true)
{
TryExecuteTask(_tasks.Take());
}
});
}
Array.ForEach(_threads, it => it.Start());
}
}
このデモンストレーション例では、次に示すように、Run/Do メソッドが純粋な非同期モードの RunAsync/DoAsync に復元され、StartNew メソッドが呼び出されるときに、最後のパラメーターとして D dedicatedThreadTaskScheduler オブジェクトが作成されます。
Task.Factory.StartNew(RunAsync, CancellationToken.None, TaskCreationOptions.LongRunning, new DedicatedThreadTaskScheduler(1));
Console.Read();
async Task RunAsync()
{
while (true)
{
await DoAsync();
}
}
async Task DoAsync()
{
await Task.Delay(2000);
var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
Console.WriteLine($"[{DateTimeOffset.Now}]Is thread pool thread: {isThreadPoolThread}");
}
作成された Task は指定された D dedicatedThreadTaskScheduler オブジェクトを使用してスケジュールされるため、当然、スレッド プールのスレッドでは DoAsync メソッドは実行されません。
7 つの独立したスレッド プール
.NET が提供するスレッド プールはグローバルに共有されるスレッド プールであり、ここで定義した D dedicatedThreadTaskScheduler は独立したスレッド プールを作成することに相当します。オブジェクト プールの効果は、次のような簡単なプログラムで示すことができます。
Task.Factory.StartNew(()=> Task.WhenAll( Enumerable.Range(1,6).Select(it=>DoAsync(it))),
CancellationToken.None,
TaskCreationOptions.None,
new DedicatedThreadTaskScheduler(2));
async Task DoAsync(int index)
{
await Task.Yield();
Console.WriteLine($"[{DateTimeOffset.Now.ToString("hh:MM:ss")}]Task {index} is executed in thread {Environment.CurrentManagedThreadId}");
var endTime = DateTime.UtcNow.AddSeconds(4);
SpinWait.SpinUntil(() => DateTime.UtcNow > endTime);
await Task.Delay(1000);
}
Console.ReadLine();
上記のコード スニペットに示されているように、非同期メソッド DoAsync は、スピン待機を使用して 4 秒かかる操作をシミュレートし、Task.Delay メソッドを呼び出すことによって 1 秒かかる IO 操作をシミュレートします。そこには、タスクの実行が開始された時刻と現在のスレッド ID が出力されます。呼び出された StartNew メソッドでは、この DoAsync メソッドを呼び出して 6 つのタスクを作成します。これらのタスクは、スケジュールのために作成された D dedicatedThreadTaskScheduler に渡されます。この D dedicatedThreadTaskScheduler に指定したスレッドの数は 2 です。以下に示す出力からわかるように、6 つの操作は実際に 2 つのスレッドで実行されます。