🔁

C#のasync/awaitを分解して理解する - イベントループと SynchronizationContext の仕組みを自作してみた

に公開

はじめに

C# の async/await を使っていると、少し不思議な感覚を覚えませんか。

var content = await File.ReadAllTextAsync("data.txt"); // ファイルを読んでいる間、スレッドはロックされない
// 読み終わったら、続きを再開...

async を付けるだけで、遅い処理を別スレッドで実行したり、非同期の完了を待ったりできるようになります。糖衣構文に見えますが、実際には裏で複雑な仕組みが動いています。

await で待っている間に、同じスレッドで他の処理が動いているのは、どんな仕組みでしょうか?別スレッドの処理が完了したら、続きをちゃんと元のスレッドで再開できるのはなぜでしょうか?

本記事では、Application.Run に相当するシンプルなイベントループを自作し、await がどのように再開されるのか確認します。なお、await の前後で処理を分割する仕組み(ステートマシンの生成)は今回は扱いません。コンパイラが別に行っている仕事です。

動作環境

本記事のコードは C# 12 / .NET 9 環境で動作確認しています。
コンソールアプリケーションです。
Windows FormsやWPFではありません。

ソースコード全体は GitHub - ledsun/MiniSyncContext に公開しています。

おおざっぱな async/await が“同期っぽく見える”3つの理由

結論から言うと、Windows Forms や WPF は「イベント駆動」の世界です。
async/await はその世界に「一時停止 → 再開」という概念を自然に溶け込ませる仕組みで、次の3つの要素が組み合わさって動いています。

  1. イベントループ
  2. コンパイラによる分割
  3. 再開先を決める SynchronizationContext

イベントベースのプログラミング(イベントループ)

Windows Forms / WPF のプログラミングは、一見すると同期的に見えます。しかし、実際にはbutton.Click += ... のように イベントハンドラーを登録し、Application.Run() が回すイベントループに任せる仕組みです。

イベントループは、OSから送られてくる「ユーザー入力」や「非同期処理の完了通知」などのメッセージをキューから取り出して順に処理します。すべてのUI処理は、このループ上で1件ずつ 順番に 実行されています。

コンパイラが await を境に処理を分割する

async メソッドはコンパイラによって ステートマシン に変換されます。
コード上は1つのメソッドでも、実際には:

  • await の前:前半のイベントハンドラー
  • await の後:後半のイベントハンドラー

に分割されます。
(この記事では、このステートマシンの内部構造までは扱いません。)

再開先は SynchronizationContext が決める

await の後半(継続処理)が どのスレッドで再開するか は、await 開始時点で設定されていた SynchronizationContext が決めます。

Windows Forms や WPF の場合、SynchronizationContext は UIスレッド[1]のイベントループ に紐づいています。 そのため、await で別スレッドの処理を呼び出しても、完了後の継続処理は 元のUIスレッドで再開 されます。

これが、「await 後に UI をそのまま触れる」理由です。

Application.Runはイベントループ

Windows Forms や WPF のアプリケーションは、Application.Run()で始めます。

Application.Run(new MainForm());

このとき Application.Run() は、アプリケーションの「イベントループ」を開始します。
イベントループとは、次のようなイベントを絶えず受け取り処理する無限ループのことです。

  • ユーザーからの入力(クリック、キー操作など)
  • 非同期処理の完了通知(ファイル読み込み、通信完了など)

普段、Windows Forms や WPF のコードを書くときにこの仕組みを意識することはほとんどありません。
しかし、裏ではこのイベントループがアプリ全体を動かしています。

async/await では、この仕組みを活かして「非同期処理」と「その後の処理」を await を境に分割し、別のイベントとして再実行できるようにしています。これにより、await で時間のかかる処理を待っている間も、同じスレッドで新しいイベント(ユーザー操作など)を受け取ることができます。

ここでは、最小構成のイベントループを自作して、「イベントループが動いている」実感を得てみましょう。

最小のイベントループ実装

EventLoopクラス

using System.Collections.Concurrent;

/// <summary>
/// await 可能なメイン処理を実行するためのイベントループ
/// </summary>
public sealed class EventLoop
{
    /// <summary>
    /// 別スレッドからのイベントを受け取るキュー
    /// </summary>
    private readonly BlockingCollection<Action> _queue = [];

    /// <summary>
    /// awaitを伴うメイン関数を実行する
    /// </summary>
    /// <param name="MainFunction"></param>
    public void Run(Func<Task> MainFunction)
    {
        // イベントループを開始
        var thread = StartThread();

        // メイン関数の完了を待つための TaskCompletionSource
        var tcs = new TaskCompletionSource();

        // メイン関数をラップした Action
        Action wrappedMain = async () =>
        {
            try
            {
                // メイン関数を実行
                await MainFunction();

                // 完了を通知
                tcs.SetResult();
            }
            catch (Exception ex)
            {
                // 例外を通知
                tcs.SetException(ex);
            }
        };

        // メイン関数をキューに追加
        _queue.Add(wrappedMain);

        // メイン関数の完了を待つ
        tcs.Task.GetAwaiter().GetResult();

        // キューを閉じる
        _queue.CompleteAdding();

        // スレッドを待つ
        thread.Join();
    }

