WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Task.Delay メソッドの謎

by WebSurfer 21. July 2021 18:27

Microsoft のドキュメントの Task.Delay メソッドの説明には "遅延後に完了するタスクを作成します。 Creates a task that will complete after a time delay." と書いてあります。

その「タスクを作成」とはどういう意味でしょう? 作成されたタスクは何がどのように動かしているのでしょう? 色々調べたのですが分かりませんでした。謎が解けたら追記しますが、とりあえず今まで調べたことを整理して書いておきます。 ⇒ 謎はまだ完全には解けていませんが、その後調べて分かったことを別の記事「Task.Delay メソッドの謎 (続き)」に書きました

とにかく、Task.Delay メソッドが作成した「遅延後に完了するタスク」は作成後動いていることは間違いなさそうです。(そんなの当たり前?)

なぜなら、(1) Task.Delay メソッドの第 1 引数に与えられた時間通りに遅延&完了しますが、そもそもタスクが動いてなければ開始も完了もないはず、(2) 第 2 引数で CancellationToken を渡すオーバーロードではキャンセルすると TaskCanceledException がスローされるのですが、それには常にキャンセル通知をチェックする必要があるはず・・・ですから。

では、一体何がその「遅延後に完了するタスク」を動かしているのでしょう?

以前は Task.Delay メソッドが「遅延後に完了するタスク」を作成して、スレッドプールからスレッドを取得し、作成したタスクを実行すると思っていたのですが、以下のコードで検証した結果を見る限りそうではなさそうな感じです。

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.ForLoopAsync();
        }

        // 非同期メソッド
        public async Task<string> WorkAsync(int number)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            DateTime start = DateTime.Now;
            string retunVlaue = $"n = {number}, ThreadID: {id}" +
                                $", start: {start:ss.fff}, ";

            // ここで 3 秒遅延
            await Task.Delay(3000);            

            // 検証用。詳細は後述
            //await Task.Run(() => Thread.Sleep(3000));
            //await Task.Run(() => Task.Delay(3000).Wait());

            DateTime end = DateTime.Now;
            TimeSpan diff = start - end;
            retunVlaue += $"end: {end:ss.fff}, timespan: {diff:s\\.fff}";
            return retunVlaue;
        }

        // 非同期メソッド WorkAsync を for ループで 100 実行
        public async Task ForLoopAsync()
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            DateTime start = DateTime.Now;
            Console.WriteLine($"Main Thread ID = {id}, " +
                $"ForLoopAsync 開始: {start:ss.fff}");

            Task<string>[] taskResults = new Task<string>[100];
            for (int i = 0; i < 100; i++)
            {
                int n = i;
                taskResults[n] = WorkAsync(n);
            }

            await Task.WhenAll(taskResults);

            foreach (Task<string> result in taskResults)
            {
                Console.WriteLine(result.Result);
            }

            DateTime end = DateTime.Now;
            TimeSpan diff = start - end;
            Console.WriteLine($"Main Thread ID = {id}, " +
                $"終了: {end:ss.fff}, 所要時間: {diff:s\\.fff}");
        }
    }
}

上のコードの実行結果は以下のようになります。画像には n = 20 までしか示していませんがその後 n = 99 まで続きます。

ForLoopAsync メソッド実行結果

n = 0 ~ 99 の start が 00.065 ~ 00.082 の範囲 (17ms)に入っており、end はすべて同じ時刻 03.122 になり、アプリ全体の所要時間は Task.Delay(3000) による遅延とほぼ同じ 3.072 秒という結果でした

ということは、17ms の間に for ループで 100 個の WorkAsync メソッドが同期的に開始され、それぞれの WorkAsync メソッドの中の Task.Dealy(3000) で 3000ms 遅延後に完了するタスクが作成・実行され、await でタスクの終了を待機し、待機が終わって WorkAsync メソッドが時刻 03.122 に 100 個同時に終わり(1ms 以下の差はあるかも)、全体のアプリが 3.072 秒で完了したということになります。ホントかなぁ・・・という気はしますが結果の画像はそう言っているようです。

結果から、Task.Dealy(3000) で作成された「遅延後に完了するタスク」100 個はスレッドプールからスレッドを取得して実行されたという訳ではなく、単に 3 秒止まっていただけというように見えます。

スレッドプールのスレッドを使う場合、先の記事「タスク並列ライブラリ (TPL) その 2」で書きましたように、スレッドプールから一度に取得できるスレッドの数は限られているので(検証に使った PC ではデフォルトで 8)、スレッドの取得にかかる時間を考えるとアプリ全体が 3.072 秒で完了ということはないからです。

(Task.Dealy が作成する「遅延後に完了するタスク」は特別で、スレッドプールから一度に 100 個スレッドを取得できるということなら話は別ですが)

Task.Dealy を実行したスレッドが、Task.Dealy が作成した「遅延後に完了するタスク」を実行することはもちろんなさそうです。そもそも、Thread.Sleep と違って、スレッドをブロックしないようにするのが Task.Delay のはずですから。

という訳で、一体何がどのようにTask.Dealy が作成した「遅延後に完了するタスク」を実行しているのかが謎です。今後の課題ということで、調べて何か分かったら追記します。

ちなみにですが、上のサンプルコードの、

await Task.Delay(3000);

を、

await Task.Run(() => Thread.Sleep(3000));

に代えると結果が違ってきます。Task.Run で Thread.Sleep(3000) をスレッドプールで実行するタスクとしてキューに並べるので、OS がスレッドプールからスレッドを取得して Thread.Sleep(3000) を実行し、その際スレッドは 3 秒ブロックされます。結果は以下のようになります。

Thread.Sleep(3000) の場合

n = 0 ~ 99 の start が 23.257 ~ 23.312 の範囲 (55ms)に入っていますが、end は 26.228 ~ 43.305 となっており、Thread.Sleep(3000) を実行するためのスレッドの取得に時間がかかっていることが分かります。アプリ全体の所要時間は 20.072 秒という結果でした。

もう一つオマケで書いておきます。 上の Thread.Sleep(3000) を Task.Delay(3000).Wait() に代えると少し様子が違ってきます。

Task.Delay(3000).Wait() の場合

n = 0 ~ 99 の start は 03.056 ~ 03.114 の範囲 (58ms)、end は 06.553 ~ 28.576 の範囲、全体の所要時間は 25.540 秒という結果でした。Thread.Sleep に比べて全体の所要時間が長くなることと、n = 5, 9 に必要以上の時間スレッドを解放できない特異点的なものがあるのが気になります。

Tags: , , , ,

.NET Framework

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  September 2021  >>
MoTuWeThFrSaSu
303112345
6789101112
13141516171819
20212223242526
27282930123
45678910

View posts in large calendar