Go escape

Most Gophers don’t really have to care about Go variable escape analysis, or can even ignore it. But if you’re using Go in a performance-sensitive domain, where you want to completely squeeze the performance out of your Go application, then understanding Go escape analysis can be very beneficial. In this article, we’ll take a look at understanding Go escape analysis together.

1. The problem to be solved by escape analysis

C/C++ programmers have a “clear-cut” understanding of heap and stack memory. After the concept of virtual memory address for processes evolved in the operating system, the virtual memory address space of an application is divided into a heap memory area (heap in this figure) and a stack memory area (stack in this figure), as shown in the figure below.

stack/heap memory

Under the x86 platform linux operating system, as shown above, the stack memory area is generally placed at the high address and the stack extends downward; while the heap memory goes at the low address and the heap extends upward, the advantage of doing so is to facilitate that the heap and stack can dynamically share that memory area.

Does this mean that all memory object addresses allocated in the heap memory area must be smaller than the memory object addresses allocated in the stack memory area? In C/C++ it does, but in Go this is not necessarily the case because the memory page used by the go heap memory is intertwined with the memory page used by the goroutine’s stack.

Both stack memory and heap memory are legally available memory address spaces for applications. They are distinguished because of the need for memory allocation and management for the application.

Storage space for objects on the stack is automatically allocated and destroyed without much involvement from the developer or the programming language runtime, as in the following C code (the difference between stack memory and heap memory is better illustrated in C code).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cstack.c

#include <stdio.h>

void bar() {
    int e = 31;
    int f = 32;
    printf("e = %d\n", e);
    printf("f = %d\n", f);
}

void foo() {
    int c = 21;
    int d = 22;
    printf("c = %d\n", c);
    printf("d = %d\n", d);
}

int main() {
    int a = 11;
    int b = 12;
    printf("a = %d\n", a);
    printf("b = %d\n", b);
    foo();
    bar();
}

The C compiler automatically allocates space on the stack memory for these variables, so we don’t have to think about when it is created and when it is destroyed. We only need to use it in a specific scope (inside the function it is in) without worrying about its memory address not being legal. This is why these variables that are allocated on the stack are also called “automatic variables”. However, if the address is returned to the outside of the function, then code outside the function will get an error when accessing these variables by dereferencing them, as in the following example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cstack_coredump.c

#include <stdio.h>

int *foo() {
    int c = 11;
    return &c;
}

int main() {
    int *p = foo();
    printf("the return value of foo = %d\n", *p);
}

As the code shows, in the above example, we return the address of the automatic variable c in the foo function to the caller of the foo function (main) via the function return value, so that when we reference the address in the main function to output the value of the variable, we will get an exception, for example, if we run the above program on ubuntu, we will get the following result (on macos, gcc will give the same warning, but the program will run without dump core).

1
2
3
4
5
6
7
# gcc cstack_dumpcore.c
cstack_dumpcore.c: In function 'foo':
cstack_dumpcore.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
     return &c;
            ^~
# ./a.out
Segmentation fault (core dumped)

This leaves us with a memory object that can be legally used globally (across functions), which is the heap memory object. But unlike memory objects located on the stack that are created and destroyed by the program itself, heap memory objects need to be allocated and freed manually through a dedicated API, and the corresponding allocation and free methods in C are malloc and free.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cheap.c

#include <stdio.h>
#include <stdlib.h>

int *foo() {
    int *c = malloc(sizeof(int));
    *c = 12;
    return c;
}

int main() {
    int *p = foo();
    printf("the return value of foo = %d\n", *p);
    free(p);
}

In this example we use malloc to allocate a heap memory object in the foo function and return the object to the main function, which then calls the free function to manually free the heap memory block after using the object.

Obviously, compared to automatic variables, the lifecycle management of heap memory objects will impose a significant mental burden on the developer. To reduce this mental burden, programming languages with GC (garbage collection) have emerged, such as Java, Go, and so on. These programming languages with GC automatically manage the objects located on the heap. When an object is unreachable (i.e. when no object of it refers to it), it will be recycled and reused.

But although the advent of GC reduces the mental burden of memory management on developers, GC is not free and the performance loss it brings to programs is not negligible, especially when there are a large number of heap memory objects to be scanned on the heap memory, which will put too much pressure on the GC, thus making it take up more computational and storage resources that should be used to handle business logic. So people started to think of ways to minimize memory allocation on the heap, and variables that can be allocated on the stack stay on the stack as much as possible.

