0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

イベント購読トラブル7チェック|解除漏れ/二重発火/メモリリーク(C#)【鍛錬K37】

0
Last updated at Posted at 2026-02-23

連載Index:読む順・公開済リンクが最新: S00_門前の誓い_総合Index
delegate/ラムダの前提: K26【鍛錬】C# ラムダ式入門

event の困りごとは、だいたいこの辺が起こる。

  • 解除したのに通知が止まらない
  • 二重に発火する
  • 画面を閉じても通知が飛ぶ
  • GCされず、メモリが増える

当たりやすい原因から順に切り分けられるように、チェック表と章番号を対応させてある。まずは表から入ると早い。

自分が詰まったところ

画面を閉じたのに通知が飛び続けて、解除漏れを疑った。
add/remove のログを入れると購読件数だけ増えていて、-= が空振りになっていた。

このページでやることは次。

  • 起きている事象から、当たりやすい原因へ絞る
  • 解除の置き先を決め、漏れを減らす
  • 購読件数と生存確認で直り方を確かめる

小さな前提

このページでは、用語を3つだけにして同じ言い方で通す。

  • publisher:通知を出す側(event を持つ側)
  • subscriber:通知を受ける側(画面/コンポーネントになりがち)
  • handler:通知を受ける関数(OnChanged など)
publisher.Changed += handler; // 購読:呼び出し先一覧が増える
publisher.Changed -= handler; // 解除:同じ handler 参照だけ外れる
  • += で publisher の呼び出し先一覧が増える。増えた分だけ呼び出し回数・走査回数が増えがち
  • -= は参照一致でしか外れない。別参照だと空振りになりやすい
  • publisher が長寿命だと、一覧が subscriber を参照し続けて subscriber が残りやすい(GCされない / メモリが増える)
  • 解除コードの置き先が散ると抜けが出やすい。Dispose に集めると追いやすい

まず確認するチェック表

下の行順が、本文の ## 1.## 7. と同じ並び。

まず探す見え方 直す方向 増えやすいもの
何が増えているか分からない 通知元で add/remove をログに出し、購読件数と参照の増え方を掴む → 第1章 呼び出し先一覧 / 購読回数
-= を通したつもりでも通知が残る 購読と解除で同じ参照を使う。ラムダ直書きを避け、参照を保持する → 第2章 解除空振り / 呼び出し先一覧
通知が2回以上飛ぶ += が複数回通る経路を減らす。必要なら件数を増やさない形にする → 第3章 呼び出し回数 / 探索回数
閉じた画面へ通知が飛び続ける 解除の置き先を Dispose に集め、通し忘れを減らす → 第4章 参照保持 / GC
static / singleton へ購読してメモリが増える 長寿命の通知元は解除到達を保証する。WPFだけで成立する場面なら WeakEventManager も候補 → 第5章 参照保持 / GC / メモリ
通知元の差し替え後も通知が残る 「今購読している通知元」を保持し、差し替え時に外してから入れる → 第6章 外す相手ズレ
GCされないか判断が迷う WeakReference で生存確認し、参照が残る線を絞る → 第7章 GC / 参照

いちばん短い確認コード

using System;

/// <summary>
/// 通知を出す側。購読件数が増える・減る「入口」になる。
/// </summary>
public sealed class Publisher
{
    /// <summary>
    /// 通知。購読が残ると呼び出し先一覧が残りやすい。
    /// </summary>
    public event EventHandler? Changed;

    /// <summary>
    /// 通知を発火する。subscriber が残っていれば呼ばれ続ける。
    /// </summary>
    public void Raise()
        => Changed?.Invoke(this, EventArgs.Empty);
}

/// <summary>
/// 通知を受ける側。Close() が外れたかが最初の観測点になる。
/// </summary>
public sealed class Subscriber_Min
{
    private readonly Publisher _publisher;

    /// <summary>
    /// 購読を開始する。ここで += が入る。
    /// </summary>
    public Subscriber_Min(Publisher publisher)
    {
        _publisher = publisher;
        _publisher.Changed += OnChanged;
    }

    /// <summary>
    /// 購読を解除する。ここで -= が空振りだと通知が残る。
    /// </summary>
    public void Close()
        => _publisher.Changed -= OnChanged; // 参照一致で外れる

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void OnChanged(object? sender, EventArgs e)
    {
        // 通知を受けたときの処理
    }
}

参照の線

観測(増え方を掴む)

  • += で publisher の handler 一覧(InvocationList)が増える
  • 一覧が伸びるほど、呼び出し回数・走査回数が増えやすい

症状(二重発火)

  • handler 一覧に同じ handler が複数入ると、subscriber 側で複数回動く

残り(GCされない / メモリ増)

  • handler 一覧が subscriber を参照し続けることがある
  • publisher が長寿命だと参照が残りやすく、subscriber が GC されずメモリが増えやすい

1. 購読件数 をログで出す

重くなるパターン
何が起きているか分からず、+= の積み上がりや -= の空振りに気づくまでが長い。

軽くする方法
通知元側で add/remove を実装し、参照と購読件数をログに出す。remove の前後で件数が動かないときは、空振りの線が濃い。

何が起きているか
全件走査 / 探索回数 / 呼び出し回数 / 割り当て。delegate増。

using System;
using System.Diagnostics;

/// <summary>
/// add/remove を持ち、購読件数を観測できる通知元。
/// </summary>
public sealed class LoggablePublisher
{
    private EventHandler? _changed;

    /// <summary>
    /// 現在の購読件数。二重発火や解除空振りの足がかりになる。
    /// </summary>
    public int SubscriberCount
        => _changed?.GetInvocationList().Length ?? 0;

    /// <summary>
    /// 通知。add/remove で参照と件数をログへ出す。
    /// </summary>
    public event EventHandler Changed
    {
        add
        {
            // 観測点:どのハンドラが何回入っているか。件数で掴む。
            _changed += value;
            Debug.WriteLine($"add:    {Describe(value)} count={SubscriberCount}");
        }
        remove
        {
            // 観測点:remove が本当に外れたか。件数差で掴む。
            var before = SubscriberCount;
            _changed -= value;
            var after = SubscriberCount;

            var miss = (before == after) ? " (remove miss)" : "";
            Debug.WriteLine($"remove: {Describe(value)} count={after}{miss}");
        }
    }

    /// <summary>
    /// 通知を発火する。
    /// </summary>
    public void Raise()
        => _changed?.Invoke(this, EventArgs.Empty);

    /// <summary>
    /// ログ向けの短い表現を作る。
    /// </summary>
    private static string Describe(Delegate d)
        => $"{d.Target?.GetType().Name ?? "(static)"}.{d.Method.Name}";
}

2. ラムダ直書き-= が空振りになる

重くなるパターン
購読と解除でラムダを別々に書き、見た目は似ていても参照が別になりやすい。

軽くする方法
解除に使うハンドラ参照をフィールドへ保持し、+= / -= の両方で使い回す。

何が起きているか
参照一致しない -= は空振りになりやすい / 呼び出し先一覧が残る / 参照が残る。

using System;

/// <summary>
/// ラムダ直書きが混ざると -= が空振りになりやすい例。
/// </summary>
public sealed class Subscriber_Lambda
{
    private readonly Publisher _publisher;

    /// <summary>
    /// 解除で使う handler 参照。ここが一致しているかが観測点。
    /// </summary>
    private readonly EventHandler _handler;

    /// <summary>
    /// handler を一度だけ作って保持し、+= / -= の両方で使う。
    /// </summary>
    public Subscriber_Lambda(Publisher publisher)
    {
        _publisher = publisher;

        // 解除でも同じ参照を使うので、一度だけ作って保持する。
        _handler = (_, __) => Handle();
    }

    /// <summary>
    /// 悪い例。毎回別参照のラムダが生成されやすい。
    /// </summary>
    public void Open_Bad()
        => _publisher.Changed += (_, __) => Handle();

    /// <summary>
    /// 悪い例。Open_Bad のラムダと参照が一致しないので空振りになりやすい。
    /// </summary>
    public void Close_Bad()
        => _publisher.Changed -= (_, __) => Handle();

    /// <summary>
    /// 良い例。保持した参照で購読する。
    /// </summary>
    public void Open()
        => _publisher.Changed += _handler;

    /// <summary>
    /// 良い例。保持した参照で解除する。
    /// </summary>
    public void Close()
        => _publisher.Changed -= _handler;

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void Handle()
    {
        // 通知を受けたときの処理
    }
}

3. += が複数回通って 二重発火 になる

重くなるパターン
表示・再初期化などで += が複数回通り、同じハンドラが何回でも一覧へ入る。

軽くする方法
+= が複数回通る経路を減らす。経路の都合で難しい場合は、件数を増やさない形にする。

何が起きているか
呼び出し回数 / 探索回数 / 全件走査 が増える。

例外
意図して複数のハンドラを登録している場合は別。

using System;

/// <summary>
/// 表示のたびに += が通ると二重発火になりやすい例。
/// </summary>
public sealed class Subscriber_DoubleFire
{
    private readonly Publisher _publisher;

    /// <summary>
    /// 通知元を受け取り保持する。
    /// </summary>
    public Subscriber_DoubleFire(Publisher publisher)
        => _publisher = publisher;

    /// <summary>
    /// 悪い例。表示のたびに += が積み上がる。
    /// </summary>
    public void OnViewShown_Bad()
        => _publisher.Changed += OnChanged;

    /// <summary>
    /// 良い例。結果を一定にして、件数の積み上がりを止める。
    /// </summary>
    public void OnViewShown()
    {
        // 観測点:件数を増やさないこと。積み上がりを止める。
        _publisher.Changed -= OnChanged;
        _publisher.Changed += OnChanged;
    }

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void OnChanged(object? sender, EventArgs e)
    {
        // 通知を受けたときの処理
    }
}

4. 解除の置き先を Dispose に集める

重くなるパターン
解除が複数の場所へ散り、どこかの経路で -= が通らない。

軽くする方法
購読のたびに「解除トークン」を作り、Dispose で一括解除する。画面なら Dispose / Closed など、必ず通る場所へ集める。

何が起きているか
参照が残る / GCされない / メモリが増える。

using System;
using System.Collections.Generic;
using System.Threading;

/// <summary>
/// IDisposable を束ねて、解除の到達を1か所へ集める入れ物。
/// </summary>
public sealed class Subscriptions : IDisposable
{
    private readonly List<IDisposable> _items = new();
    private int _disposed;

    /// <summary>
    /// 登録する。すでに Dispose 済みなら即 Dispose する。
    /// </summary>
    public void Add(IDisposable d)
    {
        if (Volatile.Read(ref _disposed) != 0)
        {
            d.Dispose();
            return;
        }
        _items.Add(d);
    }

    /// <summary>
    /// 全解除する。解除漏れの線を細くする。
    /// </summary>
    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0) return;

        foreach (var d in _items) d.Dispose();
        _items.Clear();
    }
}

