skip to content

What does runtime.SetFinalizer do in Go, and what does the runtime guarantee about when it runs?

level: juniorimportance: must knowfreq 38%

answer

  1. a hook, not a destructor
  2. the collector decides, you do not
  3. exit does not flush anything
  4. one goroutine runs them all
  5. the object survives an extra cycle

basics

~20 s

runtime.SetFinalizer registers a function the Go runtime may call some time after an object becomes unreachable. The runtime guarantees almost nothing: it runs on a later garbage-collection cycle, or not at all, and never at program exit.

solid answer

~40 s

`runtime.SetFinalizer(obj, f)` attaches `f` to an object: `obj` must be a pointer (from `new`, the address of a composite literal, or the address of a local), and `f` takes one argument that `obj` is assignable to. Some time after a collection cycle finds `obj` unreachable, the runtime queues `f(obj)`, and a single dedicated goroutine runs the queued finalizers one at a time. Everything about the timing is unspecified. Nothing forces a collection, so the finalizer may never become eligible, and when `main` returns or the program calls `os.Exit` the pending queue is simply dropped. Setting a finalizer also delays reclamation, because the object must survive an extra cycle to be handed to `f`. `runtime.SetFinalizer(obj, nil)` clears it. That is why a finalizer is a backstop for a leaked resource, never the release mechanism.

code

go · 10 lines
go
type conn struct{ id int }

func demo() {
	c := &conn{id: 7}
	runtime.SetFinalizer(c, func(c *conn) {
		fmt.Println("finalizing", c.id) // may never run
	})
	c = nil
	runtime.GC() // may queue the finalizer; it still runs asynchronously, if at all
}

go deeper

for a junior

Be ready to say what runtime.SetFinalizer registers and to state plainly that the runtime promises no timing and may never run it. Knowing that resources are released by an explicit Close is the point of the question.

for a middle

Explain the mechanics: a collection cycle must first observe the object unreachable, the object then survives an extra cycle so the function can receive its pointer, and one dedicated goroutine runs every finalizer in turn.

for a senior

Show production judgment. A finalizer is a leak backstop you instrument, not a release path. Be ready to explain why a blocking finalizer stalls all of them, and why file descriptors run out long before heap pressure triggers a cycle.

for a principal

Own the policy question: whether your codebase permits finalizers at all, what you require in their place, and how you detect callers who have quietly come to depend on the backstop instead of releasing resources themselves.

## What the call does `runtime.SetFinalizer(obj, f)` tells the Go runtime: some time after you notice that nothing in the program can reach `obj` any more, call `f(obj)`. `obj` must be a pointer to an object the program allocated — the result of `new`, the address of a composite literal such as `&conn{}`, or the address of a local variable. `f` must be a function taking a single argument that `obj` is assignable to, usually the same pointer type; any values it returns are ignored. Calling `runtime.SetFinalizer(obj, nil)` removes an association made earlier. That is the whole API. Everything difficult about it lives in the words "some time after". ## Why the timing is not yours Go's collector does not count references. It discovers unreachable objects by tracing from roots during a collection cycle, and three consequences follow. **A finalizer cannot become eligible until a cycle runs.** A program that allocates little may go a long time without one; a program run with `GOGC=off` may never run one at all. Unreachability is something the runtime *notices*, not an event it is told about, so "the object died" and "the finalizer became eligible" are separated by an unbounded amount of time. **A finalized object survives an extra cycle.** When a cycle finds the object unreachable, the runtime still cannot free it, because `f` is about to be handed the pointer. The object is kept alive, the call is queued, and only a later cycle reclaims the memory. So attaching a finalizer makes the object's memory come back *later*, not sooner — the exact opposite of the intuition people bring from languages with destructors. **One goroutine runs them all.** The queue is drained by a single dedicated goroutine, sequentially. That keeps the model simple, but it means a finalizer that blocks — waiting on a mutex the program holds, sending on a full channel, making a slow network call — stalls every other finalizer in the process, so all the memory and resources they would have released stay pinned. The documented advice is to keep a finalizer short and, if real work is needed, to start a goroutine from it. And the point candidates miss most often: **nothing runs finalizers at program exit.** When `main` returns, when `os.Exit` is called, when the process is signalled, the queue is abandoned. Data a finalizer would have flushed is lost. A finalizer is not an at-exit hook. ## Ordering, cycles and package-level variables There is no ordering guarantee between finalizers of different objects. Worse, if objects that reference each other in a cycle include one with a finalizer, there is no order that respects the dependencies, and the documented behaviour is that such a cycle is not guaranteed to be collected and its finalizers are not guaranteed to run. That is a silent, permanent leak of both memory and whatever resource the finalizer was meant to release. There is also a subtler caveat: objects allocated in initializers for package-level variables may be allocated by the linker rather than on the heap, and a finalizer is not guaranteed to run for them at all. ## What it is actually good for A finalizer is a *backstop*. Operating-system resources are scarcer than memory: a process typically runs out of file descriptors or sockets long before heap pressure triggers a collection, so relying on the collector to close them fails under exactly the load where you need it most. The right shape is an exported `Close` that the caller invokes, usually with `defer`, and — optionally — a runtime hook that catches the caller who forgot. The standard library takes this shape: an `*os.File` nobody references any more eventually has its descriptor closed, but the documented contract has always been that you call `Close` and check its error, which a finalizer cannot return to anybody. ## What replaced it Go 1.24 added `runtime.AddCleanup`, which registers a function that is *never* handed the object. It removes the resurrection hazard, makes cycles collectable, and lets the object be freed without the extra cycle. It is the recommended mechanism in new code. `runtime.SetFinalizer` still exists and behaves exactly as it always has, so plenty of live code and interview questions are still about it. ## How to answer this in a room State the mechanism in one sentence, then say the four things the runtime does not promise: that a collection will happen, that the finalizer will run promptly, that it will run at exit, and that any particular order will be respected. Finish by naming the consequence: resource release belongs in `Close`.

  • How do you cancel a finalizer you registered earlier?
    Call `runtime.SetFinalizer(obj, nil)` while `obj` is still reachable; that clears the association. Once the object is unreachable it is too late — the queued call is out of your hands. The modern replacement, `runtime.AddCleanup`, returns a `runtime.Cleanup` value whose `Stop` method does the same job more explicitly, and can be called from anywhere holding that value.
  • Why does the runtime run all finalizers on one goroutine, and what breaks if one blocks?
    They are queued and executed sequentially by a single dedicated goroutine, which keeps ordering simple and keeps the work off the application's critical path. The cost is that one finalizer blocking — on a lock the program holds, a full channel, a slow call — stalls every other finalizer in the process, so everything they would have released stays pinned. Keep them short; start a goroutine if there is real work.
  • Does setting a finalizer make the object's memory come back sooner?
    No, it delays it. The cycle that finds the object unreachable cannot free it, because the finalizer still needs the pointer; the object is kept alive, the call is queued, and a later cycle reclaims the memory. A finalized object costs at least two cycles, which is one of the reasons `runtime.AddCleanup` was added.

It is like leaving a note for the building's cleaner. It may be acted on eventually, on somebody else's schedule, and if the building closes first nobody ever reads it.

saying these in an interview costs you the question

  • Calls it a destructor that runs deterministically at scope exit
  • Assumes pending finalizers run when the program exits
  • Thinks setting a finalizer makes memory come back sooner
  • Uses a finalizer as the primary way to close files or sockets
  • Believes calling runtime.GC guarantees the finalizer has finished