連載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. WeakReference で GC残り を短く確認する
重くなるパターン
「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 の並びにしてある。番号は本文の章番号と同じ。
- 参照の線
- 1.
購読件数をログで出す - 2.
ラムダ直書きで-=が空振りになる - 3.
+=が複数回通って二重発火になる - 4. 解除の置き先を
Disposeに集める - 5.
長寿命の通知元へ購読してGCされない - 6. 通知元の差し替えで
外す相手がズレる - 7.
WeakReferenceでGC残りを短く確認する - まとめ:迷ったらこのチェックから
最後のチェック
-
-=が同じ参照を外している。ラムダを別の場所で作り直していない -
+=が複数回通らない形になっている。必要なら-=の後に+=で結果を一定にしている -
解除の置き先が
Disposeに集まっている - 長寿命の通知元へ購読する場合、解除の到達が保証できる形になっている
- 相手の差し替えがある場合、「今の通知元」を保持して外している
-
add/removeのログで購読件数が見える
連載Index:読む順・公開済リンクが最新: S00_門前の誓い_総合Index
delegate/ラムダの前提: K26【鍛錬】C# ラムダ式入門