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 outlivescounter()
,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 infuncs
outlive the loop iteration. -
Unexpected output: All closures share the same
i
(value3
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
- Avoid unnecessary closures: Use explicit parameters for short-lived functions.
- 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.