Why Go’s AddCleanup Outperforms SetFinalizer for Safe Resource Cleanup

This article explains the limitations of Go's runtime.SetFinalizer, introduces the new runtime.AddCleanup function in Go 1.24, demonstrates proper usage with code examples, and highlights important pitfalls and best‑practice tips for reliable resource management.

Radish, Keep Going!
Radish, Keep Going!
Radish, Keep Going!
Why Go’s AddCleanup Outperforms SetFinalizer for Safe Resource Cleanup

Previously I wrote an article about runtime.SetFinalizer, a function that runs when an object is reclaimed, but it has several drawbacks that limit its usage.

When using SetFinalizer in Go, the object reference must point to the start of the allocated memory block, a concept not exposed at the language level. An object can have only one SetFinalizer . If an object with SetFinalizer participates in any reference cycle, it will never be released and the finalizer will not run. Objects with SetFinalizer require at least two GC cycles before they can be reclaimed (see https://github.com/golang/go/issues/67535).

For the first point, consider this example:

package main

import (
    "fmt"
    "runtime"
)

type MyStruct struct {
    Field int
}

func main() {
    obj := &MyStruct{Field: 42}
    runtime.SetFinalizer(obj, func(m *MyStruct) {
        fmt.Println("Finalizer called for:", m)
    })
    // Incorrect example: passing a field reference instead of the object itself
    // runtime.SetFinalizer(&obj.Field, func(m *int) {
    //     fmt.Println("Finalizer called for:", *m)
    // })
}

Because of these issues, Go 1.24 introduced a new function runtime.AddCleanup to replace runtime.SetFinalizer.

Note: Neither runtime.AddCleanup nor runtime.SetFinalizer guarantees that the cleanup function will definitely run.

The design goal of AddCleanup is to solve the problems of SetFinalizer, especially avoiding object resurrection, allowing timely cleanup, and supporting cyclic cleanup.

The prototype of AddCleanup is:

func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup

Using this, you can write an RAII‑style demo. In the "go weak" article we implemented a fixed‑size cache; by adding a newElemWeak method we can automatically call delete to remove a key from the cache when the oldest element is evicted:

func (c *WeakCache) newElemWeak(elem *list.Element) weak.Pointer[list.Element] {
    elemWeak := weak.Make(elem)
    runtime.AddCleanup(elem, func(name string) {
        delete(c.cache, name)
    }, elem.Value.(*CacheItem).key)
    return elemWeak
}

Things to Watch Out For

AddCleanup

places few constraints on ptr and allows attaching multiple cleanup functions to the same pointer. However, if ptr becomes reachable from the cleanup or arg, it will never be reclaimed, causing a memory leak; the cleanup will never run, though this does not currently panic. Future detection might use GODEBUG=gccheckmark=1.

Example with a file resource:

func NewFileResource(filename string) (*os.File, error) {
    file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0666)
    if err != nil {
        return nil, err
    }
    runtime.AddCleanup(file, func(f *os.File) {
        fmt.Println("close f")
        _ = f.Close()
    }, file)
    return file, nil
}

The fmt.Println("close f") line will not be printed and the file will not be closed if the cleanup never runs.

When multiple cleanups are bound to a pointer, they run in separate goroutines, so their execution order is nondeterministic.

Especially, if several objects reference each other and become unreachable simultaneously, their cleanup functions can all run in any order, even if the objects form a cycle (which would cause a memory leak with SetFinalizer).

Example demonstrating cyclic objects:

func main() {
    x := MyStruct{Name: "X"}
    y := MyStruct{Name: "Y"}
    x.Other = &y
    y.Other = &x
    xName := x.Name
    runtime.AddCleanup(&x, func(name string) {
        fmt.Println("Cleanup for", x)
    }, xName)
    yName := y.Name
    runtime.AddCleanup(&y, func(name string) {
        fmt.Println("Cleanup for", y)
    }, yName)
    time.Sleep(time.Millisecond)
    runtime.GC()
    time.Sleep(time.Millisecond)
    runtime.GC()
}

Running the program produces:

Cleanup for Y
Cleanup for X

Thus, SetFinalizer can block GC, whereas AddCleanup executes as expected.

References

https://github.com/golang/go/issues/67535

GoRuntimegcResource CleanupAddCleanupSetFinalizer
Radish, Keep Going!
Written by

Radish, Keep Going!

Personal sharing

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.