Repository: go
Issue: proposal: runtime: manage off-heap memory lifecycle using the garbage collector
Labels: Proposal

### Proposal Details

### Proposal

Hi folks, I'd like to explore the possibility for the runtime to 'adopt' externally-allocated memory by tracking pointers to the span and unmapping the underlying memory if there are no more references:

```go
// TrackPointers tells the garbage collector to consider the off-heap memory
// span described by ptr and size as Go memory. finalize is scheduled for
// execution when the span is no longer referenced. The span is never reused
// to satisfy any other allocations.
//
// TrackPointers panics if the span overlaps with any existing memory span
// known to the Go runtime, heap or otherwise.
func TrackPointers(ptr unsafe.Pointer, size int, finalize func())
```

To be used as:
```go
alloc, _ := unix.MmapPtr(mapFD, 0, nil, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)

runtime.TrackPointers(ptr, size, func(p unsafe.Pointer) {
_ = unix.MunmapPtr(p, size)
})
```

Or, alternatively, a variant without the finalize argument that allows setting a finalizer explicitly:
```go
alloc, _ := unix.MmapPtr(mapFD, 0, nil, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)

runtime.TrackPointers(ptr, size)

runtime.SetFinalizer(alloc, func(p unsafe.Pointer) {
_ = unix.MunmapPtr(p, size)
})
```

Or, if that's equally undesirable, an internal symbol we can `//go:linkname` and make `unix.Mmap` use it transparently.

---

### Background

I'm working on a new feature in [ebpf-go](https://github.com/cilium/ebpf). A while ago, the Linux kernel gained the abillity to map the contents of a bpf map into process memory using mmap(), essentially treating bpf map contents as a file. Historically, all map accesses required preparing buffers for the key and value to pass to a bpf() syscall, a costly operation for busy maps. The mmapable map change made it so map accesses can be done by simply reading or writing to a memory location in a user space process, speeding things up by an order of magnitude or more.

Naturally, we'd like to enjoy the benefits of mmapable maps over in Go land as well, but this poses some unique challenges around memory management, specifically for managing the lifecycle of the underlying memory mappings. A common use case for mmapable maps is interacting with global BPF C variables. These are laid out in the typical data sections like `.bss`, `.data` and `.rodata` and are exposed to user space as plain BPF array maps. My goal is to be able to represent a global C variable like

```c
volatile __u16 global_u16;
```

as a canonical Go variable, albeit a pointer. For example:

```go
var GlobalUint16 *uint16
```

This becomes even more interesting if the global variable is only accessed atomically (using `__sync_*` primitives) on the C side, allowing the shared memory to be reinterpreted as a Go atomic type like `atomic.Uint16`, automatically giving the caller access to all operations implemented on those types.

Here's a playground link sketching the overall idea: https://go.dev/play/p/NyoPxKZbK5R. (Note: run this locally, playground runners lack CAP_ADMIN and/or CAP_BPF.)

Since the runtime doesn't track references to this mmap()ed region, we need to bind the lifecycle of the memory mapping to some Go object (in my ebpf-go proposal, this is modeled as an `ebpf.Memory` struct), but care needs to be taken not to lose the reference to this object if we allow the caller to take pointers to the underlying memory. The risk of a use-after-free is high.

---

This is somewhat the inverse of Go arenas, yet tangentially-related. I was sad to find out the arenas proposal is on hold indefinitely, since it would've opened the door for some more manual memory management in Go. Aside from accessing bpf maps, I can imagine this mechanism being useful for databases or zero-copy file parsers, as it would enable passing Go pointers to structs that reside in file-backed memory, without worrying about use-after-free.

I originally got this idea from https://pkg.go.dev/github.com/Jille/gcmmap, a package that mmaps over the Go heap using MAP_FIXED. It uses `runtime.mallocgc()` but allocating a byte slice works just as well. I experimented with this approach for a few weeks and it happens to work beautifully, but it makes several hard assumptions:

- there's no moving garbage collector (although we have `runtime.heapObjectsCanMove` nowadays)
- the heap is always mapped using `PROT_READ|PROT_WRITE` and `MAP_ANON|MAP_PRIVATE`
- there are no other protections like mseal()

Not to mention the risk of accidentally clearing a part of, or leaving a hole in the middle of the heap. Our package powers many mission-critical systems, and this feature would be enabled by default, which means we need to be careful. ebpf-go already made itself into the `go:linkname` hall of shame, so I'll try not to exacerbate that issue further. :slightly_smiling_face: 

Please let me know what you think. Thank you!