escape analysis is a method for statically analyzing which variables in the code need to be allocated on the stack and which need to be allocated on the heap during the compilation phase of a program based on the data flow in the program code. An ideal escape analysis algorithm would naturally be one that keeps as many variables on the stack as possible that one thinks need to be allocated on the stack, and “escapes” as few as possible to the heap. But this is too ideal, and each language has its own particular situation, and the accuracy of escape algorithms for each language is actually affected by this.

2. Escape Analysis in Go

Escape analysis has been with Go since the day it was born. As mentioned above about the goal of escape analysis, the Go compiler uses escape analysis to determine which variables should be allocated on the stack of a goroutine and which variables should be allocated on the heap.

As of today, there are two versions of Go’s Escape analysis implementation, with a watershed in Go 1.13. The first version of Go’s escape analysis implementation, prior to Go 1.13, was located in src/cmd/compile/internal/gc/esc.go in the Go source code (Go 1.12.7, for example), with a code size of 2400+ lines; Go version 1.13 includes a rewrite of Version 2 Escape Analysis by Matthew Dempsky, and is turned on by default, and can be revert to using the first version of escape analysis via -gcflags="-m -newescape=false". The new version of the code is located in src/cmd/compile/internal/gc/escape.go in the Go project source code. It reduces the escape analysis code from 2400+ lines in the previous version to 1600+ lines, with more complete documentation and comments. Note, however, that there is no qualitative change in the accuracy of the algorithm in this new version of the code.

But even so, after so many years of “tinkering”, those “Go Escape Analysis Flaws” proposed by Dmitry Vyukov in 2015 have mostly been fixed. The Go project has a detailed test code for escape analysis built in (in the test/escape*.go file under the Go project). file).

In the comments of the new version of the escape analysis implementation ($GOROOT/src/cmd/compile/internal/gc/escape.go), we can get a general idea of how escape analysis is implemented. The description of the principle in the notes mentions two invariants that the algorithm is based on.

  1. pointers to stack objects cannot be stored in the heap (pointers to stack objects cannot be stored in the heap).
  2. pointers to a stack object cannot outlive that object (i.e., pointers to a stack object cannot survive the destruction of the stack object).

The general principle and process of Go escape analysis is also given in the source code comments. The input of Go escape analysis is the abstract syntax tree (AST) of the entire program obtained by the Go compiler after parsing the Go source file.

The Node slice of the AST of the code obtained after source code parsing is xtop.

1
2
// $GOROOT/src/cmd/compile/internal/gc/go.go
var xtop []*Node

In the Main function, xtop is passed into the escape analysis entry function escapes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// $GOROOT/src/cmd/compile/internal/gc/main.go

// Main parses flags and Go source files specified in the command-line
// arguments, type-checks the parsed Go package, compiles functions to machine
// code, and finally writes the compiled package definition to disk.
func Main(archInit func(*Arch)) {
    ... ...
    // Phase 6: Escape analysis.
    // Required for moving heap allocations onto stack,
    // which in turn is required by the closure implementation,
    // which stores the addresses of stack variables into the closure.
    // If the closure does not escape, it needs to be on the stack
    // or else the stack copier will not update it.
    // Large values are also moved off stack in escape analysis;
    // because large values may contain pointers, it must happen early.
    timings.Start("fe", "escapes")
    escapes(xtop)
    ... ...
}

The following is an implementation of the escapes function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// $GOROOT/src/cmd/compile/internal/gc/esc.go
func escapes(all []*Node) {
    visitBottomUp(all, escapeFuncs)
}

// $GOROOT/src/cmd/compile/internal/gc/scc.go
// 强连接node - 一个数据结构
func visitBottomUp(list []*Node, analyze func(list []*Node, recursive bool)) {
    var v bottomUpVisitor
    v.analyze = analyze
    v.nodeID = make(map[*Node]uint32)
    for _, n := range list {
        if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() {
            v.visit(n)
        }
    }
}

// $GOROOT/src/cmd/compile/internal/gc/escape.go

