How to Use runtime.SetFinalizer in Go
You are writing a Go package that wraps a C library. The C code allocates a block of memory and hands you a pointer. You wrap that pointer in a Go struct and return it to the caller. The caller uses the struct, then drops it. They expect Go to clean up everything. Go cleans up the struct. It does not clean up the C memory. The memory leaks. You need a safety net. runtime.SetFinalizer attaches a cleanup function to a Go object. The garbage collector runs that function when the object becomes unreachable. It is a mechanism for releasing resources that live outside Go's memory management.
The dead man's switch
Think of a finalizer as a dead man's switch. You set it up when you create the object. If the object is abandoned and the garbage collector finds it, the switch triggers and runs your cleanup code. It is not a destructor. It is a promise that might happen eventually.
The garbage collector is lazy. It only runs when it needs memory. The finalizer runs only when the GC decides to reclaim the object. There is no guarantee of when it runs. There is no guarantee it runs at all if the program exits before the GC gets around to it. Finalizers are for preventing leaks, not for enforcing logic. If your program depends on a finalizer running at a specific time, the design is wrong.
Minimal example
Here is the minimal pattern: create a pointer, attach the finalizer, drop the reference.
package main
import (
"fmt"
"runtime"
)
// Widget holds a resource that needs cleanup.
type Widget struct {
id int
}
// cleanup runs when the GC reclaims the Widget.
func (w *Widget) cleanup() {
// Print to prove the finalizer executed.
fmt.Printf("Finalizer ran for widget %d\n", w.id)
}
func main() {
// Allocate the widget on the heap.
w := &Widget{id: 1}
// Attach cleanup to the pointer w.
// The GC will call cleanup when w is unreachable.
runtime.SetFinalizer(w, (*Widget).cleanup)
// Remove the only reference. w is now garbage.
w = nil
// Force a collection cycle to demonstrate the finalizer.
// In real code, you never call GC() manually.
runtime.GC()
}
The receiver name is usually one or two letters matching the type. (w *Widget) cleanup() is correct. (this *Widget) or (self *Widget) are not Go style. Trust the convention; it makes the code scannable.
What happens under the hood
When you call runtime.SetFinalizer(obj, fn), the runtime records that obj has a finalizer. The garbage collector treats this object specially. When the collector finds that obj is unreachable, it does not reclaim the memory immediately. It removes the finalizer from the object and schedules fn to run. The function executes asynchronously. The memory is reclaimed only after the finalizer completes.
If you call runtime.SetFinalizer on the same object twice, the second call replaces the first. The old finalizer is discarded. This allows you to change cleanup behavior, though it is rare.
The finalizer function must have a pointer receiver. You cannot attach a finalizer to a method with a value receiver. The compiler rejects this with a panic or type error. The signature must be func (p *T) finalizer(). If you pass the wrong type, the runtime panics with runtime.SetFinalizer: invalid second argument. If you pass a non-pointer as the first argument, you get runtime.SetFinalizer: invalid first argument.
Finalizers run on a dedicated goroutine managed by the runtime. If your finalizer blocks, it delays the cleanup of other objects. Keep finalizers fast. Do not perform I/O or acquire locks in a finalizer.
Realistic usage: wrapping C
Real code usually wraps external resources. Here is a C buffer wrapper that uses a finalizer as a safety net.
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"runtime"
"unsafe"
)
// CBuffer wraps a C-allocated buffer.
type CBuffer struct {
ptr unsafe.Pointer
size int
}
// NewCBuffer allocates memory in C and attaches a finalizer.
func NewCBuffer(size int) *CBuffer {
// Allocate memory using C.malloc.
ptr := C.malloc(C.size_t(size))
if ptr == nil {
return nil
}
buf := &CBuffer{ptr: ptr, size: size}
// Attach finalizer to free the C memory.
// The receiver must be a pointer to CBuffer.
runtime.SetFinalizer(buf, (*CBuffer).free)
return buf
}
// free releases the C memory.
func (b *CBuffer) free() {
// C.free expects a void pointer.
C.free(b.ptr)
fmt.Printf("Freed C buffer of size %d\n", b.size)
}
func main() {
buf := NewCBuffer(1024)
// Use buf...
// Drop reference.
buf = nil
runtime.GC()
}
The if ptr == nil { return nil } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. The GC doesn't know C. You have to tell it.
Pitfalls and traps
Finalizers introduce subtle bugs. The most dangerous is resurrection. If the finalizer saves a reference to the object, the object becomes reachable again. The GC sees it is reachable now. It will not run the finalizer a second time. The object stays alive until it is unreachable again, but without a finalizer. The cleanup never happens.
var resurrected *Widget
func (w *Widget) cleanup() {
if resurrected == nil {
// Save reference. The widget is now reachable.
resurrected = w
}
}
This is a bug waiting to happen. Never save a reference to the object inside its finalizer.
Timing is undefined. The finalizer might run seconds or minutes after the object becomes unreachable. If you need deterministic cleanup, use defer or a Close method. Finalizers are for leaks, not for correctness.
If the finalizer panics, the program crashes. The runtime does not recover from panics in finalizers. Wrap risky code in recover if you must, but better yet, keep finalizers simple.
When to use finalizers
Use defer when the cleanup must happen at a specific point in the code flow, such as closing a file at the end of a function. Use a Close method when the resource lifecycle is managed by the caller and deterministic release is required. Use runtime.SetFinalizer when you are wrapping an external resource and want to prevent leaks if the caller forgets to clean up. Use plain value semantics when the object holds no external state and the garbage collector can handle all memory.
Explicit cleanup beats implicit magic.