CodeGym /コース /C# SELF /イベント駆動プログラミングの最適化

イベント駆動プログラミングの最適化

C# SELF
レベル 54 , レッスン 2
使用可能

1. 導入

典型的なアプリではイベントは速くてほぼ「無料」だよ — CLR (Common Language Runtime) はイベント処理向けにかなり最適化されている。ただし、アプリが大規模になってイベントが大量に発生し、購読チェーンが長くなり、パフォーマンス要件が厳しくなると、イベントという「シンプル」な構造でもボトルネックになり得る。特にリアルタイム更新が多いシステム、UI、あるいは IoT のセンサーからの何十万もの通知を扱うときに顕著だよ。

このレクチャーでは以下を扱うよ:

  • イベントとデリゲートがパフォーマンスにどう影響するか。
  • どこがボトルネックになりやすいか。
  • 高速なイベントコードを書く方法と、パフォーマンスを阻害する問題を避けるコツ。

.NET におけるイベントの内部構造

前にも触れたけど、イベントはデリゲートのラッパーだよ。デリゲートは invocation list を持つ特別なオブジェクトで、イベントが発火するとそのリストを列挙して各メソッドを呼ぶ。イベントを呼ぶたびに CLR がこのリストを走査して全てのメソッドを同期的に呼ぶよ。(非同期になるのは、あなたが明示的に非同期コードを仕込んだときだけ。)

イメージ図:


[発行者] ----- (event) ---> [Delegate (Invocation List)] --> [ハンドラ 1]
                                                           --> [ハンドラ 2]
                                                           --> [ハンドラ N]

2. デリゲートとイベントのコスト:分解して考える

格納のコスト

  • 各デリゲートは完全なオブジェクトだよ。
  • 各ハンドラ(メソッド購読者)は追加のデリゲートを作る。
  • 購読者が多いほどオブジェクト数が増え、メモリを消費する。

普通の場合はリークやオーバーヘッドはほとんど気にならない。でもハンドラが何千もいるなら考慮が必要だよ。

呼び出しのコスト

  • イベント呼び出し = invocation list の列挙。
  • 各メソッドは同期的に(順番に)呼ばれる。
  • もしハンドラが重い処理をしたり長時間スリープすると、他の全てを遅くする。

例:シンプルな実装


public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... カウントロジックは省略するよ
        // サブスクライバは同期的に呼ばれるよ!
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

もし 1000 人の購読者がいて、それぞれのハンドラが Thread.Sleep(10) なら、イベントの呼び出しが約 10 秒かかっちゃうよ...

3. 「重い」購読者はパフォーマンスの敵

なぜハンドラは「軽く」あるべきか?

  • イベントは同期的に呼ばれるから、発火したスレッドは全てのハンドラが終わるのを待つ。
  • 遅いハンドラが1つあるだけでチェーン全体が遅くなる。
  • ハンドラが例外を投げると、他のハンドラが呼ばれない可能性がある(try/catch で保護していなければ)。

デモ


class Program
{
    static void Main()
    {
        var publisher = new Counter();
        // 速いやつ
        publisher.Counted += (s, e) => Console.WriteLine("最初");
        // 遅いやつ
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // さらにもう一つ
        publisher.Counted += (s, e) => Console.WriteLine("最後");

        // 時間計測
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"すべてのハンドラは {watch.ElapsedMilliseconds} ミリ秒で実行されたよ。");
    }
}

実行してみると明らかな遅延が分かるよ。最初のハンドラはほぼ瞬時、2番目で「待ち」が入り、最後のハンドラはその後に実行される。

実践的な結論

  • イベントハンドラに重いビジネスロジックを直接書かないで!
  • 重い処理は別スレッドやタスク、あるいは非同期ハンドラに投げるのが良いよ。

4. ハンドラの例外:パフォーマンスの罠

もし購読者の一つが例外を投げると、イベント処理が中断されて後続のハンドラが呼ばれないことがあるよ!


publisher.Counted += (s, e) => throw new Exception("エラー!");
publisher.Counted += (s, e) => Console.WriteLine("この行は表示されないよ。");

これを避けるために、各ハンドラを保護しながら手動で列挙して呼ぶと良いよ。

改良版イベント呼び出し


protected virtual void OnCounted()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            try
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"ハンドラ内のエラー: {ex.Message}");
                // ログ出力や特別なエラーハンドリング
            }
        }
    }
}

こうするとイベントはより「タフ」になるよ:一つの購読者が落ちても、他は動き続ける。

5. 非同期(fire-and-forget)イベント

イベントが遅くなり得るなら、ハンドラを別スレッドやタスクで走らせてメインスレッドをブロックしないようにしたくなるよね。

パターン1:各ハンドラを個別に Task で実行


protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // Fire-and-forget: 終了を待たないよ!
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

ただし!並列化には注意

  • 購読者が共有リソースを触るとレースコンディションが起きる可能性があるよ。
  • fire-and-forget のハンドラ内の例外は捕まえにくい。
  • 全ての購読者の完了を待ちたいなら、タスクを集めて Task.WhenAll を使うべき。

UI (WinForms/WPF) の場合は、UI スレッド外でハンドラを呼ぶと InvalidOperationException が出るから絶対にやめてね。

まとめ — 非同期イベントは慎重に設計しよう!

6. イベントの格納と呼び出しの最適化

「空の」イベント:メモリを節約する

クラスに多数のイベントがあって、その多くがほとんど使われない(例えば UI コンポーネントのイベント群)場合に使えるトリックがある:EventHandlerList

仕組み

.NET コントロール(WinForms など)は各イベントごとにデリゲートを保持するのではなく、全てのイベントを一つの構造体(EventHandlerList)に詰めて、少なくとも一つのハンドラが登録されるまで実際のデリゲートを作らないんだ。

