runtime: preserve malloc allocations until free

C malloc storage has explicit lifetime: it must remain allocated until
free even when no GC-visible pointer references it. Treating it as an
ordinary NoPtrs allocation breaks bare-metal C object graphs, while
conservatively scanning arbitrary C bytes creates false Go roots.

Add allocManual/freeManual so collectors can represent pointer-free,
explicitly managed storage. Block GC keeps these objects permanently
marked and releases their blocks on free; Boehm uses atomic uncollectable
allocations; leaking and custom collectors provide equivalent behavior.
Wasm keeps its allocation map only for validation and sizes, and WASIp2
realloc now copies min(oldSize, newSize).

Bump the Boehm library cache version because enabling atomic
uncollectable allocations changes its compiled flags and exported API.

Also handle zero-size and overflowing allocations, serialize allocation
registries, reject Go finalizers on manual storage, and add CGo regressions
for C pointer graphs, hidden until-free allocations, repeated free/reuse,
and allocation edge cases.
This commit is contained in:
Jake Bailey
2026-08-10 16:32:45 -07:00
committed by Ron Evans
parent 59fb104004
commit 02021b5853
23 changed files with 413 additions and 99 deletions
+46 -25
View File
@@ -3,7 +3,7 @@
package runtime
import (
"internal/gclayout"
"internal/task"
"unsafe"
)
@@ -11,21 +11,21 @@ import (
// code linked from other languages can allocate memory without colliding with
// our GC allocations.
// Map of allocations, where the key is the allocated pointer and the value is
// the size of the allocation.
// TODO: make this a map[unsafe.Pointer]uintptr, since that results in slightly
// smaller binaries. But for that to work, unsafe.Pointer needs to be seen as a
// binary key (which it is not at the moment).
// See https://github.com/tinygo-org/tinygo/pull/4898 for details.
var allocs = make(map[*byte]uintptr)
// Map of allocations, where the key is the allocation address and the value is
// its size. Integer keys intentionally do not act as GC roots: manual
// allocations are retained by the allocator until free.
var allocs = make(map[uintptr]uintptr)
var allocsLock task.PMutex
//export malloc
func libc_malloc(size uintptr) unsafe.Pointer {
if size == 0 {
return nil
}
ptr := alloc(size, gclayout.NoPtrs.AsPtr())
allocs[(*byte)(ptr)] = size
ptr := allocManual(size)
allocsLock.Lock()
allocs[uintptr(ptr)] = size
allocsLock.Unlock()
return ptr
}
@@ -34,16 +34,22 @@ func libc_free(ptr unsafe.Pointer) {
if ptr == nil {
return
}
if _, ok := allocs[(*byte)(ptr)]; ok {
delete(allocs, (*byte)(ptr))
allocsLock.Lock()
if _, ok := allocs[uintptr(ptr)]; ok {
delete(allocs, uintptr(ptr))
allocsLock.Unlock()
freeManual(ptr)
} else {
allocsLock.Unlock()
runtimeFatal("free: invalid pointer")
}
}
//export calloc
func libc_calloc(nmemb, size uintptr) unsafe.Pointer {
// No difference between calloc and malloc.
if size != 0 && nmemb > ^uintptr(0)/size {
return nil
}
return libc_malloc(nmemb * size)
}
@@ -54,22 +60,37 @@ func libc_realloc(oldPtr unsafe.Pointer, size uintptr) unsafe.Pointer {
return nil
}
// It's hard to optimize this to expand the current buffer with our GC, but
// it is theoretically possible. For now, just always allocate fresh.
// TODO: we could skip this if the new allocation is smaller than the old.
ptr := alloc(size, gclayout.NoPtrs.AsPtr())
var oldSize uintptr
if oldPtr != nil {
if oldSize, ok := allocs[(*byte)(oldPtr)]; ok {
oldBuf := unsafe.Slice((*byte)(oldPtr), oldSize)
newBuf := unsafe.Slice((*byte)(ptr), size)
copy(newBuf, oldBuf)
delete(allocs, (*byte)(oldPtr))
} else {
allocsLock.Lock()
var ok bool
oldSize, ok = allocs[uintptr(oldPtr)]
allocsLock.Unlock()
if !ok {
runtimeFatal("realloc: invalid pointer")
}
}
allocs[(*byte)(ptr)] = size
// It's hard to optimize this to expand the current buffer with our GC, but
// it is theoretically possible. For now, just always allocate fresh.
// TODO: we could skip this if the new allocation is smaller than the old.
ptr := allocManual(size)
allocsLock.Lock()
if oldPtr != nil {
if currentSize, ok := allocs[uintptr(oldPtr)]; !ok || currentSize != oldSize {
allocsLock.Unlock()
runtimeFatal("realloc: invalid pointer")
}
oldBuf := unsafe.Slice((*byte)(oldPtr), oldSize)
newBuf := unsafe.Slice((*byte)(ptr), size)
copy(newBuf, oldBuf)
delete(allocs, uintptr(oldPtr))
}
allocs[uintptr(ptr)] = size
allocsLock.Unlock()
if oldPtr != nil {
freeManual(oldPtr)
}
return ptr
}
+5 -4
View File
@@ -3,7 +3,6 @@
package runtime
import (
"internal/gclayout"
"sync/atomic"
"unsafe"
)
@@ -12,18 +11,20 @@ import (
func libc_malloc(size uintptr) unsafe.Pointer {
// Note: this zeroes the returned buffer which is not necessary.
// The same goes for bytealg.MakeNoZero.
return alloc(size, gclayout.NoPtrs.AsPtr())
return allocManual(size)
}
//export calloc
func libc_calloc(nmemb, size uintptr) unsafe.Pointer {
// No difference between calloc and malloc.
if size != 0 && nmemb > ^uintptr(0)/size {
return nil
}
return libc_malloc(nmemb * size)
}
//export free
func libc_free(ptr unsafe.Pointer) {
free(ptr)
freeManual(ptr)
}
//export runtime_putchar
+74 -36
View File
@@ -205,8 +205,9 @@ func (b gcBlock) free() {
// objHeader is a structure appended to every heap object to hold metadata.
type objHeader struct {
// next is the next object to scan after this.
next *objHeader
// next links the GC scan list. Manual allocations remain permanently marked
// and use the otherwise invalid value 1 as an until-free marker.
next uintptr
// layout holds the layout bitmap used to find pointers in the object.
layout gcLayout
@@ -482,6 +483,7 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer {
// Create the object header.
size -= unsafe.Sizeof(objHeader{})
header := (*objHeader)(unsafe.Add(pointer, size))
header.next = 0
header.layout = parseGCLayout(layout)
// We've claimed this allocation, now we can unlock the heap.
@@ -500,42 +502,61 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer {
return pointer
}
func realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer {
if ptr == nil {
return alloc(size, gclayout.NoPtrs.AsPtr())
// allocManual allocates pointer-free memory that remains live until freeManual.
func allocManual(size uintptr) unsafe.Pointer {
if size == 0 {
return alloc_zero(size, gclayout.NoPtrs.AsPtr())
}
ptr := alloc(size, gclayout.NoPtrs.AsPtr())
// Find the first block of the original allocation.
firstBlock := blockFromAddr(uintptr(ptr))
// Find the last block of the original allocation.
lastBlock := firstBlock.findHead()
// Calculate the size of the original allocation body.
oldSize := uintptr(lastBlock-firstBlock)*bytesPerBlock + (bytesPerBlock - unsafe.Sizeof(objHeader{}))
if size <= oldSize {
// The requested size is less than the old size.
// There are likely scenarios for this:
// - The caller intended to grow the allocation, but the original size
// was rounded up by alloc to a multiple of the block size.
// The rounded size is already sufficient.
// - The caller intended to shrink the allocation.
// We currently ignore this case.
// Either way, the current allocation can be left alone.
return ptr
}
// Create a new allocation and copy the old data.
newAlloc := alloc(size, gclayout.NoPtrs.AsPtr())
memcpy(newAlloc, ptr, oldSize)
free(ptr)
return newAlloc
gcLock.Lock()
head := blockFromAddr(uintptr(ptr)).findHead()
head.setState(blockStateMark)
header := (*objHeader)(unsafe.Add(head.pointer(), bytesPerBlock-unsafe.Sizeof(objHeader{})))
header.next = 1
gcLock.Unlock()
return ptr
}
func free(ptr unsafe.Pointer) {
// TODO: free blocks on request, when the compiler knows they're unused.
if ptr == nil {
return
}
gcLock.Lock()
addr := uintptr(ptr)
if !isOnHeap(addr) || (addr-heapStart)%bytesPerBlock != 0 {
gcLock.Unlock()
runtimeFatal("free: invalid pointer")
}
firstBlock := blockFromAddr(addr)
state := firstBlock.state()
if state != blockStateTail && state != blockStateHead && state != blockStateMark {
gcLock.Unlock()
runtimeFatal("free: invalid pointer")
}
allocationStart := firstBlock
for allocationStart != 0 && (allocationStart-1).state() == blockStateTail {
allocationStart--
}
if allocationStart != firstBlock {
gcLock.Unlock()
runtimeFatal("free: invalid pointer")
}
lastBlock := firstBlock.findHead()
header := (*objHeader)(unsafe.Add(lastBlock.pointer(), bytesPerBlock-unsafe.Sizeof(objHeader{})))
if header.next != 1 {
gcLock.Unlock()
runtimeFatal("free: invalid pointer")
}
for block := firstBlock; block <= lastBlock; block++ {
block.free()
}
insertFreeRange(firstBlock.pointer(), uintptr(lastBlock-firstBlock+1))
gcLock.Unlock()
}
// GC performs a garbage collection cycle.
@@ -666,7 +687,7 @@ func finishMark() {
if obj == nil {
return
}
scanList = obj.next
scanList = (*objHeader)(unsafe.Pointer(obj.next))
// Check if the object may contain pointers.
if obj.layout.pointerFree() {
@@ -724,7 +745,7 @@ func markRoot(addr, root uintptr) {
// Add the object to the scan list.
header := (*objHeader)(unsafe.Add(head.pointer(), bytesPerBlock-unsafe.Sizeof(objHeader{})))
header.next = scanList
header.next = uintptr(unsafe.Pointer(scanList))
scanList = header
}
@@ -758,7 +779,10 @@ func sweep() uintptr {
// Unmark the next head.
block--
block.unmark()
header := (*objHeader)(unsafe.Add(block.pointer(), bytesPerBlock-unsafe.Sizeof(objHeader{})))
if header.next != 1 {
block.unmark()
}
// Skip the tail.
for block > 0 && (block-1).state() == blockStateTail {
@@ -903,5 +927,19 @@ func SetFinalizer(obj interface{}, finalizer interface{}) {
// A nil pointer has nothing to finalize.
return
}
gcLock.Lock()
addr := uintptr(objPtr)
manual := false
if isOnHeap(addr) {
head := blockFromAddr(addr).findHead()
header := (*objHeader)(unsafe.Add(head.pointer(), bytesPerBlock-unsafe.Sizeof(objHeader{})))
manual = header.next == 1
}
gcLock.Unlock()
if manual && finalizer != nil {
runtimeFatal("runtime.SetFinalizer: manual allocation")
}
registerFinalizer(uintptr(objPtr), finalizer)
}
+23
View File
@@ -98,8 +98,28 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer {
return ptr
}
func allocManual(size uintptr) unsafe.Pointer {
if size == 0 {
return alloc_zero(size, gclayout.NoPtrs.AsPtr())
}
gcLock.Lock()
ptr := libgc_malloc_atomic_uncollectable(size)
gcResumeWorld()
gcLock.Unlock()
if ptr == nil {
runtimeFatal("gc: out of memory")
return nil
}
memzero(ptr, size)
return ptr
}
func free(ptr unsafe.Pointer) {
gcLock.Lock()
libgc_free(ptr)
gcResumeWorld()
gcLock.Unlock()
}
func GC() {
@@ -153,6 +173,9 @@ func libgc_malloc(uintptr) unsafe.Pointer
//export GC_malloc_atomic
func libgc_malloc_atomic(uintptr) unsafe.Pointer
//export GC_malloc_atomic_uncollectable
func libgc_malloc_atomic_uncollectable(uintptr) unsafe.Pointer
//export GC_free
func libgc_free(unsafe.Pointer)
+1 -8
View File
@@ -23,14 +23,7 @@ package runtime
// - func SetFinalizer(obj interface{}, finalizer interface{})
// - func ReadMemStats(ms *runtime.MemStats)
//
//
// In addition, if targeting wasi, the following functions should be exported for interoperability
// with wasi libraries that use them. Note, this requires the export directive, not go:linkname.
//
// - func malloc(size uintptr) unsafe.Pointer
// - func free(ptr unsafe.Pointer)
// - func calloc(nmemb, size uintptr) unsafe.Pointer
// - func realloc(oldPtr unsafe.Pointer, size uintptr) unsafe.Pointer
// The compiler provides the global root ranges used by markRoots.
import (
"unsafe"
-13
View File
@@ -7,7 +7,6 @@ package runtime
// may be the only memory allocator possible.
import (
"internal/gclayout"
"internal/task"
"sync/atomic"
"unsafe"
@@ -69,18 +68,6 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer {
return pointer
}
func realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer {
newAlloc := alloc(size, gclayout.NoPtrs.AsPtr())
if ptr == nil {
return newAlloc
}
// according to POSIX everything beyond the previous pointer's
// size will have indeterminate values so we can just copy garbage
memcpy(newAlloc, ptr, size)
return newAlloc
}
func free(ptr unsafe.Pointer) {
// Memory is never freed.
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !gc.custom
package runtime
import "unsafe"
func freeManual(ptr unsafe.Pointer) {
if ptr != nil && ptr != unsafe.Pointer(zeroSizeAllocPtr) {
free(ptr)
}
}
+35
View File
@@ -0,0 +1,35 @@
//go:build gc.custom
package runtime
import (
"internal/gclayout"
"internal/task"
"unsafe"
)
// Custom collectors retain manual allocations through ordinary typed roots so
// the custom GC interface does not need an additional allocation primitive.
var manualAllocs = make(map[*byte]struct{})
var manualAllocsLock task.PMutex
func allocManual(size uintptr) unsafe.Pointer {
if size == 0 {
return alloc_zero(size, gclayout.NoPtrs.AsPtr())
}
ptr := alloc(size, gclayout.NoPtrs.AsPtr())
manualAllocsLock.Lock()
manualAllocs[(*byte)(ptr)] = struct{}{}
manualAllocsLock.Unlock()
return ptr
}
func freeManual(ptr unsafe.Pointer) {
if ptr == nil || ptr == unsafe.Pointer(zeroSizeAllocPtr) {
return
}
manualAllocsLock.Lock()
delete(manualAllocs, (*byte)(ptr))
manualAllocsLock.Unlock()
free(ptr)
}
+15
View File
@@ -0,0 +1,15 @@
//go:build gc.leaking || gc.none
package runtime
import (
"internal/gclayout"
"unsafe"
)
func allocManual(size uintptr) unsafe.Pointer {
if size == 0 {
return alloc_zero(size, gclayout.NoPtrs.AsPtr())
}
return alloc(size, gclayout.NoPtrs.AsPtr())
}
-2
View File
@@ -22,8 +22,6 @@ func scanCurrentStack() {}
func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer
func realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer
func free(ptr unsafe.Pointer) {
// Nothing to free when nothing gets allocated.
}
+16 -1
View File
@@ -31,7 +31,22 @@ func os_runtime_args() []string {
//export cabi_realloc
func cabi_realloc(ptr, oldsize, align, newsize unsafe.Pointer) unsafe.Pointer {
return realloc(ptr, uintptr(newsize))
size := uintptr(newsize)
if size == 0 {
freeManual(ptr)
return nil
}
newPtr := allocManual(size)
if ptr != nil {
copySize := uintptr(oldsize)
if copySize > size {
copySize = size
}
memcpy(newPtr, ptr, copySize)
freeManual(ptr)
}
return newPtr
}
func ticksToNanoseconds(ticks timeUnit) int64 {