    /// <summary>
    /// イベントループを開始する
    /// </summary>
    private Thread StartThread()
    {
        var thread = new Thread(Loop);
        thread.Start();
        return thread;

        // イベントループの本体
        void Loop()
        {
            // 自分に継続イベントを投げさせるための SynchronizationContext を設定
            SynchronizationContext.SetSynchronizationContext(new MySyncContext(_queue));

            // イベントループ:キューから取り出して実行
            foreach (var work in _queue.GetConsumingEnumerable())
            {
                work();
            }
        }
    }
}

MySyncContextクラス

SynchronizationContextは非同期処理の戻り先を指定するクラスです。これが適切に設定されていると、「await された後の継続処理」[2]は元のスレッドに戻って実行されます。

今回の実装では、この SynchronizationContext を継承した MySyncContext を定義し、
awaitされた後の継続処理」を「イベントループのキューに積む」ことで、再開の仕組みを再現しています。


using System.Collections.Concurrent;

/// <summary>
/// Postだけ持つ簡易 SynchronizationContext
/// </summary>
public sealed class MySyncContext : SynchronizationContext
{
    /// <summary>
    /// イベントループにActionを送るためのキュー
    /// </summary>
    private readonly BlockingCollection<Action> _queue;

    public MySyncContext(BlockingCollection<Action> queue) => _queue = queue;

    public override void Post(SendOrPostCallback d, object? state)
    {
        // メイン処理内で await された後の継続処理がここに来る
        // イベントループのキューに追加して、イベントループで実行させる
        _queue.Add(() => d(state));
    }
}

デバッグ実行するとawait待ちが終わったタイミングでPostメソッドが呼ばれることがわかります。
Post() が呼ばれるのは、await していた非同期処理が完了した瞬間です。つまり、MySyncContext が「awaitされた後の継続処理をイベントキューに積み戻す装置」として働いていることがわかります。

ただし、ここでひとつ疑問が残ります。ソースコード上には見えない次の部分は、いったいどこで処理されているのでしょうか?

  • Postメソッドが呼ばれるタイミングの制御
  • SendOrPostCallbackで渡されるawaitされた後の継続処理の切り出し

これらは、C# コンパイラが async メソッドを変換する際に自動生成するステートマシン(状態遷移クラス) の内部で行われています。この記事では踏み込みません。

SampleAppクラス

ここまでで用意したイベントループと MySyncContext を実際に動かしてみましょう。
async/await がどのように動作するかを確認するためのシンプルなサンプルです。
非同期処理 (Task.Delay) と、スレッドプールでの並列処理 (Task.Run) の両方を実行します。

internal static class SampleApp
{
    public static async Task Main()
    {
        var subThreadTask = RunSubThreadTask();
        Program.ThreadWriteLine("I'm main");
        Program.ThreadWriteLine($"Ctx={SynchronizationContext.Current?.GetType().Name}");
        Program.ThreadWriteLine("await 500ms");

        // 非同期処理の完了を待つ
        await Task.Delay(500);
        Program.ThreadWriteLine("end await. await sub thread...");

        // スレッドを使った並列処理の完了を待つ
        await Task.WhenAll(subThreadTask);
        Program.ThreadWriteLine("bye!");
    }

    private static Task RunSubThreadTask()
    {
        // Task.Run はスレッドプールのスレッドで動く
        return Task.Run(() =>
        {
            Program.ThreadWriteLine("I'm sub");
            Thread.Sleep(1000);
            Program.ThreadWriteLine("bye!");
        });
    }
}

Programクラス

自作のイベントループを使って SampleApp.Main() を実行します。
通常の Application.Run() の代わりに、自作の eventLoop.Run() が「UI スレッド」のような役割を果たします。

internal static class Program
{
    static void Main()
    {
        var eventLoop = new EventLoop();

        // イベントループでメイン処理を実行
        eventLoop.Run(SampleApp.Main);

        Console.WriteLine("Done.");
    }

    public static void ThreadWriteLine(string name)
    {
        Console.WriteLine($"[tid:{Environment.CurrentManagedThreadId:00}] {name}");
    }
}

実行結果例

[tid:10] I'm main
[tid:09] I'm sub
[tid:10] Ctx=MySyncContext
[tid:10] await 500ms
[tid:10] end await. await sub thread...
[tid:09] bye!
[tid:10] bye!
Done.

出力結果中の [tid:xx] はスレッドIDで、環境によって異なる場合があります。

ここで確認できるポイントは次の通りです。

  • SynchronizationContext として自作クラス MySyncContext が設定されている
  • await による非同期処理 (Task.Delay) の待機が正しく機能している
  • await 前後で 同じスレッドID で再開している
  • 並列実行したサブスレッド (Task.Run) の完了を待ってから終了している

await待ち中にイベントループが追加のイベントメッセージを受け取れることを確認する

ここまででイベントループがawaitの完了を待てることが確認できました。

では、 awaitを待っている間、イベントループは本当に止まっていないのでしょうか?
ここからは、イベントループが「待機中に別のイベントを処理できる」ことを確かめます。

