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 ↗Pending work is an owner
An asynchronous method can return control while its operation remains unfinished. Locals needed after a suspension can become fields of a state machine, and continuations, callbacks or tasks can keep that state reachable. This is ordinary ownership, not evidence that async itself leaks. The lifetime question is whether the pending work has a bounded completion or cancellation path and whether retained state is proportionate to what the operation still needs.
Closures and callbacks create related paths. A delegate can refer to a target object or captured environment; that environment can refer to a request, service provider or large buffer. Passing a small identifier instead of a whole request may reduce retained state, but only if the callback can still fulfill its contract. Avoid replacing a clear owner with an unsafe borrowed reference merely to make a graph look smaller.
Static callbacks still own explicit state
The portable cancellation callback is static, so it cannot implicitly capture surrounding locals. Nevertheless the registration receives state explicitly and retains that state for the registration's lifetime. Static is a lexical capture restriction, not a promise of zero allocation, zero ownership or collection independence. A static delegate plus a large state object can retain just as much payload as a capturing delegate.
A CancellationTokenRegistration represents a lifecycle obligation. Disposing the registration unregisters the callback and may coordinate with callback execution. The exact concurrency contract matters when cancellation can run on another thread. Never hold an unrelated lock around disposal if the callback needs that same lock to finish. Requesting cancellation, waiting for the operation to complete and releasing the registration are separate events, and a robust design makes their order explicit.
Keep completion and cancellation separate
A cancellation token is cooperative signaling. Cancelling it does not automatically remove all queued work, stop arbitrary user code, clear task results or return every rented buffer. The operation must observe the token at meaningful boundaries and release its own resources. A task that has completed may still be reachable because a list retains it, and its result or exception can retain additional state. Conversely, removing a task from a list does not cancel the work it represents.
An async state machine's exact layout is a compiler and optimization detail. Do not claim that every local always survives every await or that a particular Release build necessarily allocates the same objects as Debug. Use the source to identify potential lifetimes, then inspect the representative compiled workload. Minimize state that crosses long waits when it is semantically unnecessary, and avoid storing completed task histories without a retention policy.
Trace the registration program
The program creates one RequestState and one token source. Register attaches a callback that increments Calls on the explicitly supplied state. Calling Cancel inside the using block invokes that callback once, so inside:1 is printed. Leaving the block disposes the registration. Calling Cancel again does not turn cancellation into a recurring event; the token source remains cancelled, and Calls remains one.
The final lines therefore print after:1 and cancelled:True. These values establish callback behavior and cancellation state in a single-threaded bounded example. They do not measure the size of a state machine or prove that a collector reclaimed the registration. The state local itself remains in use for the output. Explain the real ownership path before drawing a heap diagram from these counts.
Inspect the event-shaped retaining path
The event scenario in the lab starts with both a screen root and a static publisher root. Close the screen by disabling its root. The publisher still reaches the delegate, subscriber and payload. Disable the event subscription next. That removes the second path. The lesson is not that every event is a leak: short-lived publishers can die with their subscribers, while long-lived publishers require deliberate subscription ownership.
For actual async incidents, group retained payloads by operation state and waiting reason. A slow downstream service can extend legitimate lifetimes across thousands of concurrent requests. Correlate in-flight counts, cancellation response and retained types with the same time interval. A larger heap during a downstream outage may be overload retention rather than a permanently forgotten static collection.
Design a bounded async lifetime
Acquire admission before allocating large request state. Own a registration in the operation scope, finish or cancel child work under a documented policy, and return rented buffers only after all consumers have actually stopped. A fire-and-forget callback that still uses a buffer after its parent returns creates a correctness defect even if the parent puts the array back in a pool on time. Reachability and valid ownership are different guarantees.
For interview discussion, show both success and failure paths. Ask what happens when cancellation races completion, when a callback throws, and when a producer waits for capacity while already holding a payload. A strong answer names the current owner, the event ending its lease and the action that handles partial progress. Static callbacks can help control capture, but they cannot substitute for those lifecycle decisions.
Practice and review
Move the first Cancel call after the registration's using block. Predict that the callback no longer increments Calls. Keep the source alive and verify cancellation state independently. Then create two registrations and dispose only one before cancellation. Predict the exact count without claiming anything about collected memory.
Review an asynchronous method that reads a large body before awaiting admission. Draw the root path from its pending task to the body. Redesign it so the number of waiting callers and preallocated payloads is bounded, preserving cancellation and error behavior. Finally, explain why clearing a local in one method cannot release a payload still owned by a continuation or a long-lived publisher.
A working example
using System.Threading;
var state = new RequestState();
using var source = new CancellationTokenSource();
using (source.Token.Register(static value => ((RequestState)value!).Calls++, state))
{
source.Cancel();
Console.WriteLine($"inside:{state.Calls}");
}
source.Cancel();
Console.WriteLine($"after:{state.Calls}");
Console.WriteLine($"cancelled:{source.IsCancellationRequested}");
sealed class RequestState { public int Calls; }
Expected output
inside:1 after:1 cancelled:True
Trace ownership before inferring collection. Expected portable output: inside:1 after:1 cancelled:True