skip to content

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

level: seniorimportance: nice to knowfreq 20%

answer

  1. the map must not be a root
  2. Value tells you whether it survived
  3. the key outlives the object
  4. delete on cleanup, under the same lock
  5. check it is still dead before deleting

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.

solid answer

~50 s

`weak.Make(p)` turns a `*Doc` into a `weak.Pointer[Doc]`, a reference the collector ignores when deciding what is live; `p.Value()` gives the `*Doc` back while the object is alive and `nil` once it has been collected. So `map[string]weak.Pointer[Doc]` is a cache that never keeps a large decoded document alive by itself — the callers holding strong pointers do. Two things still need care. The map entries outlive their objects, so keys and dead weak pointers accumulate unless you attach a `runtime.AddCleanup` to each document with its key as the argument and delete the entry when it fires. And that deletion races with a lookup that has just re-decoded the same key, so the cleanup must take the cache's mutex and delete only if the stored weak pointer's `Value()` is still nil. Everything remains best-effort: an entry may outlive its last use by a long way.

code

go · 18 lines
go
type cache struct {
	mu sync.Mutex
	m  map[string]weak.Pointer[Doc]
}

func (c *cache) get(key string) *Doc {
	c.mu.Lock()
	defer c.mu.Unlock()
	if p, ok := c.m[key]; ok {
		if d := p.Value(); d != nil {
			return d
		}
	}
	d := decode(key)
	c.m[key] = weak.Make(d)
	runtime.AddCleanup(d, c.drop, key) // the cleanup gets the key, not the *Doc
	return d
}

go deeper

for a junior

Know the vocabulary: a weak.Pointer is a reference the garbage collector does not count, and asking it for the object may hand you nil because the object is already gone.

for a middle

Explain weak.Make and Value, and why a map holding only weak pointers still grows: the key and the emptied weak pointer stay in the map after the object has been collected.

for a senior

Show the whole design — weak entries plus a keyed cleanup for removal, one mutex covering both paths, and the recheck that stops a late cleanup from evicting a freshly cached object.

for a principal

Own the framing: this is canonicalisation, not capacity management. Decide when a team may use collector timing as a lifetime, and when the answer is a bounded cache with an explicit eviction policy.

## What "weak" means here An ordinary Go pointer is strong: while the collector can reach an object through it, the object is live. A `weak.Pointer[T]`, from the `weak` package, is a reference the collector does not count. Holding one does not keep the object alive, and once the object is collected the weak pointer reports that. The API is two calls. `weak.Make(p)` builds a `weak.Pointer[T]` from an ordinary `*T`. The method `Value()` returns the original `*T` if the object is still alive, and `nil` if it has been collected. `weak.Pointer` values are comparable, so they can themselves be used as map keys — which is what interning machinery such as the standard library's `unique` package is built around. ## The cache design The motivating use is an in-process cache of large decoded objects keyed by an identifier — parsed documents, decoded images, compiled templates — where you want *identity*: everyone asking for `doc-42` at the same moment should get the same object rather than each decoding their own copy. A `map[string]weak.Pointer[Doc]` gives you that without the cache being the reason the documents live. A lookup asks the map for the weak pointer, calls `Value()`, and either hands back the still-live document or decodes a fresh one and stores a new weak pointer. When the last caller drops its strong pointer, the document becomes garbage on the collector's schedule, and the cache does not object. ## The part everyone forgets: the map still grows Collecting the object does not remove the map entry. What remains is a live key and a weak pointer whose `Value()` returns nil — small, but one per distinct key, for the life of the process. On a keyspace that is effectively unbounded (user ids, request ids, file paths) that is a genuine leak, just a slower one than caching the documents themselves. The fix is the pairing this leaf is about: when you store the weak pointer, also attach `runtime.AddCleanup` to the document, with the *key* as the argument, and have the cleanup delete the map entry. The key is a string; it reaches nothing, so the registration does not pin the document — which it would if the cleanup closed over the document or over a struct pointing at it. ## The race the cleanup introduces A cleanup fires an unspecified amount of time after the object died. By then another goroutine may have missed in the cache, decoded a fresh document for the same key, and stored a new weak pointer. If the cleanup deletes blindly, it evicts a live entry — not a correctness bug for a cache, but a mysterious hit-rate hole and, for a canonicalisation cache, a real identity bug: two callers now hold different objects for the same key. The fix is to take the same mutex the lookup takes and delete only if the entry currently stored is still dead, that is, if its `Value()` returns nil. That check is cheap and it is the difference between a design that works and one that works most of the time. The cleanup runs on a runtime-owned goroutine, so it must not block for long: taking a short mutex is fine, doing I/O is not. ## What this design is, and is not It is canonicalisation: one shared instance per key, for as long as anybody wants it. It is not a cache policy. There is no size bound, no eviction order, no hit-rate knob, and the lifetime of an entry is decided by the collector plus whoever happens to hold a strong pointer. A hot key with no live holder is dropped; a cold one pinned by a stray reference is kept. If the requirement is "use no more than N megabytes" or "keep the last N minutes", weak pointers are the wrong tool and a bounded cache with an explicit policy is the right one. It is also best-effort in the same way every runtime hook on this leaf is: nothing forces a collection, so an unreferenced document may sit in memory for a long time, and no entry is ever removed at program exit. ## Testing it The honest test drops the strong reference, calls `runtime.GC()`, and then waits — with a timeout — for evidence that the cleanup ran, such as the map entry disappearing or a channel closing. A timeout is a real failure: it means something still references the document, and the usual culprit is a closure in the cleanup registration itself.

  • Why is a weak-pointer map a poor general-purpose cache?
    Because the collector's schedule is not a cache policy. An entry lives exactly as long as somebody holds a strong pointer, so a hot entry with no live holder is dropped and a cold one pinned by a stray reference is kept. There is no size bound, no eviction order and no hit-rate knob. Use it for canonicalisation, and use a bounded cache when you need memory control.
  • What race does the cleanup that deletes the map entry introduce?
    The cleanup runs some time after the object died, and by then another caller may have decoded a fresh document and stored a new weak pointer under the same key. Deleting blindly evicts the live entry. Take the same mutex the lookup takes and delete only when the stored pointer's `Value()` is still nil.
  • Does weak.Make(p) stop the object p points to from being collected while p is live?
    No, and it does not need to. `p` is an ordinary strong pointer and keeps the object alive on its own for as long as it is live. `weak.Make` only creates an additional reference the collector ignores, so once every strong reference is gone the object can be reclaimed even though the weak pointer still exists.

saying these in an interview costs you the question

  • Treats a weak-pointer map as an eviction policy
  • Assumes weak.Pointer.Value never returns nil
  • Deletes the map entry without rechecking that it is still dead
  • Stores the strong pointer in the map as well
  • Expects the collector to reclaim entries at a predictable time