/// <summary>
/// 購読と解除をペアで扱うための最小トークン。
/// </summary>
public sealed class Subscription : IDisposable
{
    private Action? _dispose;

    /// <summary>
    /// 解除処理を受け取って保持する。
    /// </summary>
    public Subscription(Action dispose) => _dispose = dispose;

    /// <summary>
    /// 解除を1回だけ実行する。
    /// </summary>
    public void Dispose()
        => Interlocked.Exchange(ref _dispose, null)?.Invoke();

    /// <summary>
    /// 解除処理から Subscription を作る。
    /// </summary>
    public static IDisposable Create(Action dispose) => new Subscription(dispose);
}

/// <summary>
/// event 購読を IDisposable で返すヘルパ。
/// </summary>
public static class EventSubscribe
{
    /// <summary>
    /// 購読し、解除トークンを返す。
    /// </summary>
    public static IDisposable Subscribe(Publisher p, EventHandler h)
    {
        p.Changed += h;
        return Subscription.Create(() => p.Changed -= h);
    }
}

/// <summary>
/// 画面っぽい寿命を持つ側。Dispose で解除をまとめて実行する。
/// </summary>
public sealed class ViewLike : IDisposable
{
    private readonly Subscriptions _subs = new();

    /// <summary>
    /// 生成時に購読し、解除トークンを保持する。
    /// </summary>
    public ViewLike(Publisher publisher)
        => _subs.Add(EventSubscribe.Subscribe(publisher, OnChanged));

