4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

さいきょうの .NET async/await 見える化計画(なお printf デバッグのもよう)

Last updated at Posted at 2025-07-11

※ この記事で紹介するコードの全体像は GitHub にて公開しています。ご興味のある方は、以下のリポジトリで確認できます。
https://github.com/cozyupk/misc/tree/main/src/task-continuation-probe

本記事ではソースコードを一部かいつまんで説明をさせていただきます。

スレッド攪拌処理の導入

課題

C# では Environment.CurrentManagedThreadId を利用して、「現在そのコードが実行されているマネージドスレッドID」を取得することができます。そしてそれをいわゆる "printfデバッグ" (実際にはもちろん Console.WriteLine だったりするわけですが) することで、非同期コードの挙動を観察することができます。

※ 本記事の以下の記述では、「マネージドスレッドID」を単に「スレッドID」と呼びます。

例えばこんなコードを書いて、

        /// <summary>
        /// ダミーの非同期メソッドです。スレッドIDを出力し、指定されたラベルを付けて待機します。
        /// </summary>
        static async Task DummyMethod(ThreadProbe threadProbe, string label)
        {
            threadProbe.WriteLineThreadID($"{label} - before await()", 2);
            await Task.Delay(200); // Simulate some asynchronous work
            threadProbe.WriteLineThreadID($"{label} - after await()", 2);
        }

        /// <summary>
        /// ContinueWith を利用し、元の Task だけを返す ケース
        /// </summary>
        static (string, Func<Task>) Case_AwaitAndReturnOriginalTask(ThreadProbe threadProbe)
        {
            var task = Task.Run(() => DummyMethod(threadProbe, "In Task.Run()"));
            task.ContinueWith(t => DummyMethod(threadProbe, "In ContinueWith()"));
            return ("ContinueWith を利用し、元の Task だけを返す ケース", () => task);
        }

を書いて、Case_AwaitAndReturnOriginalTask の戻り値を

        /// <summary>
        /// 指定されたケースを実行し、スレッドの状態を出力するメソッドです。
        /// </summary>
        static async Task ExecuteCase(ThreadProbe threadProbe, (string, Func<Task>) caseToExecute)
        {
            threadProbe.WriteLineThreadID($"await 開始: {caseToExecute.Item1}");
            await caseToExecute.Item2();
            threadProbe.WriteLineThreadID($"await 完了: {caseToExecute.Item1}");
            await Task.Delay(1000);
            threadProbe.WriteLineThreadID($"待機完了: {caseToExecute.Item1}");
            Console.WriteLine();
        }

みたいな感じで「実行」すれば、

[Thread 02: SyncCtx is null] await 開始: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 01: SyncCtx is null] In Task.Run() - before await()
  [Thread 01: SyncCtx is null] In Task.Run() - after await()
[Thread 01: SyncCtx is null] await 完了: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()
  [Thread 03: SyncCtx is null] In ContinueWith() - after await()
[Thread 03: SyncCtx is null] 待機完了: ContinueWith を利用し、元の Task だけを返す ケース

といった結果を取得することができます。

※ WriteLineThreadIDは別途実装

が、毎回こうなるとは限りません。なぜなら .NET には「スレッドプール」という仕組みがあり、一度使われたスレッドは使い捨てではなく、再利用されるからです。

具体的には、非同期タスクや Task.Run などのバックグラウンド処理で使われたスレッドは、処理完了後に破棄されるのではなく「スレッドプール」に戻され、将来のタスクで再び使われる可能性があります

そのため、たとえ await の前後でスレッドが切り替わっていたとしても、結果として同じスレッドIDが 「たまたま再利用されて表示される」 ことがあるのです。
そしてその可能性は──

プログラムが数十行のシンプルなコンソールアプリだった場合、
めっっっっっちゃ高確率で起こります!

ContinueWith の挙動に関しては後程話題にします。

