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
A closure lets a function use variables from its surrounding scope after that scope's ordinary call has returned. The compiler arranges storage that keeps those captured variables available. If a long-lived owner stores the delegate, the delegate can retain its captured state and any objects reachable through it. This is useful behavior, not automatically a leak, but it creates an ownership relationship that should have an end. Capturing a variable is also different from freezing its value at the moment the lambda was written.
Trace the actual program
CreateReader creates a local string variable named text, constructs a lambda reading it, then changes text to after before returning the delegate. Calling the returned function prints after, because it observes the captured variable's current value. Storing that delegate in callbacks gives the list a retaining path to the captured environment. The list count prints one, then Clear makes it zero. The separate local reader still refers to the delegate and is kept alive explicitly, so clearing this one list is not proof that its captured state became unreachable.
How the mechanism works
Think of the captured environment as part of the graph even though the source does not explicitly name a closure class. A lambda that captures an entire service instance can keep considerably more state reachable than one that receives a small immutable identifier explicitly. The exact compiler-generated allocation shape is an implementation detail, and runtimes may optimize it, but the required ability to read the captured variable remains. Inspect what a delegate actually depends on instead of assuming every lambda has the same storage cost or retention footprint.
Boundaries and misconceptions
Setting an unrelated local to null does not remove a delegate's captured reference, and removing a callback from one owner does not remove every owner of that callback. Conversely, a static lambda cannot capture surrounding variables, which can make accidental capture a compile-time error when capture is not intended. Do not rewrite every callback as static without preserving its required inputs. The practical goal is a clear lifetime boundary, not banning closures or treating every compiler-generated object as an inherent defect.
Practice: change one ownership path
First change the assignment inside CreateReader from after to revised. Predict the first output revised without changing the lambda. Hint: the lambda reads a variable that is shared with that enclosing invocation, not a snapshot of the initial string. Then create two readers by calling CreateReader twice and explain why their captured environments belong to separate invocations. Add an argument to the factory so the readers can return distinct labels and test them independently.
Practice: test a second boundary
Next make a callback accept the label as an explicit parameter rather than capturing it, and store only that callback under a static lambda. Hint: supply the variable data at invocation time instead of trying to access a forbidden surrounding local. Compare the ownership diagrams of the two designs. Finally remove both the list entry and any separate delegate owner in a helper and describe eligibility without predicting a collection timestamp. The absence of a callback entry is an application fact, not a heap-profiler result.
Review the evidence
A retention investigation should include callback registries, queued work, and asynchronous continuations among potential owners. Record which delegate is stored, what it captures, and which component must release it. For this program, the clear operation changes only one owner's entry count while the local reader intentionally remains. That precise explanation avoids a common debugging mistake: attributing surviving data to a broken garbage collector when a perfectly valid captured reference still makes the data reachable.
- The first output is
after, reflecting the updated captured variable. - Clear removes list entries but the separate reader still owns the delegate.
- Captured state has a lifetime determined by every reachable delegate owner.
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;
Func<string> CreateReader()
{
string text = "before";
Func<string> read = () => text;
text = "after";
return read;
}
var reader = CreateReader();
Console.WriteLine(reader());
var callbacks = new List<Func<string>> { reader };
Console.WriteLine(callbacks.Count);
callbacks.Clear();
Console.WriteLine(callbacks.Count);
GC.KeepAlive(reader);
Expected output
after 1 0
The first output is `after`, reflecting the updated captured variable.