Go is a modern language with garbage collection, it abandoned the traditional C/C++ developers need to manually manage the way of memory, to achieve active memory allocation and release management. Go’s garbage collection makes the concept of heap and stack transparent to programmers, and its addition of escape analysis and GC really frees programmer’s hands, giving developers more energy to focus on software design itself.

As mentioned in the article “How CPU Caching affects Go programs,” “You don’t have to be a hardware engineer, but you do need to understand how hardware works.” Although Go helps us to achieve automatic memory management, we still need to understand its internal mechanism. Memory management consists of two main actions: allocate and free. Escape analysis serves memory allocation. To better understand escape analysis, let’s talk about the stack.

Stack and heap

The memory carrier of an application, which we can simply divide into heap and stack.

In Go, stack memory is automatically allocated and released by the compiler. The stack area usually stores function parameters, local variables, and calling function frames, which are allocated with the creation of the function and destroyed when the function exits. A Goroutine corresponds to a stack, which is short for call stack. A stack usually contains many stack frames, which describe the call relationship between functions. Each frame corresponds to a function call that has not been returned, and it itself also stores data in the form of stacks.

For example, in A Goroutine, function A() is calling function B(), so the memory layout of the call stack is shown as follows.

Unlike a stack, only one heap exists at runtime. Narrowly speaking, memory management is only for heap memory. At run time, the program can actively request memory from the heap, which is allocated through Go’s memory allocator and reclaimed by the garbage collector.

The stack is unique to each Goroutine, which means that memory operations on the stack do not need to be locked. Memory on the heap, on the other hand, sometimes needs to be locked to prevent multithreading collisions (why sometimes, because Go’s memory allocation strategy learns from TCMalloc’s thread-caching idea, which assigns a McAche to each processor P, and allocates memory from the McAche without locking).

Also, for memory reclamation on the program heap, there is a marker cleanup phase, such as the tricolor notation used by Go. However, in terms of memory on the stack, it is very cheap to allocate and free. Simply put, it requires only two CPU instructions: one to allocate to the stack and the other to release from the stack. This can be done only with the help of stack dependent registers.

In addition, stack memory makes better use of the CPU’s caching strategy. Because they’re more continuous than the heap.

Escape analysis

So, how do we know whether an object should be placed in heap or stack memory? You can find it in the FAQ on the official website (address: golang.org/doc/faq)…

If possible, the Go compiler allocates variables to the stack as much as possible. However, when the compiler cannot prove that the function returned and the variable was not referenced, it must be allocated on the heap to avoid dangling the pointer. In addition, if the local variable is very large, it will also be allocated on the heap.

So how is Go determined? The answer: Escape analysis. The compiler uses escape analysis to select a heap or stack. The basic idea of escape analysis is to check that the lifetime of a variable is completely knowable, and if it passes, it can be allocated on the stack. Otherwise, it is called escape and must be allocated on the heap.

Although the Go language does not specify escape analysis rules, it has the following criteria, which can be referred to.

  • Escape analysis is done at the compiler, which is different from runtime escape analysis for the JVM;
  • If the variable is not referenced outside the function, it is placed on the stack first.
  • If a variable has a reference outside the function, it must be in the heap;

We can view escape analysis results by running the go build-gcflags ‘-m -l’ command, where -m prints escape analysis information and -l disables inline optimization. Let’s familiarize ourselves with some common escape scenarios through a few examples.

Case one: The variable type is uncertain

package main

import "fmt"

func main(a) {
	a := Awesome!
	fmt.Println(a)
}
Copy the code

The results of escape analysis are as follows

 $ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:7:13:... argument does not escape ./main.go:7:13: a escapes to heap
Copy the code

As you can see, the analysis tells us that variable A escaped onto the heap. However, we don’t have an external reference, so why would there be an escape? To see more detail, you can add an additional -m parameter to the statement. I get the following information

 $ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:7:13: a escapes to heap:
./main.go:7:13:   flow: {storage for. argument} = &{storagefor a}:
./main.go:7:13:     from a (spill) at ./main.go:7:13
./main.go:7:13:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:13:   flow: {heap} = {storage for. argument}: ./main.go:7:13:     from ... argument (spill) at ./main.go:7:13
./main.go:7:13:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13:... argument does not escape ./main.go:7:13: a escapes to heap
Copy the code

A escapes because it is passed into an argument to FMT.Println, and the method argument itself escapes.

func Println(a ... interface{}) (n int, err error)Copy the code

Since fmt.Println’s function argument is of type interface, the exact type of its argument cannot be determined at compile time, so it is allocated to the heap.

Case two: Exposure to external Pointers

package main

func foo(a) *int {
	a := Awesome!
	return &a
}