// escapeFuncs performs escape analysis on a minimal batch of
// functions.
func escapeFuncs(fns []*Node, recursive bool) {
    for _, fn := range fns {
        if fn.Op != ODCLFUNC {
            Fatalf("unexpected node: %v", fn)
        }
    }

    var e Escape
    e.heapLoc.escapes = true

    // Construct data-flow graph from syntax trees.
    for _, fn := range fns {
        e.initFunc(fn)
    }
    for _, fn := range fns {
        e.walkFunc(fn)
    }
    e.curfn = nil

    e.walkAll()
    e.finish(fns)
}

According to the annotation, the general principle of escapes is.

  • First, a directed weighted graph is constructed, where the vertices (called “locations”, represented by gc.EscLocation) represent variables assigned by statements and expressions, and the edges (gc.EscEdge) represent assignments between variables (the weights represent the number of addresses addressed/fetched).
  • Next, traverse (visitBottomUp) this directed weighted graph and look for assignment paths in the graph that may violate the above two invariant conditions. The assignment paths that violate the above invariants. A variable v is marked as requiring an assignment on the heap if its address is stored on the heap or elsewhere that may exceed its lifetime.
  • To support inter-functional analysis, the algorithm also records the data flow from each function’s arguments to the heap and to its result. The algorithm refers to this information as the “parameter tag”. This tag information is used during static calls to improve the escape analysis of function parameters.

Of course, even after reading this, you may still be confused, it does not matter, this is not to explain the principle of escape analysis, if you want to understand the principle, then please read the more than 2400 lines of code carefully.

Note: One thing needs to be clear, that is, the static escape analysis also can not determine the object will be placed on the heap, the subsequent precise GC will deal with these objects, so as to ensure the maximum degree of code security.

3. Examples of Go escape analysis

Go toolchain provides a way to view the escape analysis process, we can use -m in -gcflags to make Go compiler output the escape analysis process, here are some typical examples.

1) Escape analysis of a simple native type variable

Let’s look at the escape analysis process for a native integer variable, here is the sample code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/int.go
package main
import "testing"
func foo() {
    a := 11
    p := new(int)
    *p = 12
    println("addr of a is", &a)
    println("addr that p point to is", p)
}

func bar() (*int, *int) {
    m := 21
    n := 22
    println("addr of m is", &m)
    println("addr of n is", &n)
    return &m, &n
}

func main() {
    println(int(testing.AllocsPerRun(1, foo)))
    println(int(testing.AllocsPerRun(1, func() {
        bar()
    })))
}

We perform escape analysis with -gcflags “-m -l”. The reason for passing -l is to turn off inline and shield inline from this process and from the final code generation.

1
2
3
4
5
6
7
// go 1.16版本 on MacOS
$go build -gcflags "-m -l" int.go
# command-line-arguments
./int.go:7:10: new(int) does not escape
./int.go:14:2: moved to heap: m
./int.go:15:2: moved to heap: n
./int.go:23:38: func literal does not escape

The result of the escape analysis is consistent with our manual analysis: m and n in the function bar escape to the heap (corresponding to the line with the words moved to heap: xx in the output above), and these two variables will be allocated storage on the heap. The a in the foo function and the pointer to the memory block pointed to are allocated on the stack (even if we created the int object by calling new, the new object in Go is not necessarily allocated on the heap, and the output log of the escape analysis specifically mentions that new(int) did not escape). Let’s execute the example (also passing -l to close the inline).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$go run -gcflags "-l" int.go
addr of a is 0xc000074860
addr that p point to is 0xc000074868
addr of a is 0xc000074860
addr that p point to is 0xc000074868
0
addr of m is 0xc0000160e0
addr of n is 0xc0000160e8
addr of m is 0xc0000160f0
addr of n is 0xc0000160f8
2

First, we see that the unescaped a and p blocks are in the address area 0xc000074860~0xc000074868, while the escaped m and n are allocated to the heap memory space, which from the output is in 0xc0000160e0~0xc0000160e8. We can clearly see that these are two different memory address spaces; in addition The output of AllocsPerRun from the testing package also confirms that the function bar performs two heap memory allocation actions.

Let’s take a look at the assembly code corresponding to this code.

1
2
3
4
5
$go tool compile -S int.go |grep new
    0x002c 00044 (int.go:14)    CALL    runtime.newobject(SB)
    0x004d 00077 (int.go:15)    CALL    runtime.newobject(SB)
    rel 45+4 t=8 runtime.newobject+0
    rel 78+4 t=8 runtime.newobject+0

