⚔️

⚔️ 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.TimerSystem.Threading.Timer のような
バックグラウンド向けタイマーを使う必要がある。これらは内部的にスレッドプールで動作し、
UI のメッセージループに依存しないため、サービス環境でも安定して利用できる。


第2章:古の戦士 ― Threadの昔話

長老は、旅人に新しい力を授ける前に、
静かに “過去の話” を始めた。


2.1 古代の力 ― Thread

昔の世界では、非同期といえば Thread を直接生み出す術しかなかった。

new Thread(() => DoWork()).Start();
  • メイン処理と同時に動き
  • 世界を止めずに別働隊を出せる

まさに “古の戦士” である。


2.2 だが扱いは難しかった

Thread は強力だったが、同時に 重く、管理も難しい 力でもあった。

  • 1体1体が重い
  • 終了管理が必要
  • エラーが拾いにくい
  • 共有資源で争いが起きる

「力はあるが、扱いが難しすぎたのじゃ。」


2.3 現代では主役を退いた理由

やがて世界には、軽量な Task と、
それを“物語として”扱える async/await が生まれた。

  • 並行実行そのものは ThreadPool が面倒を見てくれる
  • 開発者は Taskawait だけ意識すればよくなった
  • 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 を触り、lockMutex で守るのが基本だった。

private readonly object _lockObj = new();

void Increment()
{
    lock (_lockObj)
    {
        _count++;
    }
}

規模が大きくなると、どこで共有データを触っているか把握しきれず、バグの原因になりやすい。

今:UIは「投げる」、データは「壊れにくく設計する」

最近は次のような方針が主流だ。

  • UI 更新は UI スレッドに任せる

    • WinForms: Control.Invoke(...)
    • WPF: Dispatcher.Invoke(...)
  • 共有データは壊れにくくする

    • 不変オブジェクト(Immutable)を使う
    • スレッドセーフなコレクションを使う
    • どうしても必要な場所だけ lockSemaphoreSlim で最小限の排他

「共有資源には必ず制御が必要」 という前提は昔も今も同じだが、
「直接触らず、正しい窓口に投げる」「そもそも壊れにくく設計する」方向にシフトしている。


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