    /// <summary>
    /// 画面クローズ相当。購読をまとめて解除する。
    /// </summary>
    public void Dispose() => _subs.Dispose();

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void OnChanged(object? sender, EventArgs e) { }
}

5. 長寿命 の通知元へ購読して GCされない

重くなるパターン
static / singleton / イベントバスなど、通知元が長寿命のまま購読が残る。

軽くする方法
長寿命の通知元へ購読するなら、解除が必ず通る置き先を用意して到達を保証する。WPFだけで成立する場面なら WeakEventManager も候補になる。

何が起きているか
参照が残る / GC / メモリ が増える。

using System;

/// <summary>
/// 長寿命になりがちな通知元(static event の例)。
/// </summary>
public static class GlobalEvents
{
    /// <summary>
    /// static event。解除が抜けると短命の通知先が残りやすい。
    /// </summary>
    public static event EventHandler? Something;

    /// <summary>
    /// 通知を発火する。
    /// </summary>
    public static void Raise()
        => Something?.Invoke(null, EventArgs.Empty);
}

/// <summary>
/// static event へ購読する側。解除到達を Dispose で持つ。
/// </summary>
public sealed class Subscriber_Static : IDisposable
{
    private readonly Subscriptions _subs = new();

    /// <summary>
    /// 購読を開始し、解除トークンを保持する。
    /// </summary>
    public Subscriber_Static()
    {
        GlobalEvents.Something += OnSomething;
        _subs.Add(Subscription.Create(() => GlobalEvents.Something -= OnSomething));
    }

