Go doesn't have a stack the way that some other languages do

August 31, 2021

Go often looks like a relatively low level language, both through its syntax similarity to C and because it has things like explicit pointers. However in some ways this appearance can be deceptive and intuitions from languages like C can be incorrect (although not usually dangerous). One of those intuitions is about the role of the stack. This is because Go (the language) doesn't really have a stack (you can search the specification to see this; there's no mention of one). Go implementations will usually have a stack, but it's an implementation detail and some things don't behave like you might expect. In particular, in Go, local variables in a function aren't necessarily allocated on the stack.

Go certainly tries to allocate things on the stack when it can, including local variables, but this is because it's fast to allocate and deallocate stack space, not because of any semantic need. Local variables in Go just have to follow the scoping rules, at least as far as their actual use goes. Whether any particular local variable is actually allocated on the stack or is allocated on the heap depends on what your Go compiler can determine it needs to do in order to give you proper semantic behavior. This has changed over time, especially as the main Go compiler has gotten smarter and smarter about escape analysis, to the point where sometimes it has to be explicitly defeated.

(In theory there might be no semantic obstacle to stack allocating even a package level variable under suitable circumstances. In practice I think the Go compiler is unlikely to ever do that, partly because it makes things too confusing for debugging.)

This may go both ways. At least in theory, Go could choose to heap allocate sufficiently large local variables even if they don't escape, or do something extra special to allocate them so that they were outside of the stack but got released when their scope and lifetime was up. I don't know if the current Go compiler (well, compilers) have such a limit, or if they will allocate even gigantic things on a function's stack (which is growable and so theoretically has all the space it needs).

In general, taking the address of a local variable means that the local variable will have to be preserved as long as the pointer is possibly valid. Often this means that the local address will be heap allocated (and so every call to the function will create a local variable with a different address). However, Go's escape analysis is sufficiently sophisticated that merely taking the address of a local variable doesn't force it to be heap allocated; it depends on what you do with it.

As a demonstration of this, consider the following two functions:

func demo1(a int) {
  i := a
  ip := &i
  fmt.Println("demo1", a, "addr", uintptr(unsafe.Pointer(ip)))
}

func demo2(a int) *int {
  i := a
  ip := &i
  fmt.Println("demo2", a, "addr", uintptr(unsafe.Pointer(ip)))
  return ip
}

If you try these out, for example in the Go playground, you'll find that demo1() prints the same address on every call while demo2() currently prints a different one for consecutive calls even if you throw away its return value. However (and again currently), if you pass ip directly to fmt.Println(), even demo1() prints different addresses on consecutive calls.

(I believe that ip doesn't escape from the fmt.Println() call stack, but the Go compiler currently won't determine that aod so considers ip to escape, which means i must be heap allocated and then later garbage collected. Go does see through some use of pointers in called functions, as shown by the efforts Go has to go through to defeat escape analysis.)

Written on 31 August 2021.
« How ZFS stores symbolic links on disk
Large Unix programs were historically not all that portable between Unixes »

Page tools: View Source, Add Comment.
Search:
Login: Password:
Atom Syndication: Recent Comments.

Last modified: Tue Aug 31 21:23:42 2021
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.