Change owners, step through generations, pinning and finalization, or compare memory under load. The model explains concepts; the SDK examples measure real runtime observations.
Open the Memory Lab ↗The memory contract
An event publisher normally retains the delegates subscribed to its event. Those delegates can retain their target object or captured variables, which in turn can retain a document, view model, or other large graph. The direction matters: a long-lived publisher can keep a short-lived listener reachable. Garbage collection cannot infer that the listener is logically obsolete while this retaining path remains. A subscription therefore needs an owner responsible for removing it, or another deliberately designed weak-event relationship with its own semantics.
Trace the actual program
The sample subscribes a handler that captures a Document named rune. Publishing once records that name in the observed list. The listener count is one before unsubscription, then zero after removing the same handler. Publishing again produces no additional observation, so the final observed count remains one. These outputs verify the publisher's callback relationship. The local handler and document are still intentionally accessible in the program, so the sample does not claim that unsubscription alone proves the document was collected or that the process heap shrank.
How the mechanism works
Inside Publisher, GetInvocationList provides an application-level count of registered callbacks. It is not a count of all references to a listener in the runtime. The event delegate, the handler local, and any other registry can independently contribute paths. Named methods or stored delegate variables make later unsubscription explicit. Rewriting a similar lambda at removal time does not reliably identify the original subscribed delegate. A disposable subscription token can package the publisher and handler together so the owning scope can end the relationship consistently.
Boundaries and misconceptions
Unsubscribing does not cancel work the handler already started, roll back state it already changed, or revoke copies held elsewhere. Ordinary event invocation is synchronous unless the publisher explicitly dispatches work; exceptions in handlers can also affect that invocation path. Do not treat events as an automatic background queue. For an actual memory investigation, combine subscription-lifetime tests with target-specific retaining-path evidence rather than claiming every event is a leak or that every unsubscribed listener has immediately disappeared.
Practice: change one ownership path
First omit the unsubscription in a copy of the sample and predict a final observed count of two after the second publish. Hint: the publisher still retains the same handler, so both notifications are expected behavior. Restore removal and then add a second distinct handler. Predict a listener count of two before removing just the first one and one afterward. Trace each delegate identity separately rather than treating all listeners as a single boolean flag.
Practice: test a second boundary
Next wrap the subscription in an IDisposable object whose Dispose removes the stored handler exactly once. Test disposal twice and verify that the publisher ends with zero listeners. Hint: make the subscription token clear its own references after removal, so it does not unnecessarily retain the publisher and listener itself. Keep the test about observable registration and notification behavior. A later heap capture can investigate actual object retention, but a forced finalizer print is not a reliable replacement for that evidence.
Review the evidence
An event-retention fix should identify both the long-lived publisher and the lifecycle transition at which a listener stops needing updates. Document who owns the subscription token and test that transition directly. The unchanged output shows the correct name, listener counts one and zero, and one observation. It demonstrates control over a retaining edge while preserving the distinction between application ownership, garbage-collection eligibility, collection timing, and operating-system memory accounting. A lifecycle test should also cover ending a listener twice and publishing after its owner has ended, without accidentally creating a replacement subscription or keeping a forgotten registration alive.
- Store the actual handler identity when later removal is required.
- Unsubscription removes one publisher edge, not every possible reference.
- The original final observation count is one, not evidence of reclaimed bytes.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using System.Buffers;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
var publisher = new Publisher();
var document = new Document("rune");
var observed = new List<string>();
Action handler = () => observed.Add(document.Name);
publisher.Changed += handler;
publisher.Publish();
Console.WriteLine(observed[0]);
Console.WriteLine(publisher.ListenerCount);
publisher.Changed -= handler;
Console.WriteLine(publisher.ListenerCount);
publisher.Publish();
Console.WriteLine(observed.Count);
GC.KeepAlive(handler);
record Document(string Name);
sealed class Publisher
{
public event Action? Changed;
public int ListenerCount => Changed?.GetInvocationList().Length ?? 0;
public void Publish() => Changed?.Invoke();
}
Expected output
rune 1 0 1
Store the actual handler identity when later removal is required.