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つの要素が組み合わさって動いています。
- イベントループ
- コンパイラによる分割
- 再開先を決める
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つの要素が連携しています。
それぞれ、記事中の実装クラスに対応しています。
-
イベントループ(
EventLoopクラス)-
Application.Run()に相当する仕組み -
BlockingCollection<Action>を使ってイベントをキューイングし、イベントをメインスレッド上で順番に実行 -
awaitの完了通知も「イベント」としてこのループに戻ってくる
-
-
処理の分割(コンパイラによるステートマシン変換)
-
asyncメソッドは、コンパイラによってawaitを境に「前半」と「後半」に分割 - コード中では 1 メソッドでも、裏では「
awaitの後に再開するコールバック」を自動生成 -
SampleApp.Main()のawait Task.Delay(...)の後ろ側が「後半ハンドラー」
-
-
再開先の指定(
MySyncContextクラス)-
SynchronizationContextを継承し、Post()で「後半処理」をイベントループのキューに登録 - 非同期処理が完了すると
Post()が呼ばれ、awaitの続きを 元のスレッド上 で再開
-
このような仕組みで「同期的に書いても、裏では非同期イベントがうまく順番に処理されている」async/await の挙動が成立しています。
今回の記事では、
- イベントループ (
EventLoop) - 継続処理の再投稿 (
MySyncContext)
を自作して、async/awaitの仕組みの半分を体験しました。
残る「処理を前後に分ける部分(ステートマシン変換)」は、C# コンパイラが自動で行っています。
C# コンパイラ内部実装に興味がある方は、参考リンクをご覧ください。
参考リンク
-
非同期メソッド - C# によるプログラミング入門
async/await の基本構文と考え方を丁寧に解説。初めて触れる人に最適です。 -
非同期メソッドの内部実装
async メソッドがどのようにステートマシンに変換されるかを詳細に解説しています。 -
async/await と同時実行制御
async/await 登場初期の記事。スレッドや排他の観点から仕組みを掘り下げています。 -
Async and Await - Stephen Cleary
C# における async/await の基本動作を実例で解説。世界的に引用される定番記事。 -
There Is No Thread - Stephen Cleary
「非同期処理=スレッドではない」という視点から await の正しい理解を促します。 -
Asynchronous programming - C# | Microsoft Learn
公式ドキュメント。Task・async・await の全体像を体系的にまとめています。 -
Writing async/await from scratch in C# - Stephen Toub (Microsoft)
async/await をゼロから実装して理解する、Microsoft エンジニアによる解説動画。
株式会社ラグザイア(luxiar.com)の技術広報ブログです。 ラグザイアはRuby on RailsとC#に特化した町田の受託開発企業です。フルリモートでの開発を積極的に推進しており、全国からの参加を可能にしています。柔軟な働き方で最新のソフトウェアソリューションを提供します。
Discussion