    /// <summary>
    /// 解除到達を保証する。
    /// </summary>
    public void Dispose() => _subs.Dispose();

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void OnSomething(object? sender, EventArgs e) { }
}
using System;
using System.Windows;

/// <summary>
/// WPF側の通知元(WeakEventManager の例)。
/// </summary>
public sealed class Publisher_Wpf
{
    /// <summary>
    /// 通知。
    /// </summary>
    public event EventHandler<EventArgs>? Changed;

    /// <summary>
    /// 通知を発火する。
    /// </summary>
    public void Raise() => Changed?.Invoke(this, EventArgs.Empty);
}

/// <summary>
/// WPFのみで成立する場面の候補。解除漏れの影響を小さくする狙い。
/// </summary>
public sealed class Subscriber_Wpf_Weak
{
    /// <summary>
    /// WeakEventManager 経由で購読する。
    /// </summary>
    public Subscriber_Wpf_Weak(Publisher_Wpf p)
        => WeakEventManager<Publisher_Wpf, EventArgs>.AddHandler(p, nameof(Publisher_Wpf.Changed), OnChanged);

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void OnChanged(object? sender, EventArgs e) { }
}

6. 通知元の差し替えで 外す相手 がズレる

重くなるパターン
購読した通知元が差し替わるのに、「どの相手へ購読したか」を保持できていない。

軽くする方法
「今購読している通知元」を保持し、差し替え時に外してから入れる。ハンドラ参照も保持して参照一致を作る。

何が起きているか
外す相手ズレ / 解除空振り / 呼び出し先一覧が残る。

using System;

/// <summary>
/// 通知元が差し替わる場面の購読管理例。
/// </summary>
public sealed class Subscriber_Switch
{
    private Publisher? _current;

    /// <summary>
    /// handler 参照を保持し、参照一致で -= が通るようにする。
    /// </summary>
    private readonly EventHandler _handler;

    /// <summary>
    /// handler を1回だけ確保する。
    /// </summary>
    public Subscriber_Switch()
        => _handler = OnChanged;

    /// <summary>
    /// 次の通知元へ付け替える。前の相手から外してから入れる。
    /// </summary>
    public void Attach(Publisher next)
    {
        if (_current is not null)
            _current.Changed -= _handler;

        _current = next;
        _current.Changed += _handler;
    }

    /// <summary>
    /// 現在の通知元から外す。
    /// </summary>
    public void Detach()
    {
        if (_current is null) return;

        _current.Changed -= _handler;
        _current = null;
    }

    /// <summary>
    /// 通知を受けたときの処理。例なので中身は省略する。
    /// </summary>
    private void OnChanged(object? sender, EventArgs e) { }
}

7. WeakReferenceGC残り を短く確認する

重くなるパターン
「GCされない」の判断が付かず、原因切り分けが進みにくい。

軽くする方法
WeakReference を作り、強参照を手放してから GC を走らせ、生存しているかを確認する。生存が続くなら、どこかで強参照が残っている。

何が起きているか
参照が残る / GCされない / メモリが増える。

using System;

/// <summary>
/// WeakReference を使った「生存確認」用ユーティリティ。
/// </summary>
public static class GcCheck
{
    /// <summary>
    /// 対象の WeakReference を作る。
    /// </summary>
    public static WeakReference CreateWeak(object obj)
        => new WeakReference(obj);

    /// <summary>
    /// 検証用の GC 実行。通常コードへ入れる用途は想定しない。
    /// </summary>
    public static void RunGcForCheck()
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }

    /// <summary>
    /// 生存しているかを見る。true が続くなら強参照が残っている線が濃い。
    /// </summary>
    public static bool IsAlive(WeakReference wr)
        => wr.IsAlive;
}

まとめ:迷ったらこのチェックから

困りごとが出たときは、当たりやすい原因から順に切り分けられるように、1→7 の並びにしてある。番号は本文の章番号と同じ。

最後のチェック

  • -= が同じ参照を外している。ラムダを別の場所で作り直していない
  • += が複数回通らない形になっている。必要なら -= の後に += で結果を一定にしている
  • 解除の置き先が Dispose に集まっている
  • 長寿命の通知元へ購読する場合、解除の到達が保証できる形になっている
  • 相手の差し替えがある場合、「今の通知元」を保持して外している
  • add/remove のログで購読件数が見える

連載Index:読む順・公開済リンクが最新: S00_門前の誓い_総合Index
delegate/ラムダの前提: K26【鍛錬】C# ラムダ式入門

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?