⚔️ C#クエスト ― 非同期の迷宮 🐉
プロローグ ― 待ち時間の魔物
かつて C# の世界には、“待ち時間”という名の魔物がいた。
同期の村では、何をするにも 「終わるまで待つ」 のが当たり前だった。
ファイルも画面更新もネットワークも、一本の道を順番に歩くだけ。
しかし世界は変わりはじめる。
- ユーザーは 「すぐ動いてほしい」 と言うようになり、
- サーバは同時に多くのリクエストをさばき、
- 「画面が固まる」という苦情が増えた。
ある日、長老が旅人を呼び止める。
「同期だけでは、もう“待ち時間の魔物”に勝てぬのじゃ。
この世界には 裏で動く道 を作る技、非同期 がある。」
「じゃが、その迷宮は複雑で、いきなり踏み込むのは危険じゃ。
まずは タイマー や スリープ といった初歩の武器から学ぶがよい。」
旅人は決心する。
「非同期の迷宮を攻略してみせます。」
最初の武器は、小さくても確かな一歩──タイマーイベントの短剣であった。
第1章:タイマーの短剣 ― 非同期の入口
最初の武器は、一定時間ごとに自動で動く タイマーだった。
- 「○秒後に動きたい」
- 「一定間隔で処理を繰り返したい」
そんなシンプルな要望を叶えてくれる、非同期への入口である。
1.1 時刻アラーム ― 指定時刻に技を発動する
まずは 「指定時刻に何かする」 ところから始める。
例:
- 朝 7:30 に通知を出す
- 毎日決まった時刻にアラートを表示する
var timer = new System.Timers.Timer();
timer.Interval = 1000; // 1秒ごとにチェック
timer.Elapsed += (s, e) =>
{
if (DateTime.Now.Hour == 7 &&
DateTime.Now.Minute == 30)
{
ShowAlarm();
}
};
timer.Start();
画面操作とは無関係に、裏で時刻を見て技を発動してくれる。
「ほら、非同期って意外と簡単じゃん。」
1.2 インターバルで時計を動かす ― “勝手に動くUI更新”
次は 「一定間隔で状態を更新する」 例として、時計を作る。
timer.Interval = 1000;
timer.Elapsed += (s, e) =>
{
UpdateClockLabel(DateTime.Now);
};
timer.Start();
メイン処理はそのまま進みつつ、
裏でタイマーが動き続けて UI を更新する。
「メインの処理を書き換えなくても、画面が勝手に動くんだ。」
1.3 入力が止まったあとに更新 ― “賢い遅延実行”
タイマーは “少し待ってから動く” 用途にも向いている。
例:ツリービューでフォルダを移動している間は更新せず、
入力が止まって 300ms 経ったらフォルダの内容を表示する。
void OnTreeSelectionMove()
{
timer.Stop();
timer.Interval = 300;
timer.Start();
}
void OnTimerElapsed(...)
{
UpdateFolderContents();
}
無駄な再読み込みを減らし、操作を軽くするための小技だ。
1.4 タイマーの限界 ― ウィンドウの外では力が弱まる
鍛冶屋から、こう頼まれた。
「ログイン前から動くサービスとして実行したい。」
しかし、旅人はすぐに気づく。
タイマーには 「画面(メッセージループ)が必要」 という弱点があった。
- WinForms や WPF のタイマーは UI スレッド前提
- サービス/コンソールにはその前提がない
- よって UI タイマーは動かない/不安定になる
「この短剣だけでは、迷宮の深部は攻略できない……。」
そのとき、長老の声が聞こえる。
そろそろ、次の力を授けよう。
📝 コラム:タイマーの弱点(技術編)
背景には、タイマーの種類の違いがある。
| タイマー | 主な用途 | 動作場所 |
|---|---|---|
| WinForms.Timer | Windows Forms の UI 更新 | UI のメッセージループ |
| DispatcherTimer(WPF) | WPF の UI 更新 | Dispatcher(UI スレッド) |
| System.Timers.Timer | 軽量なバックグラウンド処理 | スレッドプール |
| System.Threading.Timer | 完全なバックグラウンド | スレッドプール |
UI タイマーは メッセージループや Dispatcher があることを前提に動く。
サービスやコンソールでは、System.Timers.Timer や System.Threading.Timer のような
バックグラウンド向けタイマーを使う必要がある。これらは内部的にスレッドプールで動作し、
UI のメッセージループに依存しないため、サービス環境でも安定して利用できる。
第2章:古の戦士 ― Threadの昔話
長老は、旅人に新しい力を授ける前に、
静かに “過去の話” を始めた。
2.1 古代の力 ― Thread
昔の世界では、非同期といえば Thread を直接生み出す術しかなかった。
new Thread(() => DoWork()).Start();
- メイン処理と同時に動き
- 世界を止めずに別働隊を出せる
まさに “古の戦士” である。
2.2 だが扱いは難しかった
Thread は強力だったが、同時に 重く、管理も難しい 力でもあった。
- 1体1体が重い
- 終了管理が必要
- エラーが拾いにくい
- 共有資源で争いが起きる
「力はあるが、扱いが難しすぎたのじゃ。」
2.3 現代では主役を退いた理由
やがて世界には、軽量な Task と、
それを“物語として”扱える async/await が生まれた。
- 並行実行そのものは ThreadPool が面倒を見てくれる
- 開発者は
Taskとawaitだけ意識すればよくなった -
try/catchで例外も扱いやすい
こうして、「分身=Thread を自前で作る時代」 は終わり、
Thread は 仕組みを理解するための古い魔法 という位置づけになった。
2.4 次の力 ― Task へ
長老は言う。
「古の魔法を知ったなら、
次は現代の力 “Task” を授けよう。」
旅人はいよいよ、
軽く安全に動く“現代の分身” と出会うことになる。
第3章:Task ― 軽い分身の召喚
旅人は、長老の昔話を聞きながら思う。
「非同期には“裏で動く力”が必要だ。
でも、もっと 軽くて扱いやすい分身 はないのだろうか?」
長老はうなずき、静かに語り始めた。
「では、“Task” を授けよう。
これは現代の分身であり、非同期の基本じゃ。」
3.1 Task.Run ― 軽い分身の召喚
var task = Task.Run(() =>
{
Console.WriteLine("軽い分身が仕事中");
});
Task の特徴はシンプルだ。
- 軽い
- 勝手に終わり、後片付けも不要
- 完了状態(成功/失敗/キャンセル)を持つ
「これは扱いやすいぞ……!」
3.2 終わりを待つことも、結果を受け取ることもできる
Task の最大の強みは、
“待つ” という動作をそのままコードに書けること。
await task;
戻り値を返すこともできる。
var task = Task.Run(() => 42);
int result = await task;
裏で何が起きているか気にせず、
ただ「終わるのを待つ」「結果を受け取る」を書くだけでいい。
これは、
複雑な並行処理を “分かりやすい物語” に近づける力 を持っている。
3.3 それでも残る“迷宮感”
ただし、Task だけで物語を書こうとすると、
まだ迷宮は完全には晴れない。
Task.Run(() => Step1())
.ContinueWith(t => Step2(t.Result))
.ContinueWith(t => Step3(t.Result));
-
ContinueWithが続くと可読性が下がる - エラーがどこで起きたか追いづらい
- 分岐やネストが増えるほど、物語の筋が見えなくなる
「便利になったとはいえ、これではまだ読みづらい……。」
旅人がつぶやくと、長老は微笑んだ。
「そなたには、そろそろ await を授けてもよい頃じゃ。」
このあと旅人は、
Task を本当の意味で“物語に変える” 魔法:async/await と出会うことになる。
第4章:async/await ― 迷宮を物語に変える魔法
Task を使えるようになった旅人。
最後のピースが async / await だった。
4.1 ContinueWith の迷宮が、一本道の物語に
旅人が await を唱える。
var result = await Step1Async();
result = await Step2Async(result);
await Step3Async(result);
- ネストが消え、
- 分岐が減り、
- 例外は普通に
try/catchで扱える。
「これは……同期処理を読むのと同じ感覚だ。」
4.2 async は魔法陣、await は発動ポイント
-
async… 「この中には待ちがあるよ」という宣言 -
await… 「ここで一度中断し、再開する」ポイント
async Task<int> GetValueAsync()
{
await Task.Delay(1000);
return 42;
}
コードの流れは 見たままの順番 で理解できる。
4.3 だが、魔法には“影”もある
影①:Result / Wait() の罠
var x = GetValueAsync().Result;
UI や ASP.NET では、これがデッドロックやフリーズの原因になる。
「await の世界を無理に同期へ引き戻すと、罠が発動する。」
影②:コンテキストの違い
await 後がどこで再開されるかは環境次第だ。
- UI … UI スレッドに戻る
- ASP.NET … リクエストコンテキストに戻る
- コンソール/サービス … 別スレッドで続くこともある
影③:キャンセルやタイムアウトは自分で設計する
async/await 自体には「やっぱやめたい」「時間切れ」といった概念はない。
それらは 設計として自分で組み込む 必要がある。
4.4 async/await の本質
async/await は Task の複雑さを隠し、
非同期処理を 「一本の物語」 のように書けるようにしてくれる。
ただし、
- どこで中断・再開するか
- どのコンテキストに戻るか
- どこで同期に戻すと危険か
といった“裏ルール”を知らないまま使うと、
再び迷宮に迷い込む。
「魔法に頼るだけでなく、その裏側も理解しなければ。」
次は、迷宮で生き延びるための 安全装備 を学ぶことになる。
第5章:三種の神器 ― 非同期の安全装備
長く迷宮を進むうちに、旅人は気づく。
- いつ終わるか分からない処理
- 帰ってこない分身
- 気づかぬうちに倒れているタスク
これらに対処するには、“安全に戻る術” が必要だった。
長老は言う。
「非同期には 三種の神器 がある。
キャンセル、タイムアウト、例外処理じゃ。」
5.1 キャンセル ― 不要になった戦いをやめる術
長時間の処理が、途中で不要になることは多い。
そのときに使うのが CancellationToken。
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
// 途中で…
cts.Cancel();
処理側では適切な場所でこう確認する。
token.ThrowIfCancellationRequested();
無意味な処理を続けずに済み、リソースの無駄を減らせる。
5.2 タイムアウト ― 帰ってこない分身への保険
外部サービスやネットワークは、返事が遅いこともある。
そんなときのための 時間制限(タイムアウト)。
var work = LongOperationAsync();
var timeout = Task.Delay(5000);
var finished = await Task.WhenAny(work, timeout);
if (finished == timeout)
{
// タイムアウト
}
延々と待ち続けず、
「ここまで」と区切って次に進める。
5.3 例外処理 ― 迷宮の罠から生還するために
非同期処理の中で例外が起きると、
“分身が静かに倒れているだけ” になることがある。
そこで重要なのが、await の外側で try/catch する こと。
try
{
await RiskyOperationAsync();
}
catch (Exception ex)
{
Log(ex);
}
- 分身の失敗を呼び出し元で受け止め、
- “どこで倒れたか” を把握できるようにする。
5.4 禁断の呪文:async void
三種の神器を授け終えた長老は、最後にこう付け加える。
「絶対に手を出すでない呪文がある。
async voidじゃ。」
async void は、
- 完了を待てない
- 例外を拾いにくい
- 呼び出し側から制御できない
- キャンセルもしにくい
といった性質を持つ。
唯一許されるのは UI イベントハンドラ くらいだ。
private async void Button_Click(...)
{
await DoSomethingAsync();
}
それ以外では、基本的に避けるべき ものとして扱う。
📝 コラム:非同期と共有資源 ― 排他はどうする?
非同期処理で避けて通れないのが 共有資源へのアクセス である。
複数のスレッド/タスクから同じデータや UI を触るときには、「誰がいつ触るか」を制御する必要がある。
昔:スレッドから直接触る+排他制御
かつては Thread から直接メモリや UI を触り、lock や Mutex で守るのが基本だった。
private readonly object _lockObj = new();
void Increment()
{
lock (_lockObj)
{
_count++;
}
}
規模が大きくなると、どこで共有データを触っているか把握しきれず、バグの原因になりやすい。
今:UIは「投げる」、データは「壊れにくく設計する」
最近は次のような方針が主流だ。
-
UI 更新は UI スレッドに任せる
- WinForms:
Control.Invoke(...) - WPF:
Dispatcher.Invoke(...)
- WinForms:
-
共有データは壊れにくくする
- 不変オブジェクト(Immutable)を使う
- スレッドセーフなコレクションを使う
- どうしても必要な場所だけ
lockやSemaphoreSlimで最小限の排他
「共有資源には必ず制御が必要」 という前提は昔も今も同じだが、
「直接触らず、正しい窓口に投げる」「そもそも壊れにくく設計する」方向にシフトしている。
5.5 旅人は“生還する力”を得た
こうして旅人は、
- キャンセル
- タイムアウト
- 例外処理
という三種の神器を手にし、
async void を避ける知恵 も得た。
「これで迷宮に入っても、必ず帰ってこられる。」
次は、非同期の力を 設計としてどこまで使うか を考える段階へ。
迷宮の出口は近い。
第6章:設計編 ― asyncの境界線
迷宮の終盤、旅人には最後の疑問が残った。
「武器や魔法は分かった。
でも、この力を どこまで設計に反映すればいい のだろう?」
6.1 async の魔法陣は「上から下へ」広げる
長老の答えはシンプルだった。
- UI(Controller) … 基本は async
- Service 層 … それに合わせて async
- Repository(DB/外部 I/O) … ほぼ必ず async
非同期の呼び出しは、上から下へ 伝播させるのが基本である。
途中で同期メソッドに畳むと、そこがボトルネックやデッドロックの温床になる。
6.2 async を途中で潰すと罠が発動する
Result / Wait() で無理やり同期に戻すと、
- UI フリーズ
- ASP.NET でのデッドロック
といった罠が発動する。
「async で始めたなら、最後まで async で歩む。」
これが迷宮を安全に抜ける条件だ。
6.3 とはいえ“全部 async” も違う
旅人は質問する。
「じゃあ、全部 async にするべきですか?」
長老は首を振る。
- I/O 待ちのある処理 … async にすべき
- 純粋な CPU 計算 … 同期で問題ない
int Calc() => HeavyCpuWork(); // await 不要
ループの中で無理に await を使うと、逆に遅くなる場合もある。
「async の魔法は、“待ち” があるところだけに使え。」
6.4 最終結論:I/O が境界、CPU が例外
旅人はこうまとめた。
- I/O(DB・ファイル・Web・ネットワーク) ⇒ async が基本
- CPU 計算だけの領域 ⇒ 同期で OK
- 途中で同期に畳むのは極力避ける
- 全体として、上から下まで async の筋を通す
「async をどこまで染めるべきか……
答えは“ I/O のあるところまで”か。」
迷宮の出口が見えた気がした。
エピローグ ― 迷宮の外の広い世界
非同期の迷宮を抜けた旅人は、ふり返る。
- タイマーの短剣
- 古の Thread
- Task の軽い召喚
- async/await の魔法
- キャンセル・タイムアウト・例外処理
どれも道中で必要だった武器や技だが、旅人はこう感じていた。
「非同期はゴールではなく、ただの“道具”でしかない。」
迷宮の外には、さらに広い世界が待っている。
- サーバのスケールアウト
- 負荷とスループット
- UI の快適性
- 並列処理(Parallel, Channels, IAsyncEnumerable …)
async/await は、その入口に過ぎない。
「この世界に、“待ち時間の魔物”はまだまだいる。
だがもう、迷うことはない。」
迷宮は終わり、ここからは自分の道になる。
旅人は静かに剣を収め、次の冒険へと歩き出した。
Discussion