EventLoopクラス

EventLoopクラスに、イベントメッセージ受信用のPostメソッドを追加します。

    /// <summary>
    /// イベントを受け取る
    /// </summary>
    /// <param name="action"></param>
    public void Post(Action action) => _queue.Add(action);

PeriodicEventsクラス

定期的なフィボナッチ数を計算イベントを発火するクラスを追加します。
await 待機中に動く「別イベント」の発生源です。

internal static class PeriodicEvents
{
    public static CancellationTokenSource StartToPostEvents(EventLoop ui)
    {
        // 「擬似イベント」をUIキューへ投げる
        // UIスレッドがawaitしている間に仕事ができることを確かめます。
        var cts = new CancellationTokenSource();
        _ = Task.Run(async () =>
        {
            Program.ThreadWriteLine("PeriodicEvents started.");

            var val1 = 1;
            var val2 = 1;
            while (!cts.IsCancellationRequested)
            {
                ui.Post(() =>
                {
                    var tmp = val1 + val2;
                    Program.ThreadWriteLine($"fibonacci {tmp}");

                    val1 = val2;
                    val2 = tmp;
                });
                await Task.Delay(200);
            }
        });
        return cts;
    }
}

Programクラス

ProgramクラスにPeriodicEventsクラスの呼び出しを追加し、定期イベントの発生を有効にした状態でサンプルアプリを実行します。

internal static class Program
{
    static void Main()
    {
        var eventLoop = new EventLoop();

        // イベントループへ定期的にイベントを追加
        using var cts = PeriodicEvents.StartToPostEvents(eventLoop);

        // イベントループでメイン処理を実行
        eventLoop.Run(SampleApp.Main);

        Console.WriteLine("Done.");
    }

    public static void ThreadWriteLine(string name)
    {
        Console.WriteLine($"[tid:{Environment.CurrentManagedThreadId:00}] {name}");
    }
}

実行結果例

[tid:05] PeriodicEvents started.
[tid:11] I'm main
[tid:10] I'm sub
[tid:11] Ctx=MySyncContext
[tid:11] await 500ms
[tid:11] fibonacci 2
[tid:11] fibonacci 3
[tid:11] fibonacci 5
[tid:11] end await. await sub thread...
[tid:11] fibonacci 8
[tid:11] fibonacci 13
[tid:10] bye!
[tid:11] bye!
Done.

awaitした直後にfibonacci 2が表示されています。
await待ち中にメインスレッドでPeriodicEventsから投入した処理が実行されています。
つまり、await で非同期処理を待っている間も、イベントループは止まらずにキュー上の別イベントを処理しています。

実際の Windows Forms や WPF のアプリケーションでは、マウス操作や描画更新などのイベントが同様に処理されています。await で待っている間、イベントループは新しいイベントを処理できるので、アプリケーションは固まらずに動き続けます。

まとめ:async/await が「同期っぽく」動く理由

ここまでの実装を通して、C# の async/await が「同期的に見える」仕組みを体感しました。
その正体は、イベント駆動アプリケーションモデルとコンパイラの分割機構の組み合わせです。

具体的には次の3つの要素が連携しています。
それぞれ、記事中の実装クラスに対応しています。

  1. イベントループ(EventLoop クラス)

    • Application.Run() に相当する仕組み
    • BlockingCollection<Action> を使ってイベントをキューイングし、イベントをメインスレッド上で順番に実行
    • await の完了通知も「イベント」としてこのループに戻ってくる
  2. 処理の分割(コンパイラによるステートマシン変換)

    • async メソッドは、コンパイラによって await を境に「前半」と「後半」に分割
    • コード中では 1 メソッドでも、裏では「await の後に再開するコールバック」を自動生成
    • SampleApp.Main()await Task.Delay(...) の後ろ側が「後半ハンドラー」
  3. 再開先の指定(MySyncContext クラス)

    • SynchronizationContext を継承し、Post() で「後半処理」をイベントループのキューに登録
    • 非同期処理が完了すると Post() が呼ばれ、await の続きを 元のスレッド上 で再開

このような仕組みで「同期的に書いても、裏では非同期イベントがうまく順番に処理されている」async/await の挙動が成立しています。

今回の記事では、

  • イベントループ (EventLoop)
  • 継続処理の再投稿 (MySyncContext)

を自作して、async/awaitの仕組みの半分を体験しました。

残る「処理を前後に分ける部分(ステートマシン変換)」は、C# コンパイラが自動で行っています。
C# コンパイラ内部実装に興味がある方は、参考リンクをご覧ください。

参考リンク

脚注
  1. Windows OS では、各ウィンドウ(UI要素)は特定のスレッドに 所有 されます。この UI要素 を管理している専用スレッドを「UIスレッド」と呼びます。UIスレッド以外のスレッドからUI要素を操作すると例外が発生します。これは、描画や入力イベントの競合を防ぐためです。 ↩︎

  2. この「awaitされた後の継続処理」が、コンパイラが await を境に切り出した 継続 (continuation) です。 ↩︎

ラグザイア

Discussion