そのため、課題となってくるのが、出力されたスレッドIDが同じであっても、「仕様上は違うスレッドで実行される可能性があるのに、今回はたまたま同じスレッドで動いただけ」なのかどうかがよくわからない、ということだったりします。

例えば↓の2か所。これは「仕様上同じスレッドで動いた」なのか「仕様上は異なるスレッドで動くかもしれないが、今回はたまたま同じスレッドで動いた」のかが判断できない。例えばプロダクションコードで、違うタスクも並列実行されている場合は?Task.Delay(200)している最中に、他の Task が完了した場合は?

  [Thread 01: SyncCtx is null] In Task.Run() - before await()
  [Thread 01: SyncCtx is null] In Task.Run() - after await()
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()
  [Thread 03: SyncCtx is null] In ContinueWith() - after await()

この課題をどうにかしたい。完全に「実行スレッドが異なる可能性があるものは絶対違うスレッドIDを出力する」とまではいかなくても、「実行スレッドが異なる可能性があるものが違うスレッドIDを出力する」可能性を高めたい

実装案

そこでこんな実装を用意します。trycatchCancellationToken の処理を省いて簡略化すると次のようなイメージ。

        /// <summary>
        /// スレッドプールをかき混ぜ、一定期間スリープするタスクのバーストを作成します。
        /// </summary>
        public static async Task StirThreadPoolAsync()
        {
            // 以下を無限ループで実行
            while (true)
            {
                // 異なる時間だけスリープするタスクを作成する
                // 1ms寝るタスク, 2ms寝るタスク, ..., 60ms寝るタスク
                var tasks = Enumerable.Range(1, 60).Select(t => Task.Run(() =>
                {
                        var sleepMs = t;
                        Thread.Sleep(sleepMs);
                })).ToArray();

                // すべてのタスクを開始し、実行が完了するのを待つ
                await Task.WhenAll(tasks);
            }
        }

発想としては、

「同じスレッドが再利用されにくくなるように」 ThreadPool をわざと忙しくしとくことで、 ユーザーが書いたコード上でスレッドが再利用される確率を下げスレッド切り替えをあぶり出す

というものです。

これを (Taskの戻り値を捨てる形で) いくつか裏で動かしつつ、先ほどと同じようなコードを実行すると以下のようになるかもしれません。(毎回こうなるとは限りません)

=== SynchronizationContext.Current が null の場合 ===
[Thread 01: SyncCtx is null] await 開始: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 02: SyncCtx is null] In Task.Run() - before await()
  [Thread 03: SyncCtx is null] In Task.Run() - after await()
[Thread 03: SyncCtx is null] await 完了: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()
  [Thread 04: SyncCtx is null] In ContinueWith() - after await()
[Thread 05: SyncCtx is null] 待機完了: ContinueWith を利用し、元の Task だけを返す ケース

つまり(私の環境でのとあるタイミングの実行結果では)

【StirThreadPoolAsync導入前】

  [Thread 01: SyncCtx is null] In Task.Run() - before await()
  [Thread 01: SyncCtx is null] In Task.Run() - after await()
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()
  [Thread 03: SyncCtx is null] In ContinueWith() - after await()

【StirThreadPoolAsync導入後】

  [Thread 02: SyncCtx is null] In Task.Run() - before await()
  [Thread 03: SyncCtx is null] In Task.Run() - after await()
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()
  [Thread 04: SyncCtx is null] In ContinueWith() - after await()

