C#イベントの応用:add/remove・asyncハンドラー・弱いイベント

C#イベントの応用を.NET 10で検証。カスタムadd/remove、キャンセル可能なEventArgs、プロセスを落とすasync voidハンドラー、弱いイベントパターン、インターフェースのイベント。

2026年11月改訂: この記事は2021年に韓国語で書いた、ごく短いイベントの紹介を日本語向けに書き直したものです。例を .NET 10.0.14(C# 14)でコンパイルし直すと、コンパイルできませんでした。class が clsass になっていた(error CS1513)ことと、for 文のセミコロン抜け(error CS1002)です。直すと意図どおり2から10までの偶数を出力し、イベントが null 非許容だという警告 CS8618 が新たに出ました。また原文は、イベントを外から呼べない理由を「セキュリティ上の理由だと思う」と推測していましたが、正確にはカプセル化であり、リフレクションを使えば呼べてしまいます(下で確認します)。event が何を防ぐか、EventHandler<T>、購読解除、?.Invoke といった基本は C#のデリゲートとイベント に移し、この記事では応用だけを扱います。

イベントの宣言や発行はもう書いているけれど、一段難しい疑問にぶつかった人向けの記事です。購読された瞬間に処理を走らせるには?ハンドラーから発行側に答えを返すには?async ハンドラー1つでなぜプロセスごと落ちたのか?購読解除を保証できないとき、リークはどう防ぐのか?インターフェースではイベントはどう動くのか?どれも .NET 10 で実行したコードで確かめています。

2021年の例と「外から呼べない」の本当の意味

修正した元の例では、EventManager に public event MyDelegate eventCall; を宣言し、静的メソッドを購読させて、NumberCheck で偶数のときにイベントを発行します。

Program.cs(4,33): warning CS8618: Non-nullable event 'eventCall' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the event as nullable.
2는 짝수 입니다.
4는 짝수 입니다.
…
10는 짝수 입니다.

(出力文字列は原文の韓国語のままで、「2は偶数です」という意味です。)この警告は実際のバグを指しています。購読者がいないと eventCall(num) は NullReferenceException を投げます。MyDelegate? で宣言し、eventCall?.Invoke(num) で呼び出してください。詳しい説明とスレッド競合の実験は 基本編 にあります。

「セキュリティ」の点を補足します。クラスの外からは、event には += と -= しか使えません(呼ぶと error CS0070)。これは不注意な呼び出しから設計を守る仕組みで、悪意のあるコードを止める仕組みではありません。コンパイラーはハンドラーを private フィールドに保存し、リフレクションはそのフィールドを読めます。

var field = typeof(Door).GetField("_opening", BindingFlags.NonPublic | BindingFlags.Instance)!;
var raw = (EventHandler<OpeningEventArgs>?)field.GetValue(door);
raw?.Invoke(null, new OpeningEventArgs("reflection"));   // ハンドラーが実行された

event はアクセス制御ではなく、API の約束だと考えるのが正確です。

カスタム add/remove アクセサー

フィールド形式のイベント(public event EventHandler? Changed;)では、コンパイラーが private のデリゲートフィールドと add・remove メソッドを生成します。この2つのメソッドは自分でも書けます。次の Door は購読を記録し、.NET 9 の System.Threading.Lock を使っています(lock 文がこれを特別扱いするのは C# 13 からで、LangVersion 12 では error CS9202 になりました)。

class Door
{
    private EventHandler<OpeningEventArgs>? _opening;
    private readonly Lock _gate = new();
    public int Count => _opening?.GetInvocationList().Length ?? 0;

    public event EventHandler<OpeningEventArgs>? Opening
    {
        add    { lock (_gate) { _opening += value; } Console.WriteLine($"  + {value?.Method.Name}"); }
        remove { lock (_gate) { _opening -= value; } Console.WriteLine($"  - {value?.Method.Name}"); }
    }

    public bool Open(string who)
    {
        var e = new OpeningEventArgs(who);
        _opening?.Invoke(this, e);
        return !e.Cancel;
    }
}

ラムダを2つ購読させ、あとで1つを解除すると、アクセサーが記録を残しました。

  + <<Main>$>b__0_0
  + <<Main>$>b__0_1
  …
  - <<Main>$>b__0_0
subscribers now: 1

ラムダの名前はコンパイラーが付けたものなので、匿名ハンドラーを見分けるのに Method.Name は役に立たないこともわかります。アクセサーを書くとイベント名はもうフィールドを指さないため、クラスの中でも呼び出せなくなります。

error CS0079: The event 'Door.Opened' can only appear on the left hand side of += or -=

代わりに裏のフィールド(_opening?.Invoke(...))を呼びます。アクセサーを自分で書く典型的な理由は次のとおりです。

  • 転送:ラッパーが event EventHandler Changed { add => _inner.Changed += value; remove => _inner.Changed -= value; } として、購読者を内部オブジェクトに直接つなぐ。
  • 遅延開始:最初のハンドラーが付いたときにタイマー・ファイル監視・ソケット受信を始め、最後のハンドラーが外れたら止める。
  • 別の保存先:弱参照のリスト(後述)や、ほとんど使われないイベントが何十もあるクラス向けの EventHandlerList。WinForms のコントロールはこの方式です。
  • 検証・診断:重複購読を拒否したり、リーク調査のときに誰が購読したかを記録したりする。

フィールド形式のイベントはすでにロックなしの Interlocked.CompareExchange ループを使っているので、スレッドセーフのためだけにアクセサーを書く必要はありません。

独自の EventArgs と EventHandler

EventHandler<TEventArgs> は (object? sender, TEventArgs e) という標準のシグネチャです。残る問題は TEventArgs を何にするかです。

ハンドラーが発行側に情報を返す必要があるなら、EventArgs の派生クラスが答えです。上の Opening イベントは、.NET の CancelEventArgs と同じ発想で Cancel フラグを使っています。

sealed class OpeningEventArgs(string who) : EventArgs
{
    public string Who { get; } = who;
    public bool Cancel { get; set; }
}

door.Opening += (s, e) => { if (e.Who == "guest") e.Cancel = true; };
door.Opening += (s, e) => Console.WriteLine($"  handler 2 sees Cancel={e.Cancel}");
Console.WriteLine($"admin opened: {door.Open("admin")}");
Console.WriteLine($"guest opened: {door.Open("guest")}");
  handler 2 sees Cancel=False
admin opened: True
  handler 2 sees Cancel=True
guest opened: False

全ハンドラーが購読順に1つの args オブジェクトを共有するので、2番目のハンドラーは1番目の決定を見られます。便利ですが結合でもあり、同じプロパティに最後に書き込んだハンドラーが勝ちます。

よくある近道はコンパイルできません。

error CS8864: Records may only inherit from object or another record

record SavedEventArgs(string Path) : EventArgs; で出たエラーです。レコードは通常のクラスを継承できません。上のようにプライマリコンストラクターを使ったクラスにするか、EventArgs をそもそも使わない手があります。.NET Framework 4.5 以降の EventHandler<TEventArgs> には制約がないので、event EventHandler<double>? Changed; を Changed?.Invoke(this, 21.5) で発行すると now 21.5 C と出力されました。不変のレコードも EventHandler<SavedInfo> として同じように使えます。

渡すもの 選ぶもの
なし EventHandler と EventArgs.Empty
読み取り専用の値 レコードを使った EventHandler<T>、または sealed な EventArgs 派生クラス
ハンドラーが答える(キャンセル、処理済み、結果) 設定可能なプロパティを持つ EventArgs 派生クラス
.NET のガイドラインに沿う公開ライブラリ EventArgs 派生クラス(あとでメンバーを足しても互換性が壊れない)

async イベントハンドラー:async void の落とし穴

イベントハンドラーの戻り値は void です。そこに async ラムダを購読させると、コンパイラーはそれを async void メソッドにします。await のあとで失敗するハンドラーを購読させ、発行を try で囲みました。

var uploader = new Uploader();
uploader.Done += async (s, e) =>
{
    await Task.Delay(50);
    throw new InvalidOperationException("thumbnail failed");
};
try
{
    uploader.Finish();                       // Done?.Invoke(this, EventArgs.Empty)
    Console.WriteLine("Finish() returned normally");
}
catch (Exception e) { Console.WriteLine($"caught: {e.Message}"); }   // 実行されない

await Task.Delay(500);
Console.WriteLine("still running?");
Finish() returned normally
Unhandled exception. System.InvalidOperationException: thumbnail failed
   at Program.<>c.<<<Main>$>b__0_0>d.MoveNext() in …/Program.cs:line 5
--- End of stack trace from previous location ---
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   …

シェルが報告した終了コードは134で、still running? は最後まで出力されませんでした。ハンドラーは最初の await で Finish() に戻ったので、try ブロックは普通に終わっています。例外はその50ms後、誰も待っていないスレッドプール上で発生し、そこでの未処理例外はプロセスを終了させます。Microsoft Learn の非同期プログラミングのガイドも同じことを説明しています。async void メソッドの例外は、そのメソッドが始まったときの SynchronizationContext で再スローされます。UI アプリなら、あなたの catch ではなく UI スレッドの未処理例外の経路に行くということです。

2段の図。上段:Done?.Invoke が async void ハンドラーを呼び、ハンドラーは最初の await で呼び出し元に戻る。呼び出し元の try ブロックは普通に終わる。あとでハンドラーがスレッドプール上で例外を投げるが、待っている者がいないため、未処理例外でプロセスが終了する。下段:Task を返すデリゲートでイベントを宣言し、発行側が GetInvocationList から Task を集めて Task.WhenAll を await するので、例外は呼び出し元の catch に届き、プロセスは動き続ける。
イベント発行を try/catch で囲んでも async void ハンドラーの例外が捕まらない理由と、捕まえられる Task を返す形。

対策は2つあります。UI フレームワークの Click のように自分が持っていないイベントなら、ハンドラー本体全体を try/catch で囲んでログを取ります。自分が持っているイベントなら、Task を返す形にします。

delegate Task AsyncEventHandler<TArgs>(object sender, TArgs e, CancellationToken ct);

class Uploader
{
    public event AsyncEventHandler<EventArgs>? DoneAsync;

    public async Task FinishAsync(CancellationToken ct = default)
    {
        var handlers = DoneAsync;
        if (handlers is null) return;
        var tasks = handlers.GetInvocationList()
            .Cast<AsyncEventHandler<EventArgs>>()
            .Select(h => h(this, EventArgs.Empty, ct));
        await Task.WhenAll(tasks);
    }
}

ハンドラーを3つ(1つは20ms後に失敗)付けた結果です。

  notify ok
  thumbnail ok
caught: InvalidOperationException: index failed
process keeps running

終了コードは0。全ハンドラーが並行に実行され、失敗は呼び出し元に届きました。補足が2つあります。await Task.WhenAll は最初の例外だけを再スローし、全体は返された Task の Exception.InnerExceptions に入っています。ハンドラーを1つずつ順番に動かしたいなら、foreach で1つずつ await します。マルチキャストデリゲートを直接呼ばずに GetInvocationList() を使う理由は コールバックとチェーンの記事 にあります。直接呼ぶと、最後のハンドラーの Task しか返ってきません。

弱いイベントパターン

基本編では、-= を忘れた購読者は発行側が生きている間いっしょに生き残ることを確かめました。普通は Dispose で購読を解除すれば解決します。それを保証できないとき(プラグイン構成、短命な画面がたくさん購読する長寿命のキャッシュなど)は、弱いイベントで購読者を WeakReference だけで保持し、GC が回収できるようにします。最小限の実装です。

sealed class WeakEvent<T>
{
    private readonly List<(WeakReference<object>? Target, MethodInfo Method)> _subs = [];
    public int Count => _subs.Count;

    public void Add(EventHandler<T> h) =>
        _subs.Add((h.Target is null ? null : new WeakReference<object>(h.Target), h.Method));

    public void Raise(object? sender, T e)
    {
        _subs.RemoveAll(x => x.Target is not null && !x.Target.TryGetTarget(out _));
        foreach (var (wr, method) in _subs.ToArray())
        {
            object? target = null;
            if (wr is null || wr.TryGetTarget(out target))
                method.Invoke(target, [sender, e]);
        }
    }
}

インスタンスメソッド OnChanged で購読し、解除しなかった Screen は回収され(screen alive after GC (no -=): False)、次の Raise で整理されました(subscribers left: 0)。ここまでは順調です。落とし穴はこの先です。ローカル変数で生かしたままの2つ目の Screen が、ローカル変数をキャプチャするラムダで購読しました。

  lambda-screen got it
subscribers left: 0  (keeper is still alive: True)

ハンドラーは1回動いたあと、画面は生きているのに GC のあと消えました。キャプチャするラムダの Target はコンパイラーが作ったクロージャーのオブジェクトで、それを参照していたのはデリゲートだけでした。弱参照で持っていたので回収されたわけです。弱いイベントにはインスタンスメソッドのハンドラーを使う必要があります(または購読者がデリゲートを自分で強参照しておく)。

コストも測りました。購読者1つ、100万回の発行(.NET 10.0.14、Release、Apple Silicon Mac M1 Pro、ウォームアップ後に3回)。

発行方法 1回目 2回目 3回目 1回あたりの割り当て
普通のイベント 2.1 ms 2.1 ms 2.0 ms 0 B
上の WeakEvent<T> 71.8 ms 72.4 ms 71.4 ms 80 B

1回あたり約72nsと80バイトで、リフレクション呼び出し・引数の配列・スナップショットのコピーが原因です。「設定が変わった」程度のイベントなら十分ですが、フレームごとに発行するイベントには重すぎます。WPF には同じパターンを組み込んだ WeakEventManager があります。Windows 専用なので、ここでは試していません。

インターフェースのイベント

インターフェースにもイベントを宣言でき、実装クラスは普通のフィールド形式のイベントで満たします。

interface INotify { event EventHandler? Tick; }
class Clock : INotify
{
    public event EventHandler? Tick;
    public void Raise() => Tick?.Invoke(this, EventArgs.Empty);
}

いちばん有名な例は INotifyPropertyChanged です。いろいろな形をコンパイルして確かめたルールが2つあります。

  1. 明示的な実装にはアクセサーが必要です。 event EventHandler? INotify.Tick; は error CS0071: An explicit interface implementation of an event must use event accessor syntax になります(add/remove がないので CS0535 も出ます)。event EventHandler? INotify.Tick { add => _tick += value; remove => _tick -= value; } と書き、_tick を呼び出します。2つのインターフェースが同じ名前で意味の違うイベントを宣言しているときに必要になります。
  2. 静的抽象イベント(C# 11)。 インターフェースが静的イベントを要求でき、ジェネリック制約を通して使います。
static void Fire<T>() where T : IPlugin { T.Loaded += (s, e) => Console.WriteLine($"{typeof(T).Name} loaded"); T.RaiseLoaded(); }
interface IPlugin { static abstract event EventHandler? Loaded; static abstract void RaiseLoaded(); }

出力は Plugin loaded でした。LangVersion 10 では error CS8703("The modifier 'abstract' is not valid for this item in C# 10.0")となり、C# 11 以上が必要なことを確認しました。静的イベントはプロセスと同じ寿命なので、リークの注意は2倍効いてきます。

まとめ

課題 使うもの
購読された時点で処理を走らせたい カスタム add/remove、発行は裏のフィールドで
ハンドラーが発行側に答える 設定可能なプロパティ(Cancel など)を持つ EventArgs 派生クラス
読み取り専用のデータを渡す レコードを使った EventHandler<T>、または sealed な EventArgs 派生クラス
自分のものでないイベントで async 処理 ハンドラー本体全体を try/catch
自分のイベントで async 処理 Task を返すデリゲート + GetInvocationList() + Task.WhenAll
購読者が確実に解除できない 弱いイベント(インスタンスメソッドのハンドラーのみ)
2つのインターフェースに同名のイベント アクセサー付きの明示的な実装

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple Silicon、M1 Pro)。

よくある質問

イベントの発行を try/catch で囲んだのに、async ハンドラーの例外が捕まらないのはなぜ?

EventHandler に登録した async ラムダは async void としてコンパイルされます。最初の await で発行側に戻るので try ブロックは普通に終わり、例外はあとで誰も待っていないスレッドプール上で投げられます。コンソールでの検証では "Unhandled exception" を出力し、終了コード134でプロセスが終わりました。Task を返すデリゲートでイベントを宣言し、GetInvocationList() で集めた Task を Task.WhenAll で await するか、ハンドラーの中で例外を捕まえます。

C# のレコードを EventArgs として使える?

EventArgs を継承する形では使えません。record SavedEventArgs(string Path) : EventArgs; は error CS8864(レコードは object か別のレコードからしか継承できない)になります。.NET Framework 4.5 以降の EventHandler<TEventArgs> には EventArgs の制約がないので、EventHandler<SavedInfo> に record SavedInfo を使うか、プライマリコンストラクターを使った EventArgs の派生クラスにします。

イベントの add/remove アクセサーを自分で書くべきなのはどんなとき?

購読のタイミングで何かをしたいときです。購読者のログや検証、内部オブジェクトへの転送、フィールド以外(弱参照のリストや EventHandlerList)への保存、独自のロックなどです。その代わり、クラスの中でもイベント名では呼び出せなくなり(error CS0079)、裏のデリゲートフィールドを呼ぶことになります。

  • #C#
  • #イベント
  • #async
  • #EventHandler
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