DEV Community

Cover image for Escape analysis in Go Part-1
HM
HM

Posted on

Escape analysis in Go Part-1

Although there are a few great articles on escape-analysis for go, this post is my attempt to demystify and present the topic in simplicity

Escape analysis in Go Part-1

In this post we will see how Go basic types and structs are assigned to heap or stack by the compiler

Note

I will be running a few scenarios and exposing the compiler escape analysis info using the following command:

go build -gcflags -m -l
Enter fullscreen mode Exit fullscreen mode

Each scenario is formatted as follows:

// ** Scenario <no> ** //

// change: <brief text to describe the scenario>

// program that I ran
...

// escape analysis output
...

// explanation
...

Enter fullscreen mode Exit fullscreen mode


// ** Scenario 1** //

// not using any pointers
// program that I ran
package del
type S struct {
 x int
}
func f1() {
   x:= S{1}
   _ = f2(x)
}
func f2(x S) S {
 y := x
   return y
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
<blank>

// explanation
Since there are no pointers/references all variables are local to the function stack. No heap allocation required
Enter fullscreen mode Exit fullscreen mode

// ** Scenario 2 ** //

// change: returning a pointer to a struct created in the called function

// program that I ran
package del
type S struct {
   x int
}
func f1() {
   x:= S{1}
   _ = f2(x)
}
func f2(x S) *S {
   y := x
   return &y
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
# del
.\del.go:13:2: moved to heap: y

// explanation
In scenario 2, “y” was moved to heap by the compiler because f2 is returning the reference to y. And upon return stack of f2 will be marked invalid. Thus for f1 to use the reference to y, y must be available even after f2 has finished.
Enter fullscreen mode Exit fullscreen mode

// ** Scenario 3 ** //

// change: returning a struct created in the called function using a pointer to a struct in the calling function

// program that I ran
package del
type S struct {
   x int
}
func f1() {
   s:= S{1}
   _ = f2(&s)
}
func f2(x *S) S {   
   y := *x
   return y
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
# del
.\del.go:12:9: f2 x does not escape

// explanation
Here, x does not escape and stays on f1's stack. This is because while f2 is running f1's stack will still be available

Enter fullscreen mode Exit fullscreen mode

// ** Scenario 4** //

// change: returning a pointer to the struct created in the called function using a pointer to a struct in the calling function

// program that I ran
package del
type S struct {
   x int
}
func f1() {
   s:= S{1}
   _ = *f2(&s)
   _ = *f3(&s)
}
func f2(x *S) *S {   
   y := x
   return y
}
func f3(x *S) *S {   
   y := *x // line 18
   return &y
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
# del
.\del.go:13:9: leaking param: x to result ~r1 level=0
.\del.go:17:9: f3 x does not escape
.\del.go:18:4: moved to heap: y

// explanation
We have 2 functions f2 and f3, that achieve the same thing i.e. accept a *S and return a *S. In f2, y and x are both references to the stack address in f1. While in f3, y contains the value of x and lives on f3's stack. Now since f1 dereferences what f3 returns, y needs to be available on the heap for f1 to deference.

Enter fullscreen mode Exit fullscreen mode

// ** Scenario 5 ** //

// change: constructing the struct in the called functions

// program that I ran
package del
type S struct {
   x int
}
func f1() {
   i := 123
   _ = *f2(i)  
   _ = f3(&i)
   _ = *f4(&i)
}
func f2(x int) *S {   
   y := S{x} // line 16
   return &y
}
func f3(x *int) S {   
   y := S{*x}
   return y
}
func f4(x *int) *S {   
   y := S{*x} // line 24
   return &y
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
# del
.\del.go:16:4: moved to heap: y
.\del.go:19:9: f3 x does not escape
.\del.go:23:9: f4 x does not escape
.\del.go:24:4: moved to heap: y

// explanation
Shouldn't be a surprise for why y in f2 and f4 is moved to heap

Enter fullscreen mode Exit fullscreen mode

// ** Scenario 6 ** //

// change: S.x is not *int type

// program that I ran
package del
type S struct {
   x *int
}
func f1() {
   i := 123
   _ = *f2(i)  
   _ = f3(&i)
   _ = *f4(&i)
}
func f2(x int) *S {   
   y := S{&x} // line 16
   return &y
}
func f3(x *int) S {   
   y := S{x} // line 20 
   return y
}
func f4(x *int) *S {   
   y := S{x} // line 24
   return &y
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
# del
.\del.go:15:9: moved to heap: x
.\del.go:16:4: moved to heap: y
.\del.go:19:9: leaking param: x to result ~r1 level=0
.\del.go:23:9: leaking param: x
.\del.go:24:4: moved to heap: y
.\del.go:10:4: moved to heap: i

// explanation
For f2, it should be clear why y is being moved to heap. x is also moved to heap for y to continue referencing it even after f2 returns
For f3, y is being returned to f1 where x already exists. So no need to place anything on heap
For f4, it should be cleat why y is being moved to heap. i (=x since same reference) is also moved to heap for y to continue referencing it even after f1 returns


Enter fullscreen mode Exit fullscreen mode

// ** Scenario 7 ** //

// change: function assigns value to a member of the struct, and returns nothing

// program that I ran
package del
type S struct {
   x *int
}
func f1() {
   i := 123
   var s S
   f2(i, s)
   f3(&i, s)
   f4(i, &s)
   f5(&i, &s)
}
func f2(x int, y S) {   
   y.x = &x // line 18
}
func f3(x *int, y S) {   
   y.x = x // line 21
}
func f4(x int, y *S) {   
   y.x = &x // line 22 x
}
func f5(x *int, y *S) {   
   y.x = x // line 26 x
}
Enter fullscreen mode Exit fullscreen mode
// escape analysis output
# del
.\del.go:17:16: f2 y does not escape
.\del.go:20:9: f3 x does not escape
.\del.go:20:17: f3 y does not escape
.\del.go:23:9: moved to heap: x
.\del.go:23:16: f4 y does not escape
.\del.go:26:9: leaking param: x
.\del.go:26:17: f5 y does not escape
.\del.go:10:4: moved to heap: i

// explanation
For f2, nothing is moved to heap since everything lives on the stack of f2 and isn't required once f2 returns
For f3, nothing is moved to heap since everything lives on the stack of f3 and isn't required once f3 returns
For f4, y is coming from another stack and for x to be available after f4 returns, x must be moved to the heap
For f5, same explanation as f4, x must be moved to heap except that x here refers to i since both are pointers to same address. Hence, it must be moved to heap

Enter fullscreen mode Exit fullscreen mode

Next: In the next post we will see how slices and maps behave when it comes to escape analysis

Some homework for readers:

Q. What are maps, really?

A. Check this out: https://golang.org/src/runtime/map.go#L114

Q. What are slices?

A. Check this out: https://golang.org/pkg/reflect/#SliceHeader


See you next time,

Harleen.

# Series name: **"Go escape analysis and performance"**
## The series of posts consists of the following parts:
- Part 0.1: Stacks and heaps simplified
- Part 0.2: What is escape analysis
- Part 0.3: go gcflags, memprof, cpuprof and pprof
- **Part 1: Escape analysis in Go Part-1** (you are on this page)
- Part 2: [Escape analysis in Go Part-2](https://dev.to/mannharleen/escape-analysis-in-go-part-2-mn5)
- Part 3: Enhancing go program's performance
- Part 4: Based on readers' requests :)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)