skip to content

Garbage Collector

Go's concurrent, non-generational, non-moving collector: tri-colour marking, span-oriented since Go 1.26, the write barrier that protects it, and the pacer deciding when a cycle runs.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

26

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
open as a page

With GOGC=100, what heap size does Go's garbage collector set as its next goal?

level: juniorimportance: must knowfreq 55%

basics

~20 s

GOGC is a growth percentage, not a size. At the default GOGC=100 the runtime sets the next heap goal at roughly double the live heap the last cycle measured: 200 MB live gives a goal near 400 MB.

open as a page

Does Go's garbage collector move objects in memory or use generations?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Go's garbage collector is a concurrent tri-colour mark-and-sweep collector that is neither generational nor moving. A heap object keeps the same address for its whole life, and there are no young or old generations to promote between.

open as a page

A Go daemon's RSS stays at 900 MB while gctrace reports a 60 MB live heap after a scrape spike — how do you tell a leak from retained memory?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Track the live-heap figure in gctrace across many cycles, not one sample. Flat while resident memory stays high means the runtime is holding freed spans it has not given back. A real leak makes that figure climb steadily.

open as a page

In Go, what is a garbage-collection mark assist, and which goroutine pays for it?

level: juniorimportance: should knowfreq 36%

basics

~20 s

A mark assist is garbage-collection marking work the Go runtime makes an allocating goroutine do itself. During a collection cycle, allocating memory charges your own goroutine some scanning, so the busiest allocators pay for the collector's backlog.

open as a page

Go's garbage collector marks concurrently — which phases of a GC cycle still stop the world, and what happens inside them?

level: juniorimportance: should knowfreq 52%

basics

~20 s

A Go GC cycle stops the world twice, briefly: once to finish the previous cycle's sweep and switch the write barrier on, and once at mark termination to end marking, switch the barrier off and flush per-P caches.

open as a page

In runtime.MemStats, how do HeapAlloc, HeapInuse, HeapIdle and HeapReleased differ?

level: middleimportance: should knowfreq 40%

basics

~20 s

HeapAlloc counts bytes in heap objects, including garbage not yet swept. HeapInuse counts bytes in spans holding at least one object, HeapIdle bytes in spans holding none, and HeapReleased the idle part already given back to the operating system.

open as a page

In a gctrace line, what do `42->44->21 MB` and the `3%` field mean?

level: middleimportance: should knowfreq 45%

basics

~20 s

They are the heap size when the cycle started, the heap size when it ended, and the live heap that survived marking. The percentage is the cumulative share of the program's CPU time spent collecting since start-up.

open as a page

What does runtime.AddCleanup fix that made runtime.SetFinalizer error-prone?

level: middleimportance: should knowfreq 32%

basics

~20 s

runtime.AddCleanup never hands the object to the cleanup function. That removes resurrection, keeps reference cycles collectable, and frees the memory without the extra collection cycle a finalizer forces. It also allows several cleanups per object.

open as a page

Why does a runtime.AddCleanup cleanup never run when its closure captures the pointer it was attached to?

level: middleimportance: should knowfreq 26%

basics

~20 s

The runtime holds the cleanup function and its argument until the cleanup runs, so capturing the pointer keeps the object permanently reachable. It never becomes garbage, so its memory is never freed and the cleanup can never fire.

open as a page

How does Go's runtime decide how much marking work an allocating goroutine owes as a mark assist?

level: middleimportance: should knowfreq 42%

basics

~20 s

The collector sets an assist ratio of scan work per byte allocated. Every allocation debits the goroutine's assist credit; when that credit goes negative the goroutine must scan off the debt before its allocation proceeds, or park until credit arrives.

open as a page

How does GOMEMLIMIT change the heap goal Go's GC pacer computes, and why is it soft?

level: middleimportance: should knowfreq 40%

basics

~20 s

GOMEMLIMIT is a soft ceiling on the memory the Go runtime manages. The pacer computes a goal that keeps total runtime memory under it, then uses the smaller of that and the GOGC goal, so cycles grow more frequent near the ceiling.

open as a page

Why does Go's GC pacer start a cycle before the heap reaches the goal GOGC sets?

level: middleimportance: should knowfreq 44%

basics

~20 s

Marking runs concurrently while the program keeps allocating, so Go's pacer triggers early enough that marking finishes just as the heap reaches the goal. Triggering late overshoots the goal; triggering early wastes CPU collecting a half-full heap.

open as a page

After Go's garbage collector finishes marking, when is unreachable memory actually reclaimed?