func main(a) {
	_ = foo()
}
Copy the code

The escape analysis is as follows: the escape of variable A occurs.

 $ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:4:2: a escapes to heap:
./main.go:4:2:   flow: ~r0 = &a:
./main.go:4:2:     from &a (address-of) at ./main.go:5:9
./main.go:4:2:     from return &a (return) at ./main.go:5:2
./main.go:4:2: moved to heap: a
Copy the code

This case directly satisfies our principle above: the variable has a reference outside the function. This makes sense, because when the function completes, the corresponding stack frame is destroyed, but the reference has been returned out of the function. If the external value is taken from the reference address, the address is still there, but the memory has been freed and reclaimed, that’s illegal memory, and that’s a big problem. So, obviously, this situation must be assigned to the heap.

Case 3: Variables occupy a large amount of memory

func foo(a) {
	s := make([]int.10000.10000)
	for i := 0; i < len(s); i++ {
		s[i] = i
	}
}

func main(a) {
	foo()
}
Copy the code

Escape analysis results

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:4:11: make([]int.10000.10000) escapes to heap:
./main.go:4:11:   flow: {heap} = &{storage for make([]int.10000.10000)}:
./main.go:4:11:     from make([]int.10000.10000) (too large for stack) at ./main.go:4:11
./main.go:4:11: make([]int.10000.10000) escapes to heap
Copy the code

As you can see, when we create an underlying array object of type int of 10000, it will also be allocated to the heap because the object is too large. Here we can’t help but wonder why large objects need to be allocated to the heap.

To note here, did not say is: you in Go, execute user code goroutine user mode is a kind of thread, the call stack memory is known as the user stack, it is also from the pile of area distribution, but we can still see it as the same memory space and system stack, its allocation and release was done by the compiler. The counterpart is the system stack, which is allocated and released by the operating system. In the GMP model, one M corresponds to a system stack (also known as the G0 stack of M), which is shared by multiple Goroutines on M.

The maximum stack limit varies from platform to platform.

$ ulimit -s
8192
Copy the code

The X86_64 architecture, for example, has a maximum system stack size of 8Mb. The initial size of the goroutine is 2KB. The minimum size and maximum size of the goroutine can be found in runtime/stack.go, which are 2KB and 1GB, respectively.

// The minimum size of stack used by Go code
_StackMin = 2048.var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real
Copy the code

The heap is much larger. Since 1.11, Go has adopted a sparse memory layout, and when running on Linux’s x86-64 architecture, the entire heap can manage up to 256TB of memory. Therefore, in order to avoid stack overflow and frequent scaling, it is more reasonable to allocate large objects on the heap. So, how many objects are allocated to the heap.

S :=make([]int, n, n); make([]int, n, n); Println(unsafe.sizeof (s))) is 24 bytes, which is a mistake because the underlying array still needs to be allocated.

Case 4: Variable size is uncertain

We’ll take the three examples and change them briefly.

package main

func foo(a) {
	n := 1
	s := make([]int, n)
	for i := 0; i < len(s); i++ {
		s[i] = i
	}
}

func main(a) {
	foo()
}
Copy the code

The results of escape analysis are as follows

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:5:11: make([]int, n) escapes to heap:
./main.go:5:11:   flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:11:     from make([]int, n) (non-constant size) at ./main.go:5:11
./main.go:5:11: make([]int, n) escapes to heap
Copy the code

This time, instead of specifying the size directly in the make method, we fill in the variable n, which will also be allocated to the heap by Go escape analysis. As you can see, in order to keep memory perfectly safe, the Go compiler may allocate some variables to the heap inappropriately, but since these objects will eventually be processed by the garbage collector, that’s ok.

conclusion

This article only gives some examples of escape analysis. There are many more practical situations, but understanding the idea is the most important. There are no more to list here.

Since Go’s stack allocation is transparent to the developer, the compiler has already chosen how objects are allocated through escape analysis. So what else can we benefit from this?

The answer is yes, understanding escape analysis can certainly help us write better programs. Knowing the difference between variable allocation on the stack, we need to write the code allocated on the stack as much as possible. With fewer variables on the heap, we can reduce the overhead of memory allocation, reduce the pressure of GC, and improve the running speed of the program.

As a result, you’ll find some Go live projects that don’t pass a structure pointer when a function passes a parameter, but pass a structure directly. This approach, although it requires value copying, is done on the stack and is far less expensive than dynamically allocating memory on the heap after variable escape. Of course this is not absolute; passing Pointers is more appropriate if the structure is large.

Therefore, pointer passing is a double-edged sword from a GC point of view and needs to be used with caution, otherwise line tuning to resolve GC latency can crash you.