Golang’s garbage collection mechanism allows automatic memory management to make our code cleaner and less likely to leak memory. However, the GC periodically stops and collects unused objects, so it still adds overhead to the program. The Go compiler is smart enough to decide, for example, whether a variable needs to be allocated on the heap or the stack, and unlike an allocation on the heap, a variable on the stack is reclaimed at the end of the function that declared it. Then, for GC purposes, variables allocated on the stack do not incur additional overhead, and the entire call stack of the function is destroyed after the function returns.

How does Golang decide whether a variable should be allocated on the heap or the stack? This brings us to Golang’s Escape Analysis.

The basic rule for determining escape is that if the return value of a function is a reference to a variable declared within the function, then the variable is said to have escaped from the function. As the return value of this function, it can also be modified by other programs outside the function, so it must be allocated on the heap and not on the stack of that function.

Therefore, if we can analyze the escape of variables during the compilation process, we can improve the performance of our program. First of all, the biggest benefit is to reduce the pressure of garbage collection, no escaped variables are allocated on the stack, and the function can directly recover resources when it returns; secondly, after the escape analysis, we can determine which variables can actually be allocated on the stack, which is faster than the heap and has better performance; there is also the possibility of synchronization elimination, if the function that defines the variables has a synchronization lock, but only one thread accesses it at runtime, the At this point the machine code after escaping the analysis will run with the synchronous lock removed.

Turn on Go compile-time escape analysis log

At compile time, add the -gcflags '-m' parameter to see a detailed escape analysis log of the go compilation process. However, to prevent Go from automatically inlining functions at compile time, the -l parameter is added, ending up with -gcflags '-m -l'.

Example 0:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

type S struct {}

func main() {
  var x S
  _ = identity(x)
}

func identity(x S) S {
  return x
}

Output:

1
2
3
$ go run -gcflags '-m -l' escape.go
$
$

You can see that there is no output. We know that Go uses pass-by-value when calling functions, so the x declared in the main function is copied to the stack of the identity() function. Usually, code without references always uses stack allocation, so there is no output from the escape analysis log.

So if you change the code a bit.

Example 1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

type S struct {}

func main() {
  var x S
  y := &x
  _ = *identity(y)
}

func identity(z *S) *S {
  return z
}

Output:

1
2
3
4
$ go run -gcflags '-m -l' escape.go
# command-line-arguments
./escape.go:11:22: leaking param: z to result ~r1 level=0
./escape.go:7:8: main &x does not escape

The first line is that the z variable is meant to flow through a function, only as input to the function, and is returned directly, and no reference to z is used in identity(), so the variable is not escaped.

In the second line, x is declared in the main() function, so it is on the stack of the main() function, and there is no escape.

Example 2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

type S struct {}

func main() {
  var x S
  _ = *ref(x)
}

func ref(z S) *S {
  return &z
}

Output:

1
2
3
4
$ go run -gcflags '-m -l' escape.go
# command-line-arguments
./escape.go:11:10: &z escapes to heap
./escape.go:10:16: moved to heap: z

You can see that an escape has occurred. The argument z to ref() is passed by value, so z is a copy of the value x in the main() function, and ref() returns a reference to z, so z cannot be placed on the stack of ref(), but is actually assigned to the heap.

In fact, we find that the main() function does not use the reference returned by ref() directly, in which case z can actually be assigned to the stack of ref(), but Go’s escape analysis is not sophisticated enough to identify this case; it only looks at the flow of input and returned variables. It’s worth noting that if we don’t add the -l argument, ref() will actually be used by the compiler inline with main().

What if the reference is assigned to a member of a structure?

Example 3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(i)
}

func refStruct(y int) (z S) {
  z.M = &y
  return z
}

Output:

1
2
3
4
$ go run -gcflags '-m -l' escape.go
# command-line-arguments
./escape.go:13:9: &y escapes to heap
./escape.go:12:26: moved to heap: y

It can be found that Go’s escape analysis can track references even if the reference is a member of a structure. When the structure refStruct is returned, y must have escaped from refStruct().

Compare this to the following example.

Example 4:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(&i)
}

func refStruct(y *int) (z S) {
  z.M = y
  return z
}

Output:

1
2
3
4
$ go run -gcflags '-m -l' escape.go
# command-line-arguments
./escape.go:12:27: leaking param: y to result z level=0
./escape.go:9:13: main &i does not escape

The reason this y doesn’t escape is that main() calls refStruct() with a reference to i and returns directly, never exceeding the call stack of the main() function, for the same reason as Example 1.

Another note: Example 4 is more efficient than Example 3.

In Example 3, i has to request a stack space on the stack of main(), and after refStruct(), y has to request another space on the heap; in Example 4, only i actually requests a space once, and then its reference goes through refStruct().

A more complex example.

Example 5:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

type S struct {
  M *int
}

func main() {
  var x S
  var i int
  ref(&i, &x)
}

func ref(y *int, z *S) {
  z.M = y
}

Output:

1
2
3
4
5
6
7
$ go run -gcflags '-m -l' escape.go
# command-line-arguments
./escape.go:13:21: leaking param: y
./escape.go:13:21: ref z does not escape
./escape.go:10:7: &i escapes to heap
./escape.go:9:7: moved to heap: i
./escape.go:10:11: main &x does not escape

It is understandable that y and z are not escaped, but the problem is that y is also assigned to a member of the input z of the function ref(), and Go’s escape analysis cannot track the relationship between variables and does not know that i becomes a member of x, and the analysis result says that i is escaped, but essentially i is not escaped. This is a problem.

Here are some more examples of perversions that have been assigned to the heap because of Go’s lack of escape analysis: https://docs.google.com/document/d/1CxgUBPlx9iJzkz9JWkb6tIpTe5q32QDmz8l0BouG0Cw/preview

In fact, all this is to show that if you want to reduce garbage collection time and improve program performance, you should avoid allocating space on the heap as much as possible, so you can think about this aspect more when writing your program. The following is an example of how to reduce garbage collection time and improve performance.)