We see that in lines 14 and 15 of the corresponding source code, assembly calls runtime.newobject to perform a memory allocation action on the heap, which is exactly where the escaped m and n declarations are located. We can also see from the implementation of the newobject code below that it actually performs a malloc action on the memory managed by gc.

1
2
3
4
5
6
7
8
// $GOROOT/src/runtime/malloc.go

// implementation of new builtin
// compiler (both frontend and SSA backend) knows the signature
// of this function
func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

2) Escape analysis of sliced variables themselves and sliced elements

Any gopher who knows how slicing works knows that a sliced variable is essentially a triplet.

1
2
3
4
5
6
7
//$GOROOT/src/runtime/slice.go

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

The first field of this triplet, array, points to a pointer to the real storage element at the bottom of the slice. Thus when allocating memory for a slice variable, it is important to consider both where the slice itself (i.e. the slice structure above) is allocated and where the slice elements are stored. Let’s look at the following example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/slice.go
package main
import (
   "reflect"
   "unsafe"
)
func noEscapeSliceWithDataInHeap() {
    var sl []int
    println("addr of local(noescape, data in heap) slice = ", &sl)
    printSliceHeader(&sl)
    sl = append(sl, 1)
    println("append 1")
    printSliceHeader(&sl)
    println("append 2")
    sl = append(sl, 2)
    printSliceHeader(&sl)
    println("append 3")
    sl = append(sl, 3)
    printSliceHeader(&sl)
    println("append 4")
    sl = append(sl, 4)
    printSliceHeader(&sl)
}
func noEscapeSliceWithDataInStack() {
    var sl = make([]int, 0, 8)     
    println("addr of local(noescape, data in stack) slice = ", &sl)
    printSliceHeader(&sl)
    sl = append(sl, 1)
    println("append 1")
    printSliceHeader(&sl)
    sl = append(sl, 2)
    println("append 2")
    printSliceHeader(&sl)
}
func escapeSlice() *[]int {
    var sl = make([]int, 0, 8)     
    println("addr of local(escape) slice = ", &sl)
    printSliceHeader(&sl)
    sl = append(sl, 1)
    println("append 1")
    printSliceHeader(&sl)
    sl = append(sl, 2)
    println("append 2")
    printSliceHeader(&sl)
    return &sl
}
func printSliceHeader(p *[]int) {
    ph := (*reflect.SliceHeader)(unsafe.Pointer(p))
    println("slice data =", unsafe.Pointer(ph.Data))
}

func main() {
    noEscapeSliceWithDataInHeap()
    noEscapeSliceWithDataInStack()
    escapeSlice()
}

Run escape analysis on the above example.

1
2
3
4
5
6
$go build -gcflags "-m -l" slice.go
# command-line-arguments
./slice.go:51:23: p does not escape
./slice.go:27:15: make([]int, 0, 8) does not escape
./slice.go:39:6: moved to heap: sl
./slice.go:39:15: make([]int, 0, 8) escapes to heap

We see from the output that.

  • the sl in the escapeSlice function located at line 39 escaped to the heap.
  • the elements of the slice sl in the escapeSlice function located at line 39 also escaped to the heap.
  • the elements of the slice sl located at line 27 did not escape.

Since it is difficult to see if the elements of each slice in the three functions escape, let’s see by running the example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$go run -gcflags " -l" slice.go
addr of local(noescape, data in heap) slice =  0xc00006af48
slice data = 0x0
append 1
slice data = 0xc0000160c0
append 2
slice data = 0xc0000160d0
append 3
slice data = 0xc0000140c0
append 4
slice data = 0xc0000140c0

addr of local(noescape, data in stack) slice =  0xc00006af48
slice data = 0xc00006af08
append 1
slice data = 0xc00006af08
append 2
slice data = 0xc00006af08

addr of local(escape) slice =  0xc00000c030
slice data = 0xc00001a100
append 1
slice data = 0xc00001a100
append 2
slice data = 0xc00001a100

Note: We use the SliceHeader of the reflect package to output the fields in the slice triplet that represent the address of the underlying array, in this case slice data.

