Finalizers and runtime.AddCleanup
Finalizers and runtime.AddCleanup
Overview
Go is GC-managed, but some resources are outside the heap (OS FDs, cgo memory). Historically runtime.SetFinalizer ran a function when an object became unreachable. Modern Go prefers runtime.AddCleanup (and explicit Close) because finalizers are subtle and easy to get wrong.
Diagram: Lifecycle preference
flow:
[Backstop] --timing not guaranteed--> [Explicit]
Prefer Explicit Lifecycle
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // always first choiceFinalizers are a backstop, not a primary API.
SetFinalizer (Legacy Pattern)
runtime.SetFinalizer(obj, func(o *T) {
// runs asynchronously when obj is unreachable
o.release()
})Problems:
- No guarantee on timing — may run late or, in edge cases, not before process exit.
- Resurrection — finalizer can store the object globally and keep it alive.
- Order — interdependent objects need careful design.
- Performance — finalizers delay reclaim and add GC bookkeeping.
AddCleanup (Preferred Direction)
// Go 1.24+ style cleanup (check your version docs)
runtime.AddCleanup(ptr, func(res Resource) {
res.Release()
}, resourceToken)Cleanups are designed to avoid several classic finalizer pitfalls (exact API surface evolves — verify on pkg.go.dev/runtime).
Rules of Thumb
| Need | Approach |
|---|---|
| Files, sockets, body.Close | defer Close |
| Cache with eviction | Explicit pool / LRU |
| C malloc companion | runtime.SetFinalizer only if library requires; prefer free in Close |
| Tests for leaks | testing + explicit asserts, not finalizer timing |
Experiment
go mod init example
go run .package main
import (
"fmt"
"runtime"
"time"
)
type blob struct {
id int
}
func main() {
for i := 0; i < 3; i++ {
b := &blob{id: i}
id := i
runtime.SetFinalizer(b, func(_ *blob) {
fmt.Println("finalizer", id)
})
b = nil
}
runtime.GC()
time.Sleep(50 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
fmt.Println("done")
}What to notice: Finalizers may print after GC, asynchronously; never rely on them for correctness of security-sensitive cleanup without also having an explicit path.
Try next: Wrap an os.File only with Close; use GODEBUG=gctrace=1 while allocating to see GC activity separately from finalizers.