Escape analysis is one of the features of the Go language. The compiler automatically analyzes variables/memory should be allocated on the stack or on the heap. Programmers do not need to actively care about these things, which ensures memory security while also reducing the burden on programmers.
However, this "burden reduction" feature has now become a mental burden for programmers. Especially after the popularization of various eight-legged essays, the frequent occurrence of escape analysis in interviews is getting higher and higher, and it will not often mean that job opportunities are missed. Some people even think that not understanding escape analysis is about not knowing it.
I don't like these phenomena very much, not because I don't know how to go, but because I know what escape analysis is: the analysis rules have version differences, the rules are too conservative, many times the variables that can be escaped on the stack to the heap, the rules are complicated, and many corner cases, etc. Not to mention that some of the poorly-quality eight-legged essays are misleading in the description of escape analysis.
So I suggest that most people return to the original intention of escape analysis - for programmers, escape analysis should be like transparent, and don't care too much about it.
How do you know if the variable is escaping?
I have also seen some situations that are more excessive than memorizing outdated eight-legged essays: a group of people gathered around a bare piece of code and would the variables escape and fight for them to be red in their faces.
They didn't even use the verification method provided by the go compiler to prove their own point of view.
Such a debate is meaningless, you should use the following command to check the results of the compiler escape analysis:
$ go build -gcflags=-m=2 # command-line-arguments ./:5:6: cannot inline main: function too complex: cost 104 exceeds budget 80 ./:12:20: inlining call to ./:12:21: num escapes to heap: ./:12:21: flow: {storage for ... argument} = &{storage for num}: ./:12:21: from num (spill) at ./:12:21 ./:12:21: from ... argument (slice-literal-element) at ./:12:20 ./:12:21: flow: = &{storage for ... argument}: ./:12:21: from ... argument (spill) at ./:12:20 ./:12:21: from := ... argument (assign-pair) at ./:12:20 ./:12:21: flow: {heap} = *: ./:12:21: from (, ...) (call parameter) at ./:12:20 ./:7:19: make([]int, 10) does not escape ./:12:20: ... argument does not escape ./:12:21: num escapes to heap
What escaped and what was not clearly displayed—escapes to heap
Indicates that a variable or expression has escaped.does not escape
This means no escape occurred.
In addition, this article discusses the official GC compiler of Go. Some third-party compilers such as tinygo have no obligation or reason to use the exact same escape rules as the official one - these rules are not part of the standard and do not apply to certain special scenarios.
The go version of this article is 1.23, and I don’t want someone to use a 1.1x or 1.3x version compiler to ask me why the experiment results are different.
Problems in the Eight-Legend Essay
First, we declare that the spirit of willingness to share information is still worthy of respect.
However, before sharing, at least some simple verification is done first, otherwise the content that is wrong and nonsense will only increase your laughter.
Things that don't know the size of the product will escape during the compilation period
This is actually true, but many eight-legged essays either end here, or give an example that often does not escape and then give a hilarious explanation.
for example:
package main import "fmt" type S struct {} func (*S) String() string { return "hello" } type Stringer interface { String() string } func getString(s Stringer) string { if s == nil { return "<nil>" } return () } func main() { s := &S{} str := getString(s) (str) }
Some eight-legged essays will saygetString
The parameter s of the parameter s is difficult to know what the actual type is during the compilation period, so the size is difficult to determine, so it will cause the parameters passed to it to escape.
Is this right? Right or not, because the compilation period is too wide. An interface does not know the actual type in the first half of the "compilation period", but it is possible to know it in the second half. So the key is when the escape analysis is performed, which directly determines the escape analysis results of the variable of type interface.
Let's verify:
# command-line-arguments ... ./:22:18: inlining call to getString ... ./:22:18: devirtualizing to *S ... ./:23:21: str escapes to heap: ./:23:21: flow: {storage for ... argument} = &{storage for str}: ./:23:21: from str (spill) at ./:23:21 ./:23:21: from ... argument (slice-literal-element) at ./:23:20 ./:23:21: flow: = &{storage for ... argument}: ./:23:21: from ... argument (spill) at ./:23:20 ./:23:21: from := ... argument (assign-pair) at ./:23:20 ./:23:21: flow: {heap} = *: ./:23:21: from (, ...) (call parameter) at ./:23:20 ./:21:14: &S{} does not escape ./:23:20: ... argument does not escape ./:23:21: str escapes to heap
I only intercepted the key information, otherwise the noise would be too loud.&S{} does not escape
This sentence tells us directlygetString
The parameters of the sequel did not escape.
Why? becausegetString
It was inlined. After inline, the compiler found that the actual type of the parameter is S, sodevirtualizing to *S
After de-virtualization, the compiler of the interface's actual type knows, so there is no need to let the parameters escape.
And str escaped, the type of str is known and the content is also a constant string. According to the theory of eight-legged essays, shouldn't it be avoided? In fact, the above information also tells you why, becauseSome internal functions cannot be inlined, and they use any to accept parameters. At this time, the compiler cannot do virtualization and cannot finalize the real size of the variable, so str can only escape. Remember what I said at the beginning, escape analysis is very conservative, because memory security and program correctness are the first priority.
If the function inline is prohibited, the situation is different. We can manually prohibit a function from being inlined in Go:
+//go:noinline func getString(s Stringer) string { if s == nil { return "<nil>" } return () }
This time, look at the results:
# command-line-arguments ./:14:6: cannot inline getString: marked go:noinline ... ./:22:14: &S{} escapes to heap: ./:22:14: flow: s = &{storage for &S{}}: ./:22:14: from &S{} (spill) at ./:22:14 ./:22:14: from s := &S{} (assign) at ./:22:11 ./:22:14: flow: {heap} = s: ./:22:14: from s (interface-converted) at ./:23:19 ./:22:14: from getString(s) (call parameter) at ./:23:18 ./:22:14: &S{} escapes to heap ./:24:20: ... argument does not escape ./:24:21: str escapes to heap
getString
It cannot be inlined, so it cannot be devirtualized. In the end, it cannot know the size of the variable before escaping analysis, so s as parameters finally escaped.
Therefore, the expression "compilation period" is not correct, the correct one should be "Can't know the exact size of variables/memory allocation will escape when executing escape analysis”. Another thing to note: the rewriting of inline and some built-in functions/statements occurs before escape analysis. Everyone should know what inline is. If you have time to introduce the rewrite another day, you will be able to do it.
Moreover, go is also quite casual about what can be calculated before escape analysis:
func main() { arr := [4]int{} slice := make([]int, 4) s1 := make([]int, len(arr)) // not escape s2 := make([]int, len(slice)) // escape }
s1 does not escape but s2 escapes because len will directly return a compile-time constant when calculating the length of the array. When len calculates the length of the slice, it cannot be calculated during the compilation period, so even if we know very well that the length of the slice is 4 at this time, go still thinks that the size of s2 cannot be determined before escape analysis.
This is also why I warn everyone not to care too much about escape analysis. Many times it is abnormal.
Will you not escape if you know the size during the compilation period?
Some eight-legged essays draw the following conclusion based on the phenomenon in the previous section:make([]T, constant)
Will not escape.
I think a qualified go or c/c++/rust programmer should immediately refute almost instinctively: if you do not escape, you will be allocated on the stack, and the stack space is usually limited (the system stack is usually 8-10M, and the goroutine is a fixed 1G). What if the memory space required by this make exceeds the upper limit of the stack?
Obviously, if you exceed the upper limit, you will escape to the pile, so the above sentence is not very correct. Of course, go stipulates the upper limit of allocating memory on the stack space at one time, which is much smaller than the upper limit of the stack size, but I won't tell you how much it is, because no one guarantees that it won't change in the future, and I said, it's useless for you to care about this.
There is also a classic case where the content generated by make is used as the return value:
func f1() []int { return make([]int, 64) }
Escape analysis will give the following results:
# command-line-arguments ... ./:6:13: make([]int, 64) escapes to heap: ./:6:13: flow: ~r0 = &{storage for make([]int, 64)}: ./:6:13: from make([]int, 64) (spill) at ./:6:13 ./:6:13: from return make([]int, 64) (return) at ./:6:2 ./:6:13: make([]int, 64) escapes to heap
This is nothing unexpected, because the return value must be used after the function call is finished, so it can only be allocated on the heap. This is also the original intention of escape analysis.
However, because this function is too simple, it can always be inlined. Once inlined, the make is no longer a return value, so the compiler has a chance to prevent it from escaping. You can teach it in the previous section//go:noinline
Try it.
The number of elements in slices has little to do with whether they escape or not
Some eight-legged essays will say this: "There are too many elements in the slice that will lead to escape." Some eight-legged essays will swear to say what the quantity limit is 10,000 or 100,000.
OK, let's take an example:
package main import "fmt" func main() { a := make([]int64, 10001) b := make([]byte, 10001) (len(a), len(b)) }
Analysis results:
... ./:6:11: make([]int64, 10001) escapes to heap: ./:6:11: flow: {heap} = &{storage for make([]int64, 10001)}: ./:6:11: from make([]int64, 10001) (too large for stack) at ./:6:11 ... ./:6:11: make([]int64, 10001) escapes to heap ./:7:11: make([]byte, 10001) does not escape ...
Why are the number of elements the same, one escapes and the other does not? It doesn't matter if it is the number of elements. It only has a limit on the memory allocation size on the stack mentioned in the previous section. If it exceeds it, it will escape. If it does not exceed 100 million elements you allocate.
The key is that this kind of boring problem is not low, and my friend and I have encountered this:
make([]int, 10001)
I just asked you whether this thing escapes or not. The interviewer probably forgot that the length of the int is not fixed. It is 4 bytes on the 32-bit system and 8 bytes on the 64-bit system, so this question cannot be answered before without more information. Even if you catch Rob Pike, he can only shake his head. If you encounter an interview, you can still argue with the interviewer. What should you do if you encounter an interview in the written exam?
This is what I said about the inverse result. The slice and array will escape not because of the large number of elements, but because the memory consumed (element size x number) exceeds the specified upper limit.
There is almost no difference between new and make during escape analysis
Some eight-legged essays also say that the object of new often escapes but makes it cannot, so try to use new as little as possible.
This is the old eight-part article, and no one will read it now, but even at that time, this sentence was wrong. I think it is probably because the eight-legged essay authors grafted the knowledge in Java/c++ without verification.
I have to clarify that new and make are indeed very different, but only in two places:
-
new(T)
Return *T, andmake(T, ...)
Return to T -
new(T)
T can be of any type (but slice, interface, etc. are generally not recommended), andmake(T, ...)
The T can only be slice, map or chan.
Just these two, and there is a little difference in the specific way of initialization for things like slices, but this is barely included in the second point.
Therefore, new will never be more likely to cause escape. Like make, whether new will escape will be affected only by size limitations and accessibility.
See an example:
package main import "fmt" func f(i int) int { ret := new(int) *ret = 1 for j := 1; j <= i; j++ { *ret *= j } return *ret } func main() { num := f(5) (num) }
result:
./:5:6: can inline f with cost 20 as: func(int) int { ret := new(int); *ret = 1; for loop; return *ret } ... ./:15:10: inlining call to f ./:16:13: inlining call to ./:6:12: new(int) does not escape ... ./:15:10: new(int) does not escape ./:16:13: ... argument does not escape ./:16:14: num escapes to heap
Seenew(int) does not escape
Is it right? The rumors are self-defeating.
However, in order to prevent someone from being serious, I have to introduce some implementation details: although there is not much difference in escape analysis between new and make, the current version of go has stricter limits on make size. If you look at it this way, that eight-legged essay is still wrong, because the probability of escape caused by make is slightly greater than new. So if you need to use new, you don’t need to care about these things.
Compilation optimization is too weak to drag away escape analysis
In the past two years, there have been two submissions that have caused me to completely lose interest in escape analysis. The first is:7015ed
The change is to add an alias to a local variable, so that the compiler will not let the local variable escape incorrectly.
Why does the compiler let this variable escape? It is related to the algorithm that the compiler implements accessibility analysis, and also to the reduction in analysis accuracy caused by the compiler's failure to optimize.
If you encounter this problem, can you come up with this kind of repair method? Anyway, I can't, because this submission is done by a big shot who develops and maintains the compiler and then locates the problem and proposes an optional solution. For ordinary people, I'm afraid they can't figure out what the problem is.
Another one I encountered during the 1.24 development cycle. This submission is to add new featuresA little modification was made, the previous code was like this:
func (t Time) MarshalText() ([]byte, error) { b := make([]byte, 0, len(RFC3339Nano)) b, err := t.appendStrictRFC3339(b) if err != nil { return nil, (": " + ()) } return b, nil }
The new one looks like this:
func (t Time) appendTo(b []byte, errPrefix string) ([]byte, error) { b, err := t.appendStrictRFC3339(b) if err != nil { return nil, (errPrefix + ()) } return b, nil } func (t Time) MarshalText() ([]byte, error) { return (make([]byte, 0, len(RFC3339Nano)), ": ") }
In fact, the developer needs to reuse the logic inside, so he took out a separate subfunction and made a core content without changing.
However, the new code that seems to be no essential difference showsMarshalText
performance improvement by 40%.
What's going on, because nowMarshalText
It has become simpler, so it can be inlined in many places, andappendTo
The memory is not allocated by itself, which leads to the buf that was originally used as the return value.MarshalText
It can be inlined, and the compiler finds that it does not need to be a return value where it is called externally and its size is known. Therefore, it applies to the situation we mentioned in Section 2, and buf does not need to escape. Not escaping means no need to allocate heap memory, and performance will naturally improve.
Of course, this depends on Go's inline optimization, which creates optimization opportunities that are almost impossible in C++ (appendTo is a packaging, and there is an additional parameter. There is almost no difference between it and the original code after normal inline expansion). This is somewhat abnormal in other languages, so at first I thought there was something wrong with the description in the submission, so I spent a lot of time to check and test it, and then I realized that inline might affect escape analysis and wasted all afternoon on it.
There are too many such problems, and there are many in issue. If you don’t understand what specific work the compiler has done and what algorithms it uses, it is difficult to troubleshoot and solve these problems.
Do you remember what you said at the beginning that escape analysis is to reduce the burden on programmers. Now, in turn, it requires programmers to have a deep understanding of the compiler, which is a bit of putting the cart before the horse.
These two submissions eventually led me to rethink the question of how deep the developers need to understand about escape analysis.
What to do
In fact, there are many folk legends about escape analysis, and I am too lazy to confirm/false them one by one. The following will only talk about what to do as a developer when escape analysis is chaotic and complicated.
For most developers: Like the title, don't pay too much attention to escape analysis. Escape analysis should be the wings to improve your efficiency rather than a shackle when writing code.
After all, just looking at the code, it is difficult for you to analyze the reason. During the compilation period, you may escape. Those who don’t seem to know the size may not escape. The performance of similar codes is very different. Accessibility analysis and some compilation optimizations are also interspersed in the middle. There are so many corner cases that are beyond imagination. When writing code, you will definitely not be efficient when thinking about these things.
Whenever you want to escape analysis, you can use the following steps to help you get rid of your dependence on escape analysis:
- Is the life cycle of a variable longer than the function that created it?
- If so, can we choose to return "value" instead of return pointer? The overhead of the function being inlined or the value being replicated is almost negligible;
- If not or you find that the design can be modified so that the life cycle of the variable is not that long, then go to
- Are functions a performance hotspot?
- If that's not the case, otherwise you need to use memprofile and cpuprofile to determine how much loss the escape caused
- Of course, the less escape is, the better, but if the loss caused by escape is not very large, then it is not worth continuing.
- Reusing heap memory is often simpler and more intuitive than avoiding escape. Try it
Something like that instead of trying to avoid escaping
- At this point, you have to
-gcflags=-m=2
See why the escape occurred. Some reasons are obvious and can be optimized. - For those who you can't understand why you escape, either leave it alone or use means other than Go (such as assembly).
- It is also OK to ask others for help, but the premise is that they do not memorize the eight-legged essays mechanically.
In short, you have almost no need to study escape analysis by following some common regulations such as allocating memory in advance when you know the size of the slice, designing short and concise functions, using less pointers, etc.
For developers of compilers, standard libraries, and some programs with high performance requirements, understanding escape analysis is necessary. Because the performance of Go is not very ideal, we must seize all the optimization opportunities that can be used to improve performance. For example, when I was plugged in to the standard library, I was asked to have some functions that had to be "zero allocation". Of course, I didn't study escape when I came up, but wrote the test and studied the profile first, and then used the results of the escape analysis to make further optimization.
Summarize
In fact, there are some things that are not mentioned in this article, such as the performance of arrays and closures escape analysis. Overall, their behavior is not much different from other variables, and look at the title of the article - so I don't recommend over-focusing on their escape analysis.
So, you should not be overly concerned with escape analysis. You should also stop memorizing/moving/writing eight-legged essays about escape analysis.
Most people care about escape analysis, except for interviews, it is for performance. What I often say is that performance analysis must be combined with profile and benchmark. Otherwise, if you make a guess out of thin air, you will cut your feet and walk in order not to escape, which will not only waste time and will not help performance problems at all.
Having said that, not having a deep understanding of escape analysis and not knowing that there is escape analysis are two different things. The latter is indeed a waste of time.
The above is a detailed analysis of escape analysis in Go language. For more information about Go escape analysis, please pay attention to my other related articles!