We see that.

  • The first function noEscapeWithDataInHeap declares an empty slice and appends elements to the slice later using append. (b) From the output, it appears that the slice itself is allocated on the stack, but the runtime chooses to store its elements on the heap when dynamically extending the slice.
  • The second function noEscapeWithDataInStack directly initializes a slice with 8 elements in storage space. If there are more than 8 additional elements, the runtime allocates a larger space on the heap and copies the 8 elements from the original stack, and the subsequent elements of the slice are stored on the heap. This is why it is highly recommended to create a slice with a predicted cap parameter, not only to reduce the frequent allocation of heap memory, but also to improve performance when all elements are allocated on the stack under the cap capacity if the slice variable is not escaped.
  • The third function escapeSlice, on the other hand, slice variables themselves and the storage of their elements on the heap.

3) fmt.Printf series functions let variables escape to the heap (heap)?

Many people in the go project issue feedback fmt.Printf series of functions let variables escape to the heap, is this really the case? Let’s take a look at the following example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf1.go
package main
import "fmt"
func foo() {
   var a int = 66666666
   var b int = 77
   fmt.Printf("a = %d\n", a)
   println("addr of a in foo =", &a)
   println("addr of b in foo =", &b)
}
func main() {
    foo()
}

Note: The println and print predefined functions do not have the “side effects” that affect the fugitive nature of variables like the fmt. So here println is used to output the actual allocated memory address of the variable.

To run the escape analysis on the above code.

1
2
3
4
$go build -gcflags "-m -l" printf1.go
# command-line-arguments
./printf1.go:8:12: ... argument does not escape
./printf1.go:8:13: a escapes to heap

We see that the escape analysis outputs the variable “a escapes to heap” on line 8, but this “escapes” is a bit strange, because according to previous experience, if a variable really escapes, then the escape analysis will output in the line where it is declared output: “moved to heap: xx”. The above output is neither on the line where the variable is declared, nor does it say “moved to heap: a”. Let’s run the above example to see if the address of variable a is on the heap or the stack.

1
2
3
4
$go run -gcflags "-l" printf1.go
a = 66666666
addr of a in foo = 0xc000092f50
addr of b in foo = 0xc000092f48

We see that the address of variable a is on the same stack space as the address of the non-escaped variable b. Variable a has not escaped! If you decompile to assembly, you will certainly not see the runtime.newobject call either.

Then “. /printf1.go:8:13: a escapes to heap” line means what exactly? Obviously the escape analysis in this line is an analysis of the data flow into fmt.Printf, so let’s modify the go standard library source code and then build -a recompile printf1.go to see the distribution of variables inside fmt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// $GOROOT/src/fmt/print.go

func Printf(format string, a ...interface{}) (n int, err error) {
    // 添加下面四行代码
    for i := 0; i < len(a); i++ {
        println(a[i])
        println(&a[i])
    }
    return Fprintf(os.Stdout, format, a...)
}

Recompile printf1.go and run the compiled executable (to avoid).

1
2
3
4
5
6
7
$go build -a -gcflags "-l" printf1.go
$./printf1
(0x10af200,0xc0000160c8)
0xc00006cf58
a = 66666666
addr of a in foo = 0xc00006cf50
addr of b in foo = 0xc00006cf48

We see that the real parameter a of fmt.Printf is passed in and boxed into a form variable of type interface{}, which itself is allocated on the stack (0xc00006cf58), and that the type and value parts of the form variable of type interface{} output via println point to 0x10af200 and 0xc0000160c8, respectively. Obviously the value part is allocated on the heap memory. Then “. /printf1.go:8:13: a escapes to heap” means that the value part of the boxed variable is allocated on the heap? We are not sure here.

Let’s look at an example to compare.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf2.go
package main
import "fmt"

func foo() {
    var a int = 66666666
    var b int = 77
    fmt.Printf("addr of a in bar = %p\n", &a)
    println("addr of a in bar =", &a)
    println("addr of b in bar =", &b)
}
func main() {
    foo()
}

In the printf2.go example, unlike printf1.go, we use fmt.Printf in the foo function to output the address of the variable a: &a. Let’s run the new version of the escape analysis.

1
2
3
4
5
6
// go 1.16

$go build -gcflags "-m -l" printf2.go
# command-line-arguments
./printf2.go:6:6: moved to heap: a
./printf2.go:8:12: ... argument does not escape

We see that the variable a declared at line 6 actually does escape to the heap. Let’s run printf2.go.

1
2
3
4
5
6
7
$go build -a -gcflags "-l" printf2.go
$./printf2
(0x10ab4a0,0xc0000160c8)
0xc00006cf58
addr of a in bar = 0xc0000160c8
addr of a in bar = 0xc0000160c8
addr of b in bar = 0xc00006cf48

