C#のデリゲートとイベント:.NET 10で検証した基本と落とし穴

C#のデリゲートとマルチキャスト、eventキーワードの保護、EventHandler<T>、購読解除とメモリリーク、?.Invokeのスレッド安全性を.NET 10で検証。CS8618警告と呼び出しコストも実測。

2026年11月改訂: この記事は2021年に韓国語で書いたデリゲート・イベント講座を、日本語で書き直したものです。元の3つの例を .NET 10.0.14(C# 14)の既定のコンソールプロジェクト(dotnet new console、null 許容参照型が有効)でコンパイルし直しました。3つともコンパイルでき出力も同じでしたが、Delegate.Combine の例は CS8600・CS8602 警告、イベントの例は CS8618 警告が出ました。さらにイベントの例は購読者がいないと NullReferenceException で落ちるコードでした。元記事の説明にあった行番号もコードと合っていなかったため削除しています。修正したコードに加え、event キーワードが実際に防ぐもの、購読解除とメモリリーク、スレッド安全な発生方法、マルチキャストの呼び出しコストの実測を足しました。

WinForms や WPF のイベントハンドラーは書けるけれど、「イベントって結局デリゲートでは?」「購読解除は本当に必要?」と思っている人向けの記事です。先に結論を書くと、デリゲートはメソッドとその呼び出し先オブジェクトを持つオブジェクトで、イベントはそれをクラスの外からは +=/-= しかできないように包んだものです。実務でつまずくのは構文ではなく、null の扱い、購読解除忘れ、スレッド間の競合の3つです。Func/Action の詳細(値を返すチェーン、out 引数、インターフェースとの呼び出しコスト比較)は Func と Action デリゲート の記事で扱ったので、ここでは重複させずイベント側に絞ります。

デリゲート:メソッドを値として渡す型

デリゲートは「代理人」の名の通り、メソッドの呼び出しを肩代わりする型です。宣言はメソッドのシグネチャの前に delegate を付けた形です。

delegate 戻り値の型 デリゲート名(引数リスト);

2021年の例は .NET 10 でも警告なしでコンパイルされ、同じ値を出しました。トップレベルステートメントに縮め、比較用にラムダを1行足したものが次です。

PDelegate pd1 = Plus;                                         // メソッドグループ
PDelegate pd2 = delegate (int a, int b) { return a / b; };    // 匿名メソッド (C# 2)
PDelegate pd3 = (a, b) => a / b;                              // ラムダ (C# 3)、意味は同じ
Console.WriteLine($"{pd1(5, 10)} {pd2(10, 5)} {pd3(10, 5)}");

static int Plus(int a, int b) => a + b;
delegate int PDelegate(int a, int b);
15 2 2

元記事はデリゲートを「メソッド参照を含む領域」と説明していましたが、正確には System.MulticastDelegate を継承したクラスのインスタンスです。どのメソッドを呼ぶか(Method)と、インスタンスメソッドならどのオブジェクトで呼ぶか(Target)を覚えています。この Target への参照が、後半で扱うメモリリークの原因になります。引数の型・個数と戻り値の型がデリゲートの宣言と一致しなければ代入できない、という元記事の説明は今もそのまま正しいです。ラムダの書き方は ラムダ式 の記事を参照してください。

マルチキャストデリゲートと呼び出しコストの実測

1つのデリゲートに複数のメソッドをつなぎ、まとめて呼び出せます。元記事は Delegate.Combine を使っていました。

PDelegate pd = (PDelegate)Delegate.Combine(new PDelegate(Plus), new PDelegate(Minus),
    new PDelegate(Division), new PDelegate(Multiplication));
pd(20, 10);

出力は元記事と同じ(20 + 10 = 30 から 20 * 10 = 200 まで)でしたが、null 許容が有効な今の既定プロジェクトでは警告が2つ出ます。

Program.cs(22,28): warning CS8600: Null リテラルまたは Null の可能性がある値を Null 非許容型に変換しています。
Program.cs(23,13): warning CS8602: null 参照の可能性があるものの逆参照です。

Delegate.Combine の戻り値が Delegate? だからです。実務では += を使います。コンパイラーが同じ Delegate.Combine 呼び出しに変換してくれ、キャストも警告もありません。

PDelegate pd = Plus;
pd += Minus;
pd += Division;
pd += Multiplication;
pd(20, 10);
20 + 10 = 30
20 - 10 = 10
20 / 10 = 2
20 * 10 = 200

チェーンの3つの罠(戻り値は最後の1つだけ残る、途中で例外が出ると残りは実行されない、ラムダで -= しても何も外れない)は Func と Action の記事 で実行結果つきでまとめています。

では、マルチキャストは遅いのでしょうか。何もしない静的メソッド(NoInlining)を1000万回呼んで測りました。計測方法:.NET 10.0.14、Release ビルド、Stopwatch、1000万回、ウォームアップ1回のあと3回計測、Apple Silicon Mac(M1 Pro)。

Action<int> single = Sink.A;
Action<int> multi = null!;
for (int k = 0; k < 10; k++) multi += Sink.A;      // ハンドラー10個
var array = new Action<int>[10];                   // デリゲート10個を配列で
…
for (int i = 0; i < N; i++) multi(i);
for (int i = 0; i < N; i++) foreach (var d in array) d(i);
for (int i = 0; i < N; i++) foreach (var d in multi.GetInvocationList()) ((Action<int>)d)(i);
呼び出し方 1回目 2回目 3回目
ハンドラー1個 24.3 ms 24.5 ms 24.9 ms
マルチキャスト、ハンドラー10個 241.3 ms 239.7 ms 245.5 ms
デリゲート配列10個 241.2 ms 241.5 ms 247.9 ms
GetInvocationList() を回して10個 881.5 ms 917.0 ms 893.1 ms
1000万回呼び出しにかかった時間の棒グラフ:ハンドラー1個 24.6ms、ハンドラー10個のマルチキャストデリゲート 242.2ms、配列に入れた10個のデリゲート 243.5ms、GetInvocationList を回して10個呼ぶ 897.2ms。
1000万回呼び出し、計測3回の平均。.NET 10.0.14、Release、Stopwatch、Apple Silicon Mac(M1 Pro)。短いほど速い。

マルチキャストの時間はハンドラー数にきれいに比例し(1個あたり約2.4ns)、配列を自前で回すのと差はありませんでした。「マルチキャストは遅い」という根拠はないということです。一方 GetInvocationList() は呼ぶたびに新しい配列を作ります。ハンドラー10個で呼び出し1回あたり 104バイトの割り当てがあり(GC.GetAllocatedBytesForCurrentThread で計測、通常の呼び出しは0バイト)、時間も約3.7倍でした。ハンドラーごとに例外を個別に捕まえたいときだけ使い、毎秒数万回発生するイベントでは避けるのが無難です。

event キーワードが実際に防いでいるもの

イベントは「何かが起きた」ことを知らせる仕組みで、C# ではデリゲートの上に作られています。元記事の例は1から49まで数え、10の倍数でイベントを発生させます。

public delegate void MyEventHandler(string message);
class Publisher {
    public event MyEventHandler Active;
    public void DoActive(int number) {
        if (number % 10 == 0) Active("Active!" + number);
        else Console.WriteLine(number);
    }
}
// Main: publisher.Active += new MyEventHandler(MyHandler); のあと DoActive(1〜49)

出力は元記事と同じ(1 … 9、Active!10、… Active!40、… 49)でしたが、コンパイル時に次の警告が出ます。

Program.cs(9,37): warning CS8618: null 非許容の イベント 'Active' には、コンストラクターの終了時に null 以外の値が入っていなければなりません。'required' 修飾子を追加するか、イベント を Null 許容として宣言することを検討してください。

この警告は実際のバグを指しています。購読者が1人もいないとイベントの裏のフィールドは null なので、新しい Publisher で DoActive(9)、DoActive(10) と呼ぶだけでこうなります。

9
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Publisher.DoActive(Int32 number) in Program.cs:line 10

(.NET 10 のランタイムのメッセージは OS の言語に関係なく英語で出ます。)直すのは2か所です。宣言を public event MyEventHandler? Active; にして「購読者がいないこともある」と型で示し、呼び出しを Active?.Invoke("Active!" + number); にします。これで警告なしでコンパイルでき、9 を出して正常終了しました。

では event を外してただの public デリゲートフィールドにすると何が違うのか。クラスの外から両方を試しました。

var c = new Counter();
c.Changed += (s, e) => Console.WriteLine("A");
c.Changed = null;                      // イベント:上書き
c.Changed(c, EventArgs.Empty);         // イベント:外から発生

var f = new FieldCounter();
f.Changed += (s, e) => Console.WriteLine("A");
f.Changed += (s, e) => Console.WriteLine("B");
f.Changed = (s, e) => Console.WriteLine("C");   // A と B が消える
f.Changed(f, EventArgs.Empty);                  // 誰でも発生させられる

class Counter { public event EventHandler? Changed; }
class FieldCounter { public EventHandler? Changed; }
Program.cs(3,3): error CS0070: イベント 'Counter.Changed' は、+= または -= の左側にのみ表示されます (型 'Counter' 内で使用する場合を除きます)
Program.cs(4,3): error CS0070: イベント 'Counter.Changed' は、+= または -= の左側にのみ表示されます (型 'Counter' 内で使用する場合を除きます)

イベント側の2行を消すとコンパイルが通り、出力は C の1行だけでした。フィールドでは = 一回でほかの購読者 A と B が黙って消え、どのクラスからでも「起きた」と偽の通知を送れました。この2つ(上書きと外部からの発生)を防ぐのが event の役割です。

EventHandler で書く標準パターン

自前のデリゲート型を宣言する代わりに、.NET の慣習では EventHandler<TEventArgs> を使います。シグネチャが (object? sender, TEventArgs e) に決まっているので、誰が発生させたかと付加情報を一緒に渡せます。あとで情報を増やしても、EventArgs の派生クラスにプロパティを足すだけで購読側は壊れません。

var counter = new Counter();
EventHandler<ThresholdEventArgs> onHit = (sender, e) =>
    Console.WriteLine($"hit {e.Value} (sender: {sender?.GetType().Name})");

counter.ThresholdReached += onHit;
for (int i = 1; i <= 25; i++) counter.Add(i);   // 10, 20
counter.ThresholdReached -= onHit;
counter.Add(30);                                 // 購読者なし:例外は出ない
Console.WriteLine("done");

public sealed class ThresholdEventArgs(int value) : EventArgs
{
    public int Value { get; } = value;
}

public class Counter
{
    public event EventHandler<ThresholdEventArgs>? ThresholdReached;

    public void Add(int n)
    {
        if (n % 10 == 0) OnThresholdReached(new ThresholdEventArgs(n));
    }

    protected virtual void OnThresholdReached(ThresholdEventArgs e)
        => ThresholdReached?.Invoke(this, e);
}
hit 10 (sender: Counter)
hit 20 (sender: Counter)
done

ThresholdEventArgs のプライマリコンストラクターは C# 12 の構文です。protected virtual void On イベント名(...) を用意するのも慣習で、派生クラスが発生処理に割り込んだり処理を足したりできるようにするためです。ラムダで購読するときは、上のように変数に入れておき、同じインスタンスで -= しないと外れません。

図:パブリッシャーの Add メソッドが ThresholdReached?.Invoke(this, e) を呼ぶ。イベントは呼び出しリストを持つ。+= で登録したサブスクライバーAとBは順に呼ばれる。-= で外したサブスクライバーCは点線で示され、もう呼ばれず、GCが回収できる。
パブリッシャーがイベントを発生させると、その時点の呼び出しリストにあるハンドラーだけが実行される。-= で外したハンドラーは呼ばれず、パブリッシャーから参照もされなくなる。

購読解除を忘れるとメモリリークになる

イベントは各ハンドラーとその対象オブジェクト(Target)を参照しています。そのためパブリッシャーが購読者より長生きすると、購読者は誰にも使われていなくても回収されません。アプリ全体で生きるイベントバス、静的イベント、シングルトンのサービスに画面やビューモデルが購読する、というのが典型です。実際に確かめました。

var bus = new Bus();                                  // 長寿命のパブリッシャー
Console.WriteLine($"forgot -= : alive = {Run(bus, unsubscribe: false)}");
Console.WriteLine($"called -= : alive = {Run(bus, unsubscribe: true)}");
GC.KeepAlive(bus);

[MethodImpl(MethodImplOptions.NoInlining)]
static bool Run(Bus bus, bool unsubscribe)
{
    var weak = Open(bus, unsubscribe);
    GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
    return weak.IsAlive;
}

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference Open(Bus bus, bool unsubscribe)
{
    var screen = new Screen(bus);                     // すぐ捨てられる購読者
    if (unsubscribe) screen.Dispose();
    return new WeakReference(screen);
}

class Bus { public event EventHandler? Tick; }

class Screen : IDisposable
{
    private readonly Bus _bus;
    private readonly byte[] _buffer = new byte[1_000_000];
    public Screen(Bus bus) { _bus = bus; _bus.Tick += OnTick; }
    private void OnTick(object? s, EventArgs e) { }
    public void Dispose() => _bus.Tick -= OnTick;
}
forgot -= : alive = True
called -= : alive = False

(Release ビルド、using System.Runtime.CompilerServices; は省略。Bus がイベントを発生させないので warning CS0067 が出ますが、実験には影響しません。)-= を忘れた1MBの Screen は強制 GC のあとも生き残り、Dispose で解除した方は回収されました。画面の開閉を繰り返すとこれが溜まり、閉じた画面のハンドラーが呼ばれ続けるというバグも同時に起きます。ルールは単純で、購読者の方が短命なら、購読と対になる場所(Dispose、画面を閉じる処理)で -= する、です。フォームとその上のボタンのように寿命が同じなら、まとめて回収されるので解除は必須ではありません。

?.Invoke がスレッド安全な理由

数年前までよく見た書き方は if (Changed != null) Changed(this, e); でした。これはフィールドを 2回 読みます。その間に別スレッドが最後の購読者を -= すると、2回目の読み取りで null を呼び出すことになります。1つのスレッドで購読と解除を繰り返し、もう1つのスレッドで両方の書き方を2,000万回ずつ発生させてみました。

class Pub
{
    public event EventHandler? Changed;
    public void RaiseUnsafe() { if (Changed != null) Changed(this, EventArgs.Empty); }
    public void RaiseSafe() => Changed?.Invoke(this, EventArgs.Empty);
}
// スレッドA: p.Changed += h; p.Changed -= h; を2,000万回
// スレッドB: RaiseUnsafe() と RaiseSafe() を2,000万回ずつ、NullReferenceException を数える
ビルド if (Changed != null) Changed(...) Changed?.Invoke(...)
Debug 1回目 113回 0回
Debug 2回目 512回 0回
Release 2回 0回、0回 0回、0回

?.Invoke はフィールドを一時変数に1回だけコピーし、そのコピーを検査して呼ぶので、この競合は原理的に起きません。Release で前者が0回だったのは JIT が2回の読み取りを1回にまとめたためと思われますが、保証された動作ではないので頼ってはいけません。購読側も安全です。フィールド形式のイベントでは、コンパイラーが add/remove を Interlocked.CompareExchange のループで生成します。4スレッドが2,000個ずつ購読すると、イベントには正確に 8,000個残りましたが、同じことを public デリゲートフィールドで行うと 5,416個、もう一度実行すると 5,507個しか残りませんでした(同時の読み取り・結合・書き込みで更新が失われた)。

ただし ?.Invoke が防ぐのは null の呼び出しだけです。コピーしたリストを呼ぶので、別スレッドで -= した直後にも、そのハンドラーが1回呼ばれることはあり得ます。破棄済みのリソースに触るハンドラーは、自分でその状態を確認する必要があります。

まとめ:場面ごとの選び方

場面 おすすめ
メソッドをコールバックとして渡す Func/Action(詳しくはこちら)
「起きた」ことを複数の購読者に知らせる event EventHandler<TEventArgs>?
イベントを発生させる protected virtual void OnXxx(e) => Xxx?.Invoke(this, e);
購読者の方がパブリッシャーより短命 Dispose や画面を閉じる処理で必ず -=
ラムダで購読する 変数に入れておき、同じインスタンスで -=
ハンドラーごとに例外を処理したい GetInvocationList() を回す(呼ぶたびに割り当てあり)
配列の方がマルチキャストより速い? 計測では差なし。読みやすい方を

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple Silicon、M1 Pro)。コンパイラーのメッセージは SDK の日本語表示のままです。

よくある質問

デリゲートのフィールドに event を付けると何が変わる?

クラスの外からは += と -= しか使えなくなります。外から c.Changed = null; と上書きしたり c.Changed(...) で発生させたりすると error CS0070 でコンパイルが止まります。ただの public デリゲートフィールドではどちらも通り、= 一回でほかの購読者が全部消えました。

イベントの購読を解除しないと本当にメモリリークする?

パブリッシャーの方が長生きする場合はします。長寿命のパブリッシャーに += しただけの1MBのオブジェクトは GC.Collect 後も生き残り(IsAlive = True)、Dispose で -= したものは回収されました(False)。イベントがハンドラーの対象オブジェクトを参照しているためです。

if (Changed != null) Changed(...) と Changed?.Invoke(...) は同じでは?

シングルスレッドなら同じですが、別スレッドが同時に購読解除すると違います。フィールドを2回読む前者は、Debug ビルドで2,000万回の競合テストを行うと NullReferenceException が113回と512回出ました。1回しか読まない ?.Invoke は0回でした。

  • #C#
  • #デリゲート
  • #イベント
  • #.NET

t.laboratory

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

運営者情報 · アプリ

↑