Skip to content(if available)orjump to list(if available)

Go's escape analysis and why my function return worked

tdfirth

I don’t think this is confusing to the vast majority of people writing Go.

In my experience, the average programmer isn’t even aware of the stack vs heap distinction these days. If you learned to write code in something like Python then coming at Go from “above” this will just work the way you expect.

If you come at Go from “below” then yeah it’s a bit weird.

onionisafruit

Go has been my primary language for a few years now, and I’ve had to do extra work to make sure I’m avoiding the heap maybe five times. Stack and heap aren’t on my mind most of the time when designing and writing Go, even though I have a pretty good understanding of how it works. The same applies to the garbage collector. It just doesn’t matter most of the time.

That said, when it matters it matters a lot. In those times I wish it was more visible in Go code, but I would want it to not get in the way the rest of the time. But I’m ok with the status quo of hunting down my notes on escape analysis every few months and taking a few minutes to get reacquainted.

Side note: I love how you used “from above” and “from below”. It makes me feel angelic as somebody who came from above; even if Java and Ruby hardly seemed like heaven.

compsciphd

I don't see how this is coming at go "from below".

even in C, the concept of returning a pointer to a stack allocated variable is explicitly considered undefined behavior (not illegal, explicitly undefined by the standard, and yes that means unsafe to use). It be one thing if the the standard disallowed it.

but that's only because the memory location pointed to by the pointer will be unknown (even perhaps immediately). the returning of the variable's value itself worked fine. In fact, one can return a stack allocated struct just fine.

TLDR: I don't see what the difference between returning a stack allocated struct in C and a stack allocated slice in Go is to a C programmer. (my guess is that the C programmer thinks that a stack allocated slice in Go is a pointer to a slice, when it isn't, it's a "struct" that wraps a pointer)

simiones

The confusion begins the moment you think Go variables get allocated on the stack, in the C sense. They don't, semantically. Stack allocation is an optimization that the Go compiler can sometimes do for you, with no semantics associated with it.

The following Go code also works perfectly well, where it would obviously be UB in C:

  func foo() *int {
    i := 7
    return &i
  }

  func main() {
    x := foo()
    fmt.Printf("The int was: %d", *x) //guaranteed to print 7
  }

foldr

This seems to be a persistent source of confusion. Escape analysis is just an optimization. You don't need to think about it to understand why your Go code behaves the way it does. Just imagine that everything is allocated on the heap and you won't have any surprises.

9rx

> This seems to be a persistent source of confusion.

Why? It is the same as in C.

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

    struct slice {
        int *data;
        size_t len;
        size_t cap;
    };

    struct slice readLogsFromPartition() {
        int *data = malloc(2);
        data[0] = 1;
        data[1] = 2;
        return (struct slice){ data, 2, 2 };
    }

    int main() {
        struct slice s = readLogsFromPartition();
        for (int i = 0; i < s.len; i++) {
            printf("%d\n", s.data[i]);
        }
        free(s.data);
    }

simiones

The point the GP was making was that the following Go snippet:

  func foo() {
    x := []int { 1 }
    //SNIP 
  }
Could translate to C either as:

  void foo() { 
    int* x = malloc(1 * sizeof(int));
    x[0] = 1;
    //...
  }
Or as

  void foo() { 
    int data[1] = {1};
    int *x = data;
    //...
  }
Depending on the content of //SNIP. However, some people think that the semantics can also match the semantics of the second version in C - when in fact the semantics of the Go code always match the first version, even when the actual implementation is the second version.

Yokohiii

I am currently learning go and your comment made me sort some things out, but probably in a counterintuitive way.

Assuming to everything allocates on the heap, will solve this specific confusion.

My understanding is that C will let you crash quite fast if the stack becomes too large, go will dynamically grow the stack as needed. So it's possible to think you're working on the heap, but you are actually threshing the runtime with expensive stack grow calls. Go certainly tries to be smart about it with various strategies, but a rapid stack grow rate will have it's cost.

foldr

Go won’t put large allocations on the stack even if escape analysis would permit it, so generally speaking this should only be a concern if you have very deep recursion (in which case you might have to worry about stack overflows anyway).