level: middleimportance: should knowfreq 38%

basics

~20 s

Go sweeps lazily. Marking only decides what is live; spans are swept afterwards, mostly by goroutines that are allocating, so a dead object's slot becomes reusable when its span is swept, and it stays inside the process heap.

open as a page

Why does Go's write barrier shade both the pointer being overwritten and the pointer being stored?

level: middleimportance: should knowfreq 36%

basics

~20 s

Because either half alone leaves a hole. Shading the newly stored pointer stops an object being smuggled into memory the marker has already finished with; shading the overwritten pointer keeps anything that was reachable at the moment of the write, which is what lets Go scan each goroutine stack once and never re-scan it.

open as a page

A Go RPC service has p99 spikes and runtime.gcAssistAlloc frames in its CPU profile — what is happening?

level: seniorimportance: should knowfreq 48%

basics

~20 s

The service allocates faster than background marking can keep up, so allocating goroutines are charged mark-assist work inline. The bill lands on whichever request is allocating at that moment, which shows as tail latency rather than a slower average.

open as a page

How do you confirm Go's garbage collector freed an object your program was still using?

level: seniorimportance: should knowfreq 26%

basics

~20 s

Assume the program hid the pointer, not that the collector lost it. Reproduce under GODEBUG=clobberfree=1 to make the use-after-free loud, then GODEBUG=gccheckmark=1: if it never panics, marking was complete and your unsafe or cgo code dropped the last Go pointer.

open as a page

Should a Go library's resource contract be an explicit Close, or a runtime.AddCleanup backstop?

level: principalimportance: should knowfreq 22%

basics

~20 s

Make an explicit Close the contract: it is the only deterministic release and the only one callers can test. Add a runtime.AddCleanup backstop only for scarce resources, count every time it fires, and never document it as a guarantee.

open as a page

What does running a Go binary with GODEBUG=gctrace=1 print, and where does that output go?

level: juniorimportance: nice to knowfreq 28%

basics

~20 s

GODEBUG=gctrace=1 makes the Go runtime write one summary line to standard error after every completed garbage-collection cycle. No rebuild and no code change are needed; the runtime reads the variable from the environment when the process starts.

open as a page

In Go's 25% GC CPU target, how do dedicated, fractional and idle mark workers differ?

level: middleimportance: nice to knowfreq 24%

basics

~20 s

Go targets about 25% of GOMAXPROCS for background marking. Whole units become dedicated workers that hold a P for the entire mark phase; the leftover fraction becomes a fractional worker that yields when its goal is met; idle workers use otherwise-idle Ps.

open as a page

In Go, which writes actually go through the GC write barrier, and when is it switched on?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

Only pointer-typed stores into memory the collector traces — heap object fields and package-level variables. Writes of pointer-free data and writes to locals on a goroutine's own stack get nothing. The barrier does real work only during the mark phase, between the cycle's two pauses.

open as a page

Why does runtime.KeepAlive exist, and what bug does it prevent in a wrapper holding a descriptor?

level: seniorimportance: nice to knowfreq 17%

basics

~20 s

runtime.KeepAlive marks a value as still in use at that line. Without it, an object can be collected as soon as its last field read, letting a registered cleanup close the descriptor while a call still uses it.

open as a page

How do weak.Pointer and runtime.AddCleanup let a Go cache hold entries without keeping them alive?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

A map of weak.Pointer values lets the collector reclaim a cached object once no strong reference remains, and Value then returns nil. A runtime.AddCleanup keyed by the map key deletes the dead entry so the map itself does not grow.

open as a page

A Go controller with GOMEMLIMIT set runs GC cycles back to back as its live heap grows - why?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

The live heap has grown until it nearly fills GOMEMLIMIT, leaving almost no headroom between live memory and the limit-derived goal. Each cycle reclaims little and the next triggers at once, so GC CPU climbs while throughput collapses.

open as a page

What did Go 1.26's Green Tea garbage collector change, and what stayed the same?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Green Tea, on by default since Go 1.26, changes how the marker scans memory: span by span for better locality instead of chasing objects one pointer hop at a time. The collector stays concurrent, tri-colour, non-generational and non-moving.

open as a page

Go's write barrier slows an in-memory index service that rewrites a pointer-heavy tree, but only while marking — how do you confirm that and reduce it?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

Confirm by showing the dip lines up with collection cycles, then disassemble the update path and count the barriered pointer stores per update. Reduce it by rewriting fewer links per update and by replacing child pointers with indices into one node slice, which removes both the barrier and the marker's scan work.

open as a page