となり、「あぁ、await 前後でスレッドが変わることがあるのか」 ということをはっきりと観察することができました。(今回の例の await 対象は、単なる Task.Delay(200) です。

StirThreadPoolAsync は、「違うかもしれないものをできるだけ『あぶりだす』」ものであり、『高頻度であぶりだせないパターン』も存在します。そのため、観察結果から導くことができるのは、(後述の自作SyncronizationContextのような挙動がわかっているものを除き) 「あぁ、ここは違う可能性があるのか」ということであり、「あぁ、ここは違わないんだ」という結論は導けません。

出力例にも記載がありますが、この節での観察は、SyncronizationContext.Current == null の環境、つまり、WPFのUIスレッド等ではなく、コンソールアプリの通常の Task Main の中で行っています。以降、この記事では、明示的に記載がある場合を除き同様です。

Task.ContinueWith の挙動観察

さて、ここからはちょっと視点を変えて、.ContinueWith() でありがちな「待ってるつもりが待ってない問題」を見ていきます。

↓のようなコードを考えてみましょう。

// Pattern A
// ContinueWith を利用し、元の Task だけを返す ケース
async Task PatternA() {
    var task = 何かのTask;
    task.ContinueWith(継続処理のTask);
    return task;
}

// Pattern B
// ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapなし)
async Task PatternB() {
    var task = 何かのTask;
    task = task.ContinueWith(継続処理のTask);
    return task;
}

// Pattern C
// ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapあり)
async Task PatternC() {
    var task = 何かのTask;
    task = task.ContinueWith(継続処理のTask).Unwrap();
    return task;
}

「何かのTask」「継続処理のTask」中では、とりあえず Task.Delay(200) することにしましょう。

これを先ほどと同様に実行すると次のようなログが出力されます。

=== SynchronizationContext.Current が null の場合 ===
[Thread 01: SyncCtx is null] await 開始: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 02: SyncCtx is null] In Task.Run() - before await()
  [Thread 03: SyncCtx is null] In Task.Run() - after await()
[Thread 03: SyncCtx is null] await 完了: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()
  [Thread 04: SyncCtx is null] In ContinueWith() - after await()
[Thread 05: SyncCtx is null] 待機完了: ContinueWith を利用し、元の Task だけを返す ケース

[Thread 05: SyncCtx is null] await 開始: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapなし)
  [Thread 05: SyncCtx is null] In Task.Run() - before await()
  [Thread 05: SyncCtx is null] In Task.Run() - after await()
  [Thread 05: SyncCtx is null] In ContinueWith() - before await()
[Thread 05: SyncCtx is null] await 完了: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapなし)
  [Thread 05: SyncCtx is null] In ContinueWith() - after await()
[Thread 06: SyncCtx is null] 待機完了: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapなし)

[Thread 06: SyncCtx is null] await 開始: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapあり)
  [Thread 06: SyncCtx is null] In Task.Run() - before await()
  [Thread 07: SyncCtx is null] In Task.Run() - after await()
  [Thread 07: SyncCtx is null] In ContinueWith() - before await()
  [Thread 04: SyncCtx is null] In ContinueWith() - after await()
[Thread 04: SyncCtx is null] await 完了: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapあり)
[Thread 02: SyncCtx is null] 待機完了: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapあり)

上から空行を挟んで、PatternA / PatternB / PatternC の実行ログ (printfデバック) です。

この 3 パターンの違いは、一言で言えば「継続処理の Task をちゃんと待つかどうか」にあります。

Pattern A: 元の Task だけを待つ

task.ContinueWith(...);
await task;
  • await されるのは元の task のみ。
  • 継続処理は fire-and-forget 状態
  • 継続処理の中の await は外からは待たれていない。

実際、「外側の await」は「継続処理のTask」開始以前に完了していることがログからも読み取れます。

[Thread 03: SyncCtx is null] await 完了: ContinueWith を利用し、元の Task だけを返す ケース
  [Thread 03: SyncCtx is null] In ContinueWith() - before await()

Pattern B: ContinueWith が返した Task を待つ(Unwrapなし)

task = task.ContinueWith(...);
await task;
  • ContinueWith(...) が返すのは Task<Task> 型のラッパー Task
  • await すると、継続処理が「呼び出されるところまでは」確実に待たれる
  • しかし、継続処理の中での await 完了までは待たれない
    → 言い換えれば、「継続処理の最初の await に到達したところまでしか待たない」。

つまり、「Task が完了」 とは言っても、中の非同期処理が本当に終わってるとは限らない。

