Closures · How it works

8 min read
Mid-level9 min read
Rapid overview

How it works

The idea

Q: What is a closure?

A: A function together with the environment it was created in: the outer variables it references. The function "closes over" those variables, so it can read and write them later, even after the method that declared them has returned.

static Func<int> MakeCounter()
{
    int count = 0;                // local of MakeCounter
    return () => ++count;         // the lambda captures 'count'
}

var next = MakeCounter();         // MakeCounter has returned...
Console.WriteLine(next());        // 1   ...but 'count' is still alive
Console.WriteLine(next());        // 2
var other = MakeCounter();
Console.WriteLine(other());       // 1   each call gets its own 'count'

A normal local would have died with MakeCounter's stack frame. Because the lambda captured it, it survives as long as the delegate does.

Q: Which C# constructs can form closures?

A: Lambdas (x => x + offset), anonymous methods (delegate (int x) { return x + offset; }) and local functions (int Add(int x) => x + offset;). They form a closure only when they reference a local variable, a parameter or this from the enclosing method. A lambda that uses only its own parameters and static members captures nothing and is not a closure.

Q: Does a closure capture the value or the variable?

A: The variable. The lambda sees the variable's current value at the moment it runs, and writes through to the same variable.

int x = 1;
Action print = () => Console.WriteLine(x);
x = 2;
print();          // 2, not 1

Action bump = () => x++;
bump();
Console.WriteLine(x);   // 3 - the lambda changed the outer variable

Memory level: what the compiler generates

Q: How does the compiler implement a closure?

A: It rewrites the method. Every captured local is removed from the stack frame and turned into a field of a compiler-generated class (named something like <>c__DisplayClass0_0). The method allocates one instance of that class, and every read and write of the variable, both in the method and in the lambda, becomes a field access on that object. The lambda body becomes an instance method on the same class, and the delegate stores a reference to the object (its Target) plus a pointer to that method.

// What you write
static Func<int> MakeCounter()
{
    int count = 0;
    return () => ++count;
}

// Roughly what the compiler emits
sealed class DisplayClass            // <>c__DisplayClass0_0
{
    public int count;                // the captured local is now a heap field
    public int Lambda() => ++count;  // the lambda body
}