bonniesimon

Makes sense. I need to rewire how I think about Go. I should see it how I see JS.

jstanley

It's not confusing that this works in Go. (In my opinion).

A straightforward reading of the code suggests that it should do what it does.

The confusion here is a property of C, not of Go. It's a property of C that you need to care about the difference between the stack and the heap, it's not a general fact about programming. I don't think Go is doing anything confusing.

throwaway894345

I like Go a lot, but I often wish we could be more explicit about where allocations are. It’s often important for writing performant code, but instead of having semantics we have to check against the stack analyzer which has poor ergonomics and may break at any time.

But yeah, to your point, returning a slice in a GC language is not some exotic thing.

onionisafruit

I think I would like a “stackvar” declaration that works the same as “var” except my code won’t compile if escape analysis shows it would wind up on the heap. I say that knowing I’m not a language designer and have never written a compiler. This may be an obviously bad idea to somebody experienced in either of those.

I commented elsewhere on this post that I rarely have to think about stacks and heaps when writing Go, so maybe this isn’t my issue to care about either.

Scaevolus

This could probably be implemented as an expensive comment-driven lint during compilation.

Yokohiii

Can you elaborate on the stack analyzer? All I could figure out was to see runtime.morestack calls that affected the runtime, but as far I remember the caller timings did exclude the cost. Having a clearer view of stack grow rates would be really great.

matthewaveryusa

Nope, this analysis is wrong. Decompile your code and look at what's going on: https://godbolt.org/z/f1nx9ffYK

The thing being returned is a slice (a fat pointer) that has pointer, length, capacity. In the code linked you'll see the fat pointer being returned from the function as values.

    command-line-arguments_readLogsFromPartition_pc122:
            MOVQ    BX, AX     // slice.ptr   -> AX (first result register)
            MOVQ    SI, BX     // slice.len   -> BX (second)
            MOVQ    DX, CX     // slice.cap   -> CX (third)

nasretdinov

If the functions get inlined (which they might if they're small enough), then the code won't even need to allocate on heap! That's a kind of optimisation that's not really possible without transparent escape analysis.

null

[deleted]

potato-peeler

If the variable was defined in the calling function itself, and a pointer was passed, I guess the variable will still be in the heap?

Yokohiii

Pointers escape to the heap by default.

knorker

Are you sure this is what's happening? Looks to me like the slice object is returned by value, and the array was always on the heap. See https://go.dev/play/p/Bez0BgRny7G (the address of the slice object changed, so it's not the same object on the heap)

Sure, Go has escape analysis, but is that really what's happening here?

Isn't this a better example of escape analysis: https://go.dev/play/p/qX4aWnnwQV2 (the object retains its address, always on the heap, in both caller and callee)

masklinn

That’s the one.

Since 1.17 it’s not impossible for escape analysis to come into play for slices but afaik that is only a consideration for slices with a statically known size under 64KiB.

bonniesimon

Interesting! This could be true. I'll play around with this in a bit.

knorker

Yeah I think what you're describing, returning a slice thus copying a reference to the same array (but not copying the array), then destroying the callee slice not causing the array to be freed, is just basic garbage collection logic, not escape analysis.

samdoesnothing

Go is returning a copy of the slice, in the same way that C would return a copy of an int or struct if you returned it. The danger of C behaviour in this instance is that a stack allocated array decays into a pointer which points to the deallocated memory. Otherwise the behaviour is pretty similar between the languages.

debugnik

I first wrote an answer about how local variables can survive through a pointer, but deleted it because you're right that this Go code doesn't even address locals. It's a regular value copy.

null

[deleted]

gethly

> In C, you can't assign a value in a local function and then return it

I am so glad I never taken up C. This sound like a nightmare of a DX to me.

kjeetgill

Depending on what your working on, it's actually super nice to know very clearly what lives on the stack vs the heap for performance and compactness reasons. Basically anything that didn't come from malloc or a function calling malloc lives on the stack and doesn't live past the function it was allocated in.

And these days, if you're bothering with C you probably care about these things. Accidentally promoting from the stack to the heap would be annoying.