blob: efaad18b392a8523ee1b085a31d29b6a2150664d [file] [log] [blame] [view] [edit]
---
title: "From unique to cleanups and weak: new low-level tools for efficiency"
date: 2025-03-06
by:
- Michael Knyszek
tags:
- weak
- cleanup
- finalizer
summary: Weak pointers and better finalization in Go 1.24.
---
In [last year's blog post](/blog/unique) about the `unique` package, we alluded
to some new features then in proposal review, and we're excited to share that as
of Go 1.24 they are now available to all Go developers.
These new features are [the `runtime.AddCleanup`
function](https://pkg.go.dev/runtime#AddCleanup), which queues up a function to
run when an object is no longer reachable, and [the `weak.Pointer`
type](https://pkg.go.dev/weak#Pointer), which safely points to an object without
preventing it from being garbage collected.
Together, these two features are powerful enough to build your own `unique`
package!
Let's dig into what makes these features useful, and when to use them.
Note: these new features are advanced features of the garbage collector.
If you're not already familiar with basic garbage collection concepts, we
strongly recommend reading the introduction of our [garbage collector
guide](/doc/gc-guide#Introduction).
## Cleanups
If you've ever used a finalizer, then the concept of a cleanup will be
familiar.
A finalizer is a function, associated with an allocated object by [calling
`runtime.SetFinalizer`](https://pkg.go.dev/runtime#SetFinalizer), that is later
called by the garbage collector some time after the object becomes unreachable.
At a high level, cleanups work the same way.
Let's consider an application that makes use of a memory-mapped file, and see
how cleanups can help.
```
//go:build unix
type MemoryMappedFile struct {
data []byte
}
func NewMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
// Get the file's info; we need its size.
fi, err := f.Stat()
if err != nil {
return nil, err
}
// Extract the file descriptor.
conn, err := f.SyscallConn()
if err != nil {
return nil, err
}
var data []byte
connErr := conn.Control(func(fd uintptr) {
// Create a memory mapping backed by this file.
data, err = syscall.Mmap(int(fd), 0, int(fi.Size()), syscall.PROT_READ, syscall.MAP_SHARED)
})
if connErr != nil {
return nil, connErr
}
if err != nil {
return nil, err
}
mf := &MemoryMappedFile{data: data}
cleanup := func(data []byte) {
syscall.Munmap(data) // ignore error
}
runtime.AddCleanup(mf, cleanup, data)
return mf, nil
}
```
A memory-mapped file has its contents mapped to memory, in this case, the
underlying data of a byte slice.
Thanks to operating-system magic, reads and writes to the byte slice directly
access the contents of the file.
With this code, we can pass around a `*MemoryMappedFile`, and when it's
no longer referenced, the memory mapping we created will get cleaned up.
Notice that `runtime.AddCleanup` takes three arguments: the address of a
variable to attach the cleanup to, the cleanup function itself, and an argument
to the cleanup function.
A key difference between this function and `runtime.SetFinalizer` is that the
cleanup function takes a different argument than the object we're attaching the
cleanup to.
This change fixes some problems with finalizers.
It's no secret that [finalizers
are difficult to use correctly](/doc/gc-guide#Common_finalizer_issues).
For example, objects to which finalizers are attached must not be involved
in any reference cycles (even a pointer to itself is too much!), otherwise the
object will never be reclaimed and the finalizer will never run, causing a
leak.
Finalizers also significantly delay reclamation of memory.
It takes at a minimum two full garbage collection cycles to reclaim the
memory for a finalized object: one to determine that it's unreachable, and
another to determine that it's still unreachable after the finalizer
executes.
The problem is that finalizers [resurrect the objects they're attached
to](https://en.wikipedia.org/wiki/Object_resurrection).
The finalizer doesn't run until the object is unreachable, at which point it is
considered "dead."
But since the finalizer is called with a pointer to the object, the garbage
collector must prevent the reclamation of that object's memory, and instead must
generate a new reference for the finalizer, making it reachable, or "live," once
more.
That reference may even remain after the finalizer returns, for example if the
finalizer writes it to a global variable or sends it across a channel.
Object resurrection is problematic because it means the object, and everything
it points to, and everything those objects point to, and so on, is reachable,
even if it would otherwise have been collected as garbage.
We solve both of these problems by not passing the original object to the
cleanup function.
First, the values the object refers to don't need to be kept specially
reachable by the garbage collector, so the object can still be reclaimed even
if it's involved in a cycle.
Second, since the object is not needed for the cleanup, its memory can be
reclaimed immediately.
## Weak pointers
Returning to our memory-mapped file example, suppose we notice that our program
frequently maps the same files over and over, from different goroutines that are
unaware of each other.
This is fine from a memory perspective, since all these mappings will share
physical memory, but it results in lots of unnecessary system calls to map and
unmap the file.
This is especially bad if each goroutine reads only a small section of each
file.
So, let's deduplicate the mappings by filename.
(Let's assume that our program only reads from the mappings, and the files
themselves are never modified or renamed once created.
Such assumptions are reasonable for system font files, for example.)
We could maintain a map from filename to memory mapping, but then it becomes
unclear when it's safe to remove entries from that map.
We could *almost* use a cleanup, if it weren't for the fact that the map entry
itself will keep the memory-mapped file object alive.
Weak pointers solve this problem.
A weak pointer is a special kind of pointer that the garbage collector ignores
when deciding whether an object is reachable.
Go 1.24's [new weak pointer type,
`weak.Pointer`](https://pkg.go.dev/weak#Pointer), has a `Value` method that
returns either a real pointer if the object is still reachable, or `nil` if it
is not.
If we instead maintain a map that only *weakly* points to the memory-mapped
file, we can clean up the map entry when nobody's using it anymore!
Let's see what this looks like.
```
var cache sync.Map // map[string]weak.Pointer[MemoryMappedFile]
func NewCachedMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
var newFile *MemoryMappedFile
for {
// Try to load an existing value out of the cache.
value, ok := cache.Load(filename)
if !ok {
// No value found. Create a new mapped file if needed.
if newFile == nil {
var err error
newFile, err = NewMemoryMappedFile(filename)
if err != nil {
return nil, err
}
}
// Try to install the new mapped file.
wp := weak.Make(newFile)
var loaded bool
value, loaded = cache.LoadOrStore(filename, wp)
if !loaded {
runtime.AddCleanup(newFile, func(filename string) {
// Only delete if the weak pointer is equal. If it's not, someone
// else already deleted the entry and installed a new mapped file.
cache.CompareAndDelete(filename, wp)
}, filename)
return newFile, nil
}
// Someone got to installing the file before us.
//
// If it's still there when we check in a moment, we'll discard newFile
// and it'll get cleaned up by garbage collector.
}
// See if our cache entry is valid.
if mf := value.(weak.Pointer[MemoryMappedFile]).Value(); mf != nil {
return mf, nil
}
// Discovered a nil entry awaiting cleanup. Eagerly delete it.
cache.CompareAndDelete(filename, value)
}
}
```
This example is a little complicated, but the gist is simple.
We start with a global concurrent map of all the mapped files we made.
`NewCachedMemoryMappedFile` consults this map for an existing mapped
file, and if that fails, creates and tries to insert a new mapped file.
This could of course fail as well since we're racing with other insertions, so
we need to be careful about that too, and retry.
(This design has a flaw in that we might wastefully map the same file multiple
times in a race, and we'll have to throw it away via the cleanup added by
`NewMemoryMappedFile`.
This is probably not a big deal most of the time.
Fixing it is left as an exercise for the reader.)
Let's look at some useful properties of weak pointers and cleanups exploited by
this code.
Firstly, notice that weak pointers are comparable.
Not only that, weak pointers have a stable and independent identity, which
remains even after the objects they point to are long gone.
This is why it is safe for the cleanup function to call `sync.Map`'s
`CompareAndDelete`, which compares the `weak.Pointer`, and a crucial reason
this code works at all.
Secondly, observe that we can add multiple independent cleanups to a single
`MemoryMappedFile` object.
This allows us to use cleanups in a composable way and use them to build
generic data structures.
In this particular example, it might be more efficient to combine
`NewCachedMemoryMappedFile` with `NewMemoryMappedFile` and
have them share a cleanup.
However, the advantage of the code we wrote above is that it can be rewritten
in a generic way!
```
type Cache[K comparable, V any] struct {
create func(K) (*V, error)
m sync.Map
}
func NewCache[K comparable, V any](create func(K) (*V, error)) *Cache[K, V] {
return &Cache[K, V]{create: create}
}
func (c *Cache[K, V]) Get(key K) (*V, error) {
var newValue *V
for {
// Try to load an existing value out of the cache.
value, ok := cache.Load(key)
if !ok {
// No value found. Create a new mapped file if needed.
if newValue == nil {
var err error
newValue, err = c.create(key)
if err != nil {
return nil, err
}
}
// Try to install the new mapped file.
wp := weak.Make(newValue)
var loaded bool
value, loaded = cache.LoadOrStore(key, wp)
if !loaded {
runtime.AddCleanup(newValue, func(key K) {
// Only delete if the weak pointer is equal. If it's not, someone
// else already deleted the entry and installed a new mapped file.
cache.CompareAndDelete(key, wp)
}, key)
return newValue, nil
}
}
// See if our cache entry is valid.
if mf := value.(weak.Pointer[V]).Value(); mf != nil {
return mf, nil
}
// Discovered a nil entry awaiting cleanup. Eagerly delete it.
cache.CompareAndDelete(key, value)
}
}
```
## Caveats and future work
Despite our best efforts, cleanups and weak pointers can still be error-prone.
To guide those considering using finalizers, cleanups, and weak pointers, we
recently updated the [guide to the garbage
collector](/doc/gc-guide#Finalizers_cleanups_and_weak_pointers) with some advice
about using these features.
Take a look next time you reach for them, but also carefully consider whether
you need to use them at all.
These are advanced tools with subtle semantics and, as the guide says, most
Go code benefits from these features indirectly, not from using them directly.
Stick to the use-cases where these features shine, and you'll be alright.
For now, we'll call out some of the issues that you are more likely to run into.
First, the object the cleanup is attached to must be reachable from neither
the cleanup function (as a captured variable) nor the argument to the cleanup
function.
Both of these situations result in the cleanup never executing.
(In the special case of the cleanup argument being exactly the pointer passed
to `runtime.AddCleanup`, `runtime.AddCleanup` will panic, as a signal to the
caller that they should not use cleanups the same way as finalizers.)
Second, when weak pointers are used as map keys, the weakly referenced object
must not be reachable from the corresponding map value, otherwise the object
will continue to remain live.
This may seem obvious when deep inside of a blog post about weak pointers, but
it's an easy subtlety to miss.
This problem inspired the entire concept of an
[ephemeron](https://en.wikipedia.org/wiki/Ephemeron) to resolve it, which is a
potential future direction.
Thirdly, a common pattern with cleanups is that a wrapper object is needed, like
we see here with our `MemoryMappedFile` example.
In this particular case, you could imagine the garbage collector directly
tracking the mapped memory region and passing around the inner `[]byte`.
Such functionality is possible future work, and an API for it has been recently
[proposed](/issue/70224).
Lastly, both weak pointers and cleanups are inherently non-deterministic, their
behavior depending intimately on the design and dynamics of the garbage
collector.
The documentation for cleanups even permits the garbage collector never to run
cleanups at all.
Effectively testing code that uses them can be tricky, but [it is
possible](/doc/gc-guide#Testing_object_death).
## Why now?
Weak pointers have been brought up as a feature for Go since nearly the
beginning, but for years were not prioritized by the Go team.
One reason for that is that they are subtle, and the design space of weak
pointers is a minefield of decisions that can make them even harder to use.
Another is that weak pointers are a niche tool, while simultaneously adding
complexity to the language.
We already had experience with how painful `SetFinalizer` could be to use.
But there are some useful programs that are not expressible without them, and
the `unique` package and the reasons for its existence really emphasized that.
With generics, the hindsight of finalizers, and insights from all the great work
since done by teams in other languages like C# and Java, the designs for weak
pointers and cleanups came together quickly.
The desire to use weak pointers with finalizers raised additional questions,
and so the design for `runtime.AddCleanup` quickly came together as well.
## Acknowledgements
I want to thank everyone in the community who contributed feedback on the
proposal issues and filed bugs when the features became available.
I also want to thank David Chase for thoroughly thinking through weak
pointer semantics with me, and I want to thank him, Russ Cox, and Austin
Clements for their help with the design of `runtime.AddCleanup`.
I want to thank Carlos Amedee for his work on getting `runtime.AddCleanup`
implemented, polished, landed for Go 1.24.
And finally I want to thank Carlos Amedee and Ian Lance Taylor for their work
replacing `runtime.SetFinalizer` with `runtime.AddCleanup` throughout the
standard library for Go 1.25.