We see that the address of variable a is indeed very different from variable b on the stack, which should be on the heap, so it looks like the gopher who mentioned the issue in the go project was right. The address of variable a is passed into fmt.Printf as a real reference and then boxed into an interface{} formal reference variable, and from the results, fmt.Printf really requires the value of the boxed formal reference variable to be partially allocated on the heap, but according to the escape analysis invariant, the object on the heap cannot store an address on the stack, and this time the address of a is stored, so the a is determined to be an escape, and so a itself is allocated on the heap (0xc0000160c8).

Let’s run an older version of the escape analysis with go 1.12.7.

1
2
3
4
5
6
7
8
9
// go 1.12.7
$go build -gcflags "-m -l" printf2.go
# command-line-arguments
./printf2.go:8:40: &a escapes to heap
./printf2.go:8:40: &a escapes to heap
./printf2.go:6:6: moved to heap: a
./printf2.go:8:12: foo ... argument does not escape
./printf2.go:9:32: foo &a does not escape
./printf2.go:10:32: foo &b does not escape

The old version of escape analysis gives more detailed output, e.g., “&a escapes to heap”, which must refer to &a being boxed to heap memory; whereas println outputs &a without &a being boxed. But the final determination of the variable a thereafter is an escape.

Go core team member Keith Randall gave an explanation of the logs output from the escape analysis 469117368), which means that when the escape analysis output “b escapes to heap”, it means that the value stored in b escapes to the heap (which makes sense when b is a pointer variable), i.e. any object referenced by b must be allocated on the heap, but b itself does not; if b itself also escapes to the heap, then the escape analysis will output “&b escapes to heap”.

This problem is no longer fixed, and its core problem is in 8618 this issue.

4. Manually enforcing escape avoidance

For the example in printf2.go, we are sure for sure as well as certain: a does not need to escape. However, if we use fmt.Printf, we cannot block the escape of a. So is there a way to interfere with the escape analysis so that memory objects that the escape analysis thinks need to be allocated on the heap but that we are sure don’t think need to escape avoid escaping? In the Go runtime code, we found a function.

1
2
3
4
5
// $GOROOT/src/runtime/stubs.go
func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0) // 任何数值与0的异或都是原数
}

And it is heavily used in the Go standard library and runtime implementation. The logic of this function is implemented so that the pointer value we pass in is the same as the pointer value we return. The function simply does a conversion via uintptr that converts the pointer to a value, which “cuts off” the data flow trace for escape analysis and causes the incoming pointer to avoid escaping.

Let’s look at the following example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf3.go
package main

import (
    "fmt"
    "unsafe"
)

func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0)
}

func foo() {
    var a int = 66666666
    var b int = 77
    fmt.Printf("addr of a in bar = %p\n", (*int)(noescape(unsafe.Pointer(&a))))
    println("addr of a in bar =", &a)
    println("addr of b in bar =", &b)
}

func main() {
    foo()
}

Implement a uniform analysis for this code.

1
2
3
4
5
$go build -gcflags "-m -l" printf3.go

# command-line-arguments
./printf3.go:8:15: p does not escape
./printf3.go:16:12: ... argument does not escape

We see that a has not escaped this time. Run the compiled executable.

1
2
3
4
5
6
$./printf3
(0x10ab4c0,0xc00009af50)
0xc00009af58
addr of a in bar = 0xc00009af50
addr of a in bar = 0xc00009af50
addr of b in bar = 0xc00009af4

We see that a is not placed on the heap like printf2.go, this time it is allocated on the stack as well as b. And the stack address of a is always valid during the execution of fmt.Printf.

There was a paper on optimizing performance by escape analysis “Escape from Escape Analysis of Golang The paper uses the above idea of noescape function, which you can download and read if you are interested.

5. Summary

Through this article, we have learned about the problems to be solved by escape analysis, the current state and simple principles of Go escape analysis, some examples of Go escape analysis, and a description of the output logs of escape analysis. Finally, we give a solution to forcibly avoid escape analysis, but use it with caution.

In everyday go development, escape analysis need not be considered in most cases, except in performance-sensitive areas. In these areas, doing an escape analysis of the system execution hotspots and the corresponding optimization may bring some performance improvement to the program.

The source code involved in this article can be downloaded at here.