WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Task.Delay メソッドの謎 (続き)

by WebSurfer 28. July 2021 18:56

先の記事「Task.Delay メソッドの謎」の続きです。謎はまだ完全には解けていませんが、その後調べて分かったことをこの記事に書きます。(先の記事に続けて書くと長くなりすぎるので分けました)

まず、Microsoft のドキュメント Task.Delay メソッドの説明に書いてある "遅延後に完了するタスクを作成します" の「タスクを作成」とはどういうことかですが、それについては .NET Framework 4.8 ライブラリのソースコード Task.cs の Task.Delay メソッドを調べてみました。

ソースコードを簡略化して書くと以下のようになります。つまり、Task.Delay で System.Threading.Timer のインスタンスを生成し、それを Task クラスにラップして返しています。

public class Task
{
    public static Task Delay(int millisec)
    {
        var promise = new DelayPromise();

        promise.Timer = new System.Threading.Timer(
                            state => ((DelayPromise)state).Complete(),
                            promise,
                            millisec,
                            Timeout.Infinite);
        return promise;            
    }

    private sealed class DelayPromise : Task
    {
        internal System.Threading.Timer Timer;

        internal void Complete()
        {
            if (Timer != null) Timer.Dispose();
        }
    }
}

という訳で Task.Delay メソッドで作成されたタスクの実体は System.Threading.Timer ということは分かりました。

次なる謎は先の記事の Task.Delay を使ったサンプルコードの動きです。

先の記事のサンプルコードは、メインスレッドで 100 個の WorkAsync メソッドを同期的に順次開始し、await Task.WhenAll ですべての WorkAsync メソッドの完了を待機するという形になっています。WorkAsync メソッドには await Task.Delay(3000) が含まれています。サンプルコードを簡略化して書くと以下のようになります。

public async Task<string> WorkAsync(int number)
{
    // ・・・省略・・・
    string retunVlaue = $"n = {number}, ThreadID: {id}" +
                        $", start: {start:ss.fff}, ";
 
    await Task.Delay(3000);

    // ・・・省略・・・
    return retunVlaue;
}

public async Task ForLoopAsync()
{
    // ・・・省略・・・
 
    Task<string>[] taskResults = new Task<string>[100];

    taskResults[0] = WorkAsync(0);
    taskResults[1] = WorkAsync(1);
    taskResults[2] = WorkAsync(2);
    // ・・・省略・・・
    taskResults[99] = WorkAsync(99);
 
    await Task.WhenAll(taskResults);
 
    foreach (Task<string> result in taskResults)
    {
        Console.WriteLine(result.Result);
    }

    // ・・・省略・・・
}

ForLoopAsync メソッドを実行すると、まず最初に WorkAsync(0) が実行され、その中の await Task.Delay(3000) で 3 秒待機してから WorkAsync(0) が完了し、次の WorkAsync(1) メソッドの実行に進むというように、同期的に順次 WorkAsync(0) から WorkAsync(99) まで実行されていくと思っていました。

その場合、アプリ全体の実行時間は 3 秒 x 100 = 5 分かかるはずです。しかしながら、実際は先に記事に書いたように 3 秒少々で全体が完了します。そこが謎だったのですが、デバッガを使って追いかけてみて謎が分かりました。

最初に WorkAsync(0) が実行されその中の await Task.Delay(3000); の行が実行されると、そこで 3 秒待機するのではなく直ちに次の WorkAsync(1) が呼ばれます。WorkAsync(1) の中の await Task.Delay(3000); の行が実行されると直ちに次の WorkAsync(2) が呼ばれるというように、一気に最後の WorkAsync(99) まで呼ばれるところまで進んでから await Task.Delay(3000); で待機します。

そして 3 秒の待機が終わると、WorkAsync(0) から WorkAsync(99) の await Task.Delay(3000); の次の行から return retunVlaue; までが一気に実行され、戻り値が Task<string> 型の配列 taskResults に代入されるという動きになります。

それゆえアプリ全体の実行時間は 3 秒少々しかかからないという結果になったようです。

サンプルコードの実行時間が 3 秒少々しかかかからなかった理由は分かりましたが、なぜ WorkAsync(0) から WorkAsync(99) のそれぞれで同期的に 3 秒待機してから次に進むのではなく、WorkAsync メソッドが最初から最後まで一気に呼ばれてから待機するのかは分かりません。