static Func<int> MakeCounter()
{
    var env = new DisplayClass();    // heap allocation #1
    env.count = 0;
    return new Func<int>(env.Lambda); // heap allocation #2 (the delegate)
}
 stack (MakeCounter's frame, gone after return)
┌──────────────┐
│ env → ───────┼──┐
└──────────────┘  │     managed heap
                  │    ┌──────────────────────────┐
 next (delegate) ─┼──► │ Func<int> delegate       │
                  │    │  Target → ───────────────┼──┐
                  │    │  Method → Lambda()       │  │
                  │    └──────────────────────────┘  │
                  │    ┌──────────────────────────┐  │
                  └──► │ DisplayClass             │◄─┘
                       │  count = 2               │
                       └──────────────────────────┘

That is why capturing works "by variable": there is only one count, a field on one heap object, shared by the method and every lambda that captured it.

Q: What does a closure cost?

A: Typically two heap allocations: the display-class object that holds the captured variables, and the delegate that points at it. Both are garbage the GC must later collect. On a hot path (a request pipeline, a tight loop, a LINQ query inside a loop) this adds up. A lambda that captures nothing costs nothing per call: the compiler caches its delegate in a static field of a singleton class (<>c) and reuses it.

Q: What if a lambda only uses this (instance fields)?

A: No display class is needed: the lambda is compiled as an instance method on your own class and the delegate's Target is this. You still pay for a new delegate each time the lambda expression is evaluated, and the delegate keeps this alive.

Q: When is the display class allocated?

A: At the start of the scope where the captured variable is declared, not where the lambda is created. So a method that captures a variable only on a rare branch still allocates on every call:

int Process(int id)
{
    var key = id.ToString();                 // 'key' is captured below,
    if (id < 0)                              // so the display class is allocated
        return _cache.GetOrAdd(key, _ => Load(key)); // at method entry,
    return id;                               // even when id >= 0
}

Moving the capturing code into its own method, or passing state explicitly (see below), removes the allocation from the common path.

Q: Are captured variables still on the stack?

A: No. Once captured, a local lives on the heap for the whole method, including the parts that never touch the lambda. This is also why you cannot capture ref locals, in/out/ref parameters, or ref structs such as Span<T>: they must stay on the stack, and a heap field cannot hold them.

Q: Do several lambdas in one method share the captured variables?

A: Yes. Lambdas that capture variables from the same scope share one display class instance. That keeps them consistent (they see each other's writes), but it also means one long-lived lambda keeps all variables in that shared object alive, including a large buffer captured only by a different, short-lived lambda.


Lifetime and leaks

Q: How can a closure cause a memory leak?

A: A closure keeps everything it captures reachable for as long as the delegate is reachable. The classic case is an event handler lambda on a long-lived publisher:

public sealed class PriceWidget
{
    private readonly byte[] _bigCache = new byte[10_000_000];

    public PriceWidget(PriceFeed feed)     // feed is a long-lived singleton
    {
        feed.PriceChanged += p => Render(p); // captures 'this'
    }
}

The feed holds the delegate, the delegate holds the PriceWidget through this, and the widget holds 10 MB. The widget can never be collected until it unsubscribes. You cannot unsubscribe an inline lambda by writing the same lambda again (it is a different delegate instance), so keep a reference to the handler, or use a named method, and remove it in Dispose.

Q: How do closures interact with caches and background work?

A: Anything stored for later (a cached Func<>, a timer callback, a queued Task.Run lambda, an IMemoryCache factory) keeps its captured variables alive until it is released. Capturing an HttpContext, a DbContext or a scoped service into a lambda that outlives the request both leaks it and uses it after it has been disposed.


The loop-variable trap

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
    actions.Add(() => Console.Write(i));
foreach (var a in actions) a();
Q: What does this print, and why?

A: 333. A for loop declares one variable i for the whole loop, so all three lambdas capture the same variable, and by the time they run the loop has left it at 3. The fix is a fresh variable per iteration:

for (int i = 0; i < 3; i++)
{
    int copy = i;                       // new variable each iteration
    actions.Add(() => Console.Write(copy));
}                                        // prints 012
Q: Does the same happen with foreach?

A: Not since C# 5. The foreach iteration variable is now logically a new variable on every iteration, so foreach (var x in xs) actions.Add(() => Console.Write(x)); captures each value separately. The change was a deliberate breaking fix; for loops were left as they were because their variable is explicitly updated by i++. JavaScript made the same distinction with let (per iteration) versus var (one per function).


Avoiding the cost

Q: What is a static lambda?

A: A lambda marked static (C# 9) may not capture locals, parameters or this; attempting it is a compile error. It documents and enforces "no closure, no allocation", which matters in hot paths and library code.

var names = users.Select(static u => u.Name);        // fine
var tagged = users.Select(static u => prefix + u.Name); // error CS8820: captures 'prefix'
Q: How do APIs let you avoid closures?

A: By passing the state as an explicit argument, so the lambda can be static and cached. Many BCL APIs have such overloads:

// Closure: allocates a display class + delegate per call
cache.GetOrAdd(key, k => Create(k, options));

// State-passing: no capture, delegate cached once
cache.GetOrAdd(key, static (k, opts) => Create(k, opts), options);

Other examples: string.Create(length, state, static (span, s) => …), ThreadPool.QueueUserWorkItem(static s => …, state, preferLocal: false), CancellationToken.Register(static s => …, state), and ILogger message templates instead of interpolated strings.

Q: Do local functions allocate when they capture?

A: Not necessarily. If a local function is only called (never converted to a delegate), the compiler puts the captured variables in a struct display class and passes it by ref, so there is no heap allocation. As soon as you pass the local function as a delegate (list.ForEach(Print)), it needs a heap closure like a lambda. Local functions can also be static (C# 8) to forbid capture.


Closures and async / iterators

Q: Is an async method a closure?

A: It uses the same trick for a different reason. The compiler turns an async method (and an iterator with yield) into a state machine, and any local that is alive across an await or yield is hoisted into a field of that state machine so it survives the suspension. When the method completes synchronously the state machine stays on the stack as a struct; when it actually suspends, it is boxed onto the heap. That is why a Span<T> local cannot live across an await, and why the memory notes on tuples say a tuple "used across an await" ends up on the heap.


Where you meet closures every day

  • LINQ: orders.Where(o => o.Total > minTotal) captures minTotal. With EF Core the lambda becomes an expression tree and the captured variable becomes a SQL parameter, not a constant, which is why changing minTotal does not generate a new query plan.
  • ASP.NET Core minimal APIs and middleware: app.MapGet("/", () => config.Greeting) captures config.
  • Callbacks and events: timers, Task.Run, ContinueWith, event handlers.
  • Factories and memoization: Lazy<T>(() => Build(settings)), IMemoryCache.GetOrCreate.
  • Functional helpers: returning a configured function, like MakeCounter or a Func<decimal, decimal> ApplyDiscount(decimal rate).
Q: Give real-world places where closures appear.

A:


Common interview gotchas

Q: What prints? int n = 5; Func<int> f = () => n * 2; n = 10; Console.WriteLine(f());

A: 20. The lambda captured the variable n, and reads its value when it runs.

Q: Why does a method allocate even when the branch with the lambda is not taken?

A: The display class is created at the start of the scope that declares the captured variable, so the allocation happens whether or not the lambda is ever created.

Q: Are two identical lambdas equal as delegates?

A: No. Each evaluation of a capturing lambda creates a new delegate instance, so feed.PriceChanged -= p => Render(p); removes nothing. Keep the delegate in a field to unsubscribe.

Q: Can a closure capture a struct and mutate it?

A: Yes. The captured struct variable becomes a field of the display class, so the lambda and the method both mutate that one field. It is no longer a separate copy on each side.

Q: How do C# closures compare with JavaScript closures?

A: Same concept: both capture variables, not values, and both keep them alive. JavaScript engines keep a scope object alive; C# makes it explicit as a compiler-generated class. JavaScript's var in a loop shows the same "all callbacks see the last value" bug as a C# for loop, and let fixes it the way C#'s per-iteration foreach variable does.

See also