実際に、「継続処理のTask」で await をしたタイミングで、「外側の await」が完了していることがログからも読み取れます。

  [Thread 05: SyncCtx is null] In ContinueWith() - before await()
[Thread 05: SyncCtx is null] await 完了: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapなし)

Pattern C: Unwrap() で内側の Task まで待つ

task = task.ContinueWith(...).Unwrap();
await task;
  • Unwrap() により Task<Task> を平坦化し、内側の継続処理の完了までしっかり待てる
  • 非同期メソッドの挙動を正しく反映したいなら、これが推奨

こちらも実際に、「継続処理のTask」中の await 完了をもって、「外側の await」も完了していることがログからもわかります。

  [Thread 04: SyncCtx is null] In ContinueWith() - after await()
[Thread 04: SyncCtx is null] await 完了: ContinueWith を利用し、ContinueWith が返した Task を返す ケース(Unwrapあり)

通常、Task.ContinueWith() に期待するのはこの挙動のはず!(もちろん意図的にPatternA/PatternBをしたいケースが絶対にないとは言えません。)


結論:ContinueWith を非同期で使うなら Unwrap を忘れない!

.ContinueWith() は、もともと async/await 導入前の古い非同期継続モデルで、意図しない Task<Task> を返してしまう罠があります。

そのため、非同期メソッドを継続処理に使う場合は .Unwrap() がほぼ必須です。

  • Pattern A: 継続処理が待たれず、非同期の副作用がタイミング未定に
  • Pattern B: 継続処理が「呼ばれたこと」だけが待たれる
  • Pattern C: 継続処理が「ちゃんと終わるまで」待たれる ← 正解

逆に言えば、.ContinueWith() を使って、.Unwrap() を使わないなら、「非同期の中身は誰も見てないよ」と割り切る必要がある、ということです。

特にテストコードやスクリプト系のバッチ処理などでは、await Task<Task> になっていてもビルドエラーにならず通ってしまうため、「動いてるように見えるけど一部の非同期処理が裏で取り残される」事故につながります。


なお、最近の C# 開発では .ContinueWith() よりも await ベースの直列/並列記述の方が一般的で可読性も高いため、特殊な制御フローが必要なとき以外は .ContinueWith() 自体を避けるのがベターという説もあります。

参考: ContinueWith の歴史

.NET界における Taskawait/async の詳細な歴史や背景については、atmarkITの記事が非常に詳しく、参考になります。.NET Framework 4 で Taskモデル (.ContinueWith()含む) が導入され、その後、.NET Framework 4.5 において、async/await が導入されたようです。

本記事のまとめ

  • Task.Delay などの非同期処理では、スレッドが切り替わる可能性がある
  • でもスレッドプールの再利用のせいで、ID が同じになる場合もある
    → だから「見た目同じでも実は別物だった」ってことがある。
  • ContinueWithasync/await 前時代の構文であり、意図しない Task<Task> になる罠がある。
    → 非同期継続を書くときは .Unwrap() を忘れずに!

次回予告(予告をしておくだけで絶対書くとはいってない)

ConfigureAwait(false) を付けろって言われたから付けてるけど、正直よくわかってない」

という人、全国に何万人いるでしょうか?

次回の記事では、この .ConfigureAwait(false) という謎キャッチについて──

  • どんな場面で違いが出るのか?
  • つけても意味ないパターンはあるのか
  • つけると不安定になりかねないパターンはあるのか
  • 代替策は?男は黙って Task.Run()

といった点を、実験コード+ログ付きでしっかり観察・解説していくことができるかもしれませんし、できないかもしれません。

GitHub で観察コード公開中

記事で使用した観察コードは GitHub 上で公開中です。
興味のある方はこちらからどうぞ。
GitHub - cozyupk/misc/task-continuation-probe

.ConfigureAwait(false) 実験コードも含んでますので予習もできちゃいますよ!おくさん!

おしまい

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?