Go can allocate slices on the heap

The Go Blog has a post titled Allocating on the Stack. The post makes the claim that programs compiled with Go v1.26 compiler, and using the v1.26 runtime can allocate backing store of slices on the stack under some circumstances.

I figured out a way to demonstrate this to myself. It was harder than I thought it would be because of the Go compiler’s escape analysis. Before my experimentation, I also believed that Go processes on Linux followed the traditional model of code/heap/stack. They do not.

I made several failed attempts at a method to check heap or stack. The principle things I learned along the way are that the Go runtime allocates goroutines’ stacks from the traditional Linux heap, and that the Go compiler escape analysis makes it difficult to output stack allocations’ addresses. I also have a hypothesis that the Go compiler treats unsafe.SliceData differently than most other functions with respect to its argument escaping to heap.

I finally figured out this procedure:

  1. Get the high and low addresses of a goroutine’s stack from the Go runtime.
  2. Find the address of a slice’s backing store without a heap escape.

Once you’ve got the stack high and low addresses, you can compare address of backing store to the stack addresses.

The Go runtime keeps track of all goroutines’ stack address, but does not export them. Luckily there is a third party Go package named routine that gains access to Go runtime functions via linker sleight of hand. This is real weird stuff, and requires a lot of experimentation to get correct. The author of package routine did some good work.

package routine doesn’t export the function that links to the Go runtime function you need to call to get stack addresses, but that can be fixed. All you need to do is add an exported function to a local copy of package routine source code, and convince the Go compiler via the go.mod file to use the local copy.

My code repo has gritty details about exactly how to accomplish all of this.

There’s some lines of code that cause the compiler to do heap allocations instead of stack allocations.

  • Calling fmt.Printf() naively to show the slice or backing store address.
  • Return backing store address or slice itself from the function that does allocation.

It is possible to call fmt.Printf() on a numeric value of the address of a slice’s backing data. The code has to do enough type conversions:

    fmt.Printf("backing store at %p\n", uintptr(unsafe.Pointer(unsafe.SliceData(x))))

These lines of code cause heap escapes for the slice x:

    fmt.Printf("Slice %p\n", x)
    fmt.Printf("Slice at %p\n", &x)
    fmt.Printf("backing store at %p\n", unsafe.SliceData(x))
    fmt.Printf("backing store at %p\n", unsafe.Pointer(unsafe.SliceData(x)))
    fmt.Printf("backing store at %p\n", &(x[0]))

I ended up creating a slice and finding the address of its backing store with code like this:

    x := make([]byte, 64)
    backingStore := uintptr(unsafe.Pointer(&(x[0])))

Looks to me like doing enough type conversions (or maybe just the type conversion unintptr()) causes the Go compiler’s escape analysis to lose the thread for a particular address.

Type conversions unsafe.Pointer() and uintptr() are visually identical to function calls. They are not function calls, and do not trigger the compiler into allocating anything on the heap.

In the example code above, &(x[0]) evaluates to the address of the backing store without causing the Go compiler to allocate the slice x from the heap.

This is what my program prints out:

$ ./slicestack
system heap:  2ad3a7c00000-2ad3a8400000
system stack: 7fff81cfb000-7fff81d1c000

slice backing store addressing check
backing store at 0x2ad3a80ca000
first element at 0x2ad3a80ca000

stack top     00002ad3a80a9000
backing store 00002ad3a80a8db0
stack bottom  00002ad3a80a8000
Slice backing store on stack: true

My program accomplishes these things:

  1. Prints the traditional Unix heap and stack memory allocations, via reading /proc/$PID/maps pseudo-file.
  2. Demonstrates that the method of getting address of slice’s backing store actually works. What you see is the address of the first element of a slice (&(x[0])), and the return value of unsafe.Slice(x). They are numerically equal.
  3. Checks that a slice’s backing store gets allocated on the goroutine’s stack.

Modifying the stack-allocation-checking code to do something like fmt.Printf("%p\n", &(x[0])) will cause it to output Slice backing store on stack: false.

I consider the Go Blog’s post verified.