WebSurfer's Home

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

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

by WebSurfer 2021年7月28日 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[100];
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

About this blog

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

Calendar

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

View posts in large calendar