EventHandlerList を手動で作る例

using System.ComponentModel; // EventHandlerList がここにあるよ!

class MyControl
{
    private readonly EventHandlerList _events = new EventHandlerList();

    private static readonly object EventMyEvent = new object();

    public event EventHandler MyEvent
    {
        add    { _events.AddHandler(EventMyEvent, value); }
        remove { _events.RemoveHandler(EventMyEvent, value); }
    }

    protected virtual void OnMyEvent()
    {
        var handler = (EventHandler)_events[EventMyEvent];
        handler?.Invoke(this, EventArgs.Empty);
    }
}

なぜこれが有効か:大半が「空」のイベントに対して不要なデリゲートを作らず、メモリを節約できるよ。

7. スレッドセーフティ:レースとロックの回避

.NET のイベント自体はスレッドセーフではないよ!購読 (+=) や解除 (-=) をしている最中に別スレッドでイベントを発火すると、デリゲートが呼び出し直前に null になって NullReferenceException を起こす可能性がある。

ベストプラクティス

  • ?. 演算子を使う(例: Counted?.Invoke(...)) — null を防げるよ。
  • 複雑なケースでは lock でイベントへのアクセスを保護する。


private readonly object _lockObj = new object();
private EventHandler _myEvent;

public event EventHandler MyEvent
{
    add { lock (_lockObj) { _myEvent += value; } }
    remove { lock (_lockObj) { _myEvent -= value; } }
}

protected virtual void OnMyEvent()
{
    EventHandler handler;
    lock (_lockObj)
    {
        handler = _myEvent;
    }
    handler?.Invoke(this, EventArgs.Empty);
}

こんな複雑さが必要なのは?

  • マルチスレッド環境(サーバーやマルチスレッドのパーサーなど)。
  • 購読/解除が別スレッドから行われ、イベント発火が別のスレッドから行われるケース。

8. add/remove アクセサで制御と最適化

特別なケース(例えば全購読をログしたいとか、購読者数を制限したい)では、アクセサを自前実装にしてイベントを管理できるよ:


private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("制限:10人以上の購読者は許可しないよ。");
    }
    remove { _event -= value; }
}

これでできること:

  • カスタムロジックの差し込み。
  • イベントをスレッドセーフにする。
  • 上限チェックや購読/解除のログ出力。

9. 役立つ細かい話

ラムダ式、クロージャーとパフォーマンス

ラムダはオンザフライで購読を書くのに便利だよ:


var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");

でもラムダが外側の変数を捕まえるとクロージャーが作られてメモリ使用が増えることがある。普通の UI ケースでは大きな問題にならないけど、低レイヤーのコードではクロージャーの数や捕まえたオブジェクトのライフタイムを気にした方がいいよ。

面白い事実:
同じ内容のラムダを 2 回追加すると、それぞれ別オブジェクトのデリゲートになって、メソッドは 2 回実行されるよ。

イベントとデリゲートのプロファイリング

アプリが大きくなったら、イベントも他のコードと同じようにプロファイルすべきだよ。

イベントの速度を測るには?

  • 呼び出しから処理終了までの時間計測には Stopwatch を使うと良い。
  • メモリプロファイラ(例: dotMemory、Visual Studio の内蔵ツール)で、解除されずに残っている購読者を見つける。
  • "ゾンビ購読者" を探すには、長寿命オブジェクトの invocation list が長くなっていないかをチェックする。

「最適化と落とし穴」一覧

問題/シナリオ 解決策
長寿命(かつ不要な)イベントが多数ある EventHandlerList を使う
ある購読者が全員を遅くする 重いロジックを task/別スレッドに移す
スレッド安全性 呼び出す前にデリゲートをコピー、追加/削除時に lock
ハンドラ内の例外 各ハンドラ呼び出しを try/catch で保護する
"ゾンビ購読者" によるメモリリーク 必ず解除する、IDisposable を実装する、プロファイルする

図:最適化されたイベントのライフサイクル


+----------------+       +------------------+       +---------------------+
| サブスクライバ作成 |  -->  | 購読 (+=)        |  -->  | Invocation に登録    |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   購読解除 (-=) |                     例外が発生  |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| サブスクライバ破棄  |  -->  | Invocation から削除 |  --> | もうゾンビじゃないよ |
+----------------+       +--------------------+      +----------------------+

10. 面接で「イベントの扱い方」をどう説明するか

もし「C# のイベントの非効率な点は?」や「いつイベントの最適化が必要?」と聞かれたら、次を答えられるようにしておこう:

  • イベントは loose coupling に優れるが、購読者が大量にいる場合やハンドラが重い場合は非効率になり得る。
  • デフォルトでスレッドセーフではない。
  • 手動で解除しないとメモリリークする可能性がある。
  • 大量のプロデューサー/コンシューマーでは EventHandlerList やカスタムアクセサ add/remove が有効。
  • 深い制御はたいてい稀で、ほとんどのケースは標準パターンで十分。

次回のレクチャーでは、より高度なシナリオと実践例を見て、これらの最適化が実際の課題でどう機能するかを確認するよ。

よくある誤解とアンチパターン

  • .NET のイベントは常に速いと思い込むこと — 多数の購読者や重いハンドラが現れると速くなくなるよ。
  • GC が全てを勝手に片付けてくれると思うこと — 解除していなければオブジェクトは死なないよ!
  • イベントをビジネスロジック層間の「遠い」接続に使うこと — 明示的なパターン(例: Mediator)の方が良い場合が多いよ。
2
タスク
C# SELF, レベル 54, レッスン 2
ロック未解除
購読者数を制限して追加・削除する
購読者数を制限して追加・削除する
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION