WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Task.Delay メソッドの謎

by WebSurfer 2021年7月21日 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 2021年7月20日 14:11

注意: 以下は .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(...) で待機した結果です。

並列化は OS 任せ

コードはこの記事の下の方に記載したサンプルコードを見てください。その中で、実行対象の同期メソッドが 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 メソッド

Parallel.Invoke メソッド

(3) Parallel.For メソッド

Parallel.For メソッド

(4) Parallel.ForEach メソッド

Parallel.ForEach メソッド

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

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)

並列化は OS 任せ (.NET 6.0)

(2) Parallel.Invoke メソッド (.NET 6.0)

Parallel.Invoke メソッド (.NET 6.0)

Tags: , , , , ,

.NET Framework

ASP.NET Core MVC の非同期プログラミング

by WebSurfer 2021年1月2日 17:40

先の記事「ASP.NET MVC の非同期プログラミング」は .NET Framework 版の MVC5 アプリの話ですが、ASP.NET Core 3.1 MVC アプリでも同様なことを検証しましたのでその結果を書きます。

ASP.NET Core 3.1 MVC の非同期プログラミング

以下に (1) async / await を利用した非同期プログラミングで使用されるスレッドがどのようになるか、(2) Task.Result などを使った同期コードと非同期コードを混在させるとデッドロックは起きるのか、(3) Task.ConfigureAwait(Boolean) メソッドを付与するとどう変わるかついて書きます。

意外だったのは、.NET Framework 版と違って、Task.Result を使ってもデッドロックならないということででした。

なお、Kestrel の場合はどうなるかですが、Visual Studio を使って IIS Express (インプロセス ホスティング) と Kestrel を切り替えて両方の動作を確認しました。どちらも同じ結果となりました。

(1) 使用されるスレッド

ASP.NET で非同期プログラミングを行う目的は、スレッドプールにある限られた数のスレッドを有効利用し、スループットを向上するためです。なので非同期メソッドのチェーンの一番深いところにある await 前後でスレッドが切り替わるはずです。

それを確認するために以下のコードで試してみた結果が上の画像です。期待通り、TimeCosumingMethod メソッドの await 前後で ManagedThreadId が 27 から 25 に変わっています。

public async Task<ActionResult> AsyncTest()
{
    ViewBag.Id1 = "開始時, ID=" +
                  Thread.CurrentThread.ManagedThreadId;

    ViewBag.Id2 = await TimeCosumingMethod();

    // .NET Framework 版の MVC5 アプリでは上に代えて以下のようにすると
    // デッドロックになったが Core 3.1 版ではデッドロックにはならない
    //ViewBag.Id2 = TimeCosumingMethod().Result;

    ViewBag.Id3 = "終了時, ID=" +
                  Thread.CurrentThread.ManagedThreadId;

    return View();
}

private async Task<string> TimeCosumingMethod()
{
    int id = Thread.CurrentThread.ManagedThreadId;

    await Task.Delay(3000);

    // ConfigureAwait(false) の付与は結果に関係なし。
    // デッドロックになる時は ConfigureAwait(false) が勝手に付与され、
    // デッドロックにならない時は付与しても無視されるような感じ
    //await Task.Delay(3000).
    //    ConfigureAwait(continueOnCapturedContext: false);

    return "TimeCosumingMethod の戻り値, ID(IN)=" + id +
           " / ID(OUT)=" + Thread.CurrentThread.ManagedThreadId;
}

(2) Task.Result の使用

先の記事「await と Task.Result によるデッドロック」で書いたように Task.Result を使った同期コードと非同期コードを混在させるとデッドロックは起きるのかということの確認です。

結果は下の画像の通り処理は無事完了し、デッドロックは起きなかったです。.NET Framework 版の MVC5 アプリで TimeCosumingMethod の await Task.Delay(3000); に .ConfigureAwait(false) を付与した場合と同じ結果です。すなわち ID(OUT) のみ ManagedThreadId が異なり他は同じになっています。

Task.Result の使用

Task.Result をどのように使ったかは上のコードの AsyncTest アクションメソッドのコメントを見てください。.NET Framework 版での検証と全く同じやり方ですです。

.NET Framework 版でデッドロックなる理由は: まず、TimeCosumingMethod().Result で 1 つの同期ブロックが待機中となる。呼び出された TimeCosumingMethod メソッドの await で待機する際にキャプチャされた「現在のコンテキスト」で await 完了後の同期処理を実行しようとする。しかし「現在のコンテキスト」では一度に実行するコードは 1 つのチャンクに限定されている。なので、Result プロパティでの待機が終わるまで await 完了後の同期処理は実行できない。結果デッドロックになる・・・ということで、Core 3.1 でも同じになると信じていたんですが、一体どうなっているのでしょう?

(3) ConfigureAwait の付与

AsyncTest アクションメソッドで Result を使うか否かで結果は上の画像のように変わりますが、上のコードの TimeCosumingMethod のコメントに書きましたように、ConfigureAwait(false) の付与はその結果に関係なかったです。

Core では AsyncTest アクションメソッドで Result を使ってデッドロックになる時は ConfigureAwait(false) が勝手に付与され、正しく await してデッドロックにならない時は ConfigureAwait(false) を付与しても無視されているような感じです。

.NET Framework 版とは話が大きく変ってきてしまうのですが、一体どうなっているのでしょう。どうも今までの知識は Core には役に立たないようで、また勉強しなければならないようです。でも、今はその気力がないです。(笑)

Tags: , , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar