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:
- Get the high and low addresses of a goroutine’s stack from the Go runtime.
- 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:
- Prints the traditional Unix heap and stack memory allocations,
via reading
/proc/$PID/mapspseudo-file. - 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 ofunsafe.Slice(x). They are numerically equal. - 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.