ちなみに、Task.WhenAll は使わないで各 WorkAsync メソッドに await を付与すると各メソッドの実行時に 3 秒待機してから次のメソッドが実行されるようになります。

string[] stringResults = new string[3];
stringResults[0] = await WorkAsync(0);
stringResults[1] = await WorkAsync(1);
stringResults[2] = await WorkAsync(2);
// ・・・省略・・・
stringResults[99] = await WorkAsync(99);

Task.WhenAll が影響しているような気がしますが、どうも自分には Task とか Timer に関する理解が致命的に欠けているようで、そのあたりメカニズムが分かりません。勉強が足りないようです。


以下はオマケです。

上のような自作 Task.Delay メソッドのコードを実装しても期待通りには動きませんでした。原因不明ですが、いろいろ試した結果とそれに基づく想像を備忘録として以下に書いておきます。

当たり前ですが、.NET Framework ライブラリを使って await Task.Delay(3000) とした場合はそこで 3 秒待機した後次の行に進みます。ところが、上の自作コードを使って Task.Delay(3000) というように「タスクを作成」してそれを await するとそこで止まってしまいます。()

DelayPromise コンストラクタを呼び出して作成した Task なので「タスクを作成」した時点では TaskStatus は Created になります。その後 TaskStatus は Created のまま変わらないので await するとそこで止まってしまうということのようです。 (TaskStatus が RanToCompletion にならないと await は抜け出せないようです)

Task を Start するコードを追加すると、TaskStatus は WaitingToRun ⇒ Running ⇒ RanToCompletion と変わっていきますが await で待機しません。設定した遅延時間の 3 秒後に Complete コールバックに制御は飛んでくるので Task は期待通り動いているようですが、.NET Framework の制御を外れて勝手に動いているという感じです。

ただし、普通に Task コンストラクタで Task を生成して Start する場合、例えば以下のようにすると .NET Framework の制御下で動くようです。(Start しないと TaskStatus は Created のまま変わらず、await で止まってしまうのは上と同じです)

Task t = new Task(() =>
    { 
        Console.WriteLine("new Task"); 
        Thread.Sleep(3000); 
    });

t.Start();
await t;

t.Start(); で TaskStatus は WaitingToRun となり、Thread.Sleep(3000); を抜けて Task が完了すると RanToCompletion に変わります。上のコードのように await t; とすると期待通りそこで待機します。await で待機中に Task が完了し TaskStatus が RanToCompletion に変わってから await を抜けるという感じです。

ちなみに、.NET Framework ライブラリの Task.Delay は Start せずとも TaskStatus は WaitingForActivation になって、設定した遅延時間の後 RanToCompletion になります。await で待機した場合は、待機中に Task が完了して TaskStatus が RanToCompletion になると await を抜けるようです。

ライブラリの場合は、await による .NET Framework の制御が働くよう、ライブラリに定義してある下記の Task のコンストラクタで設定される m_stateFlags が良しなに計らってくれているような気がします。(想像&気がするだけです)

internal Task()
{
    m_stateFlags = TASK_STATE_WAITINGFORACTIVATION | 
                   (int)InternalTaskOptions.PromiseTask;
}

Tags: , , , ,

.NET Framework

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

タスク並列ライブラリ (TPL) その 2

by WebSurfer 20. July 2021 14:11

先の記事「タスク並列ライブラリ (TPL)」で、TPL に代えて、複数のタスクをスレッドプールで実行するように設定し、並列化については OS 任せにするコードを紹介しました。

TPL を使った場合は "使用可能なすべてのプロセッサを最も効率的に使用するようにコンカレンシーの程度を動的に拡大" するそうですが、それとの違いを調べてみました。(独断&自分流の調べ方なのでハズレがあるかも)

並列化は OS 任せ

上の画像は、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.Invoke メソッド

Parallel.Invoke メソッド

Parallel.For メソッド

Parallel.For メソッド

Parallel.ForEach メソッド

Parallel.ForEach メソッド

Parallel LINK

Parallel LINK の場合、全体の実行時間が 42 秒前後となり、TPL と比べて 7 割弱増えてしまいました。実行中の挙動を見ていると、途中で一旦結果が表示されて止まってしまい、何秒かののち再開されて最後まで実行されるという感じです。

Parallel LINK

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}");
        }
    }
}

Tags: , , , , ,

.NET Framework

About this blog

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

Calendar

<<  July 2021  >>
MoTuWeThFrSaSu
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar