Closures in Go are powerful but come with hidden memory implications. By capturing variables from their surrounding scope, they force the compiler to decide: Should these variables live on the stack or heap? Let’s dissect real-world examples to see escape analysis in action.


The Code

package main  

func counter() func() int {  
    count := 0              // Escapes to heap (captured by closure)  
    return func() int {  
        count++  
        return count  
    }  
}  

func main() {  
    // Closure captures heap-allocated variable  
    c := counter()  
    println(c()) // 1  
    println(c()) // 2  

    // Loop closure pitfall  
    var funcs []func()  
    for i := 0; i < 3; i++ {  
        funcs = append(funcs, func() {  
            println(i)      // i escapes to heap (shared by all closures)  
        })  
    }  
    for _, f := range funcs {  
        f() // Output: 3, 3, 3  
    }  
}

Memory Segmentation Visualized

+-------------------+   Lowest Address  
|      Code         |  
|-------------------|  
| counter()         |  // Function logic  
| closure logic     |  // Generated closure code  
+-------------------+  
|      Data         |  
|-------------------|  
| (No constants)    |  
+-------------------+  
|      Stack        |  
|-------------------|  
| main() frame      |  
|   c (func) →      |  
|   funcs (slice) → |  
|   i (int)         |  
+-------------------+  
|      Heap         |  
|-------------------|  
| count (int = 0)   |  // Captured by counter()'s closure  
| i (int = 3)       |  // Shared by all loop closures  
| closure contexts  |  // Function + captured variables  
+-------------------+   Highest Address

Breakdown of Key Behaviors

1. counter() Closure

  • Heap escape: count is captured by the returned closure. Since the closure outlives counter(), count moves to the heap.
  • Lifetime extension: The heap allows count to persist across closure calls.

2. Loop Closure Pitfall

  • Shared variable: The loop variable i is captured by all closures.
  • Heap allocation: i escapes to the heap because the closures in funcs outlive the loop iteration.
  • Unexpected output: All closures share the same i (value 3 after loop ends).

Why This Happens

  • Escape analysis rule: If a variable is referenced by a closure that outlives its declaring function, it must be heap-allocated.
  • Compiler proof:
go build -gcflags="-m" main.go  
  # ./main.go:4:2: moved to heap: count  
  # ./main.go:14:3: ... i escapes to heap

Fixing the Loop Pitfall

Force stack allocation by creating a loop-local copy of i:

for i := 0; i < 3; i++ {  
    i := i  // Stack-allocated copy per iteration  
    funcs = append(funcs, func() {  
        println(i)  // Output: 0, 1, 2  
    })  
}
  • Result: Each closure captures its own i copy, avoiding heap sharing.

Performance Implications

  • Heap overhead: Each escaped variable adds GC pressure.
  • Closure context size: A closure’s memory footprint includes its captured variables.

Optimization Tips

  1. Avoid unnecessary closures: Use explicit parameters for short-lived functions.
  2. Prevent unintended captures:
func ReadFile(name string) error {  
       f, err := os.Open(name)  
       defer f.Close()  
       // Do work  
   }

Here, f is captured by defer but stays on the stack because defer runs within the function scope.