How do closures work behind the scenes? (C#)

Viewed 4140

I feel I have a pretty decent understanding of closures, how to use them, and when they can be useful. But what I don't understand is how they actually work behind the scenes in memory. Some example code:

public Action Counter()
{
    int count = 0;
    Action counter = () =>
    {
        count++;
    };

    return counter;
}

Normally, if {count} was not captured by the closure, its lifecycle would be scoped to the Counter() method, and after it completes it would go away with the rest of the stack allocation for Counter(). What happens though when it is closured? Does the whole stack allocation for this call of Counter() stick around? Does it copy {count} to the heap? Does it never actually get allocated on the stack, but recognized by the compiler as being closured and therefore always lives on the heap?

For this particular question, I'm primarily interested in how this works in C#, but would not be opposed to comparisons against other languages that support closures.

4 Answers

Your third guess is correct. The compiler will generate code like this:

private class Locals
{
  public int count;
  public void Anonymous()
  {
    this.count++;
  }
}

public Action Counter()
{
  Locals locals = new Locals();
  locals.count = 0;
  Action counter = new Action(locals.Anonymous);
  return counter;
}

Make sense?

Also, you asked for comparisons. VB and JScript both create closures in pretty much the same way.

The compiler (as opposed to the runtime) creates another class/type. The function with your closure and any variables you closed over/hoisted/captured are re-written throughout your code as members of that class. A closure in .Net is implemented as one instance of this hidden class.

That means your count variable is a member of a different class entirely, and the lifetime of that class works like any other clr object; it's not eligible for garbage collection until it's no longer rooted. That means as long as you have a callable reference to the method it's not going anywhere.

Eric Lippert's answer really hits the point. However it would be nice to build a picture of how stack frames and captures work in general. To do this it helps to look at a slightly more complex example.

Here is the capturing code:

public class Scorekeeper { 
   int swish = 7; 

   public Action Counter(int start)
   {
      int count = 0;
      Action counter = () => { count += start + swish; }
      return counter;
   }
}

And here is what I think the equivalent would be (if we are lucky Eric Lippert will comment on whether this is actually correct or not):

private class Locals
{
  public Locals( Scorekeeper sk, int st)
  { 
      this.scorekeeper = sk;
      this.start = st;
  } 

  private Scorekeeper scorekeeper;
  private int start;

  public int count;

  public void Anonymous()
  {
    this.count += start + scorekeeper.swish;
  }
}

public class Scorekeeper {
    int swish = 7;

    public Action Counter(int start)
    {
      Locals locals = new Locals(this, start);
      locals.count = 0;
      Action counter = new Action(locals.Anonymous);
      return counter;
    }
}

The point is that the local class substitutes for the entire stack frame and is initialized accordingly each time the Counter method is invoked. Typically the stack frame includes a reference to 'this', plus method arguments, plus local variables. (The stack frame is also in effect extended when a control block is entered.)

Consequently we do not have just one object corresponding to the captured context, instead we actually have one object per captured stack frame.

Based on this, we can use the following mental model: stack frames are kept on the heap (instead of on the stack), while the stack itself just contains pointers to the stack frames that are on the heap. Lambda methods contain a pointer to the stack frame. This is done using managed memory, so the frame sticks around on the heap until it is no longer needed.

Obviously the compiler can implement this by only using the heap when the heap object is required to support a lambda closure.

What I like about this model is it provides an integrated picture for 'yield return'. We can think of an iterator method (using yield return) as if it's stack frame were created on the heap and the referencing pointer stored in a local variable in the caller, for use during the iteration.

Related