skip to content

A Go library caches record addresses in a map[uint64]uintptr and crashes randomly; how do you diagnose and fix it?

level: seniorimportance: should knowfreq 35%

answer

  1. the collector follows pointer words only
  2. a stored address is only a snapshot
  3. make the collector run far more often
  4. -race switches on more than races
  5. cache the offset, not the address

basics

~10 s

A uintptr is not a reference, so once the cache holds the only copy of an address the record can be collected and its memory reused. Store a typed pointer or unsafe.Pointer instead.

solid answer

~50 s

The cache stores addresses as integers, and the garbage collector does not scan integers. As soon as the `uintptr` is the only thing referring to a record, the record is unreachable and can be reclaimed and the memory handed to something else, so converting the number back later is a use-after-free. It is also an invalid conversion by the documented `unsafe` rules — a `uintptr` may only become a `Pointer` again within the expression that produced it. To confirm it, run the package's tests with `-race` so checkptr instrumentation validates each conversion, force collection aggressively with a low `GOGC` or an explicit `runtime.GC()` in the test, and run `go vet`, whose `unsafeptr` check flags this shape statically. Fix it by storing `*record` or `unsafe.Pointer` — both are real references — and, if the records live in a mapping, holding that mapping in the same struct and calling `runtime.KeepAlive` so it cannot be released while a handed-out pointer is live.

code

go · 11 lines
go
type cache struct {
	hot map[uint64]uintptr // BUG: the collector sees no reference here
}

func (c *cache) put(id uint64, r *record) {
	c.hot[id] = uintptr(unsafe.Pointer(r))
}

func (c *cache) get(id uint64) *record {
	return (*record)(unsafe.Pointer(c.hot[id])) // may already be freed
}

go deeper

for a junior

Take away the rule rather than the diagnosis: storing an address as a number does not stop Go from reclaiming what it points at.

for a middle

Explain why the collector cannot see the reference — it follows pointer-typed words using the compiler's type information — and why storing an unsafe.Pointer or a typed pointer fixes it.

for a senior

Show the full diagnosis: grep for the unsafe import, run the tests with -race for checkptr, force collection with a low GOGC or an explicit runtime.GC(), read go vet's unsafeptr report, then fix by storing a real reference and keeping the mapping alive deliberately.

for a principal

Argue the design change rather than the patch: an index library should hand out bounds-checked offsets, and a package that hands raw pointers into a mapping across an API boundary makes every caller responsible for a lifetime rule they cannot see.

## Reading the symptom The report is a search-index library that mostly works and occasionally returns a record with a nonsense score, or dies with a fault at an address that is not mapped, or trips the runtime into complaining about a bad pointer while it is scanning the heap. The crashes cluster under load and never reproduce in a unit test. Everything in that description points at a dangling pointer, and there is exactly one place in a Go program where a dangling pointer can be manufactured: an `unsafe.Pointer` conversion. So the first move is not a debugger, it is `grep` for the `unsafe` import in that module, and then read every conversion in the packages that have it. ## Why the cache is the bug ``` type cache struct { hot map[uint64]uintptr } ``` The map's values are integers. The collector builds its view of the heap by following pointer-typed words, using the type information the compiler recorded for every object; a `uintptr` word is data, and it is skipped. So the moment the last real pointer to a record goes out of scope, the record is unreachable regardless of what the cache holds, and the next cycle can reclaim it. The allocator hands the memory to something else; the fields you read afterwards are whatever that something else wrote. This is also a straightforward violation of the `unsafe` package's documented rules. Converting a `Pointer` to a `uintptr` is permitted; converting a `uintptr` back to a `Pointer` is permitted only inside the expression that produced it, with only arithmetic in between. A `uintptr` that has been stored in a map, a struct field or a global has, by definition, left that expression. If the records live on a goroutine stack rather than the heap, there is a second failure on top: stacks are copied when they grow, every real pointer into them is rewritten during the copy, and a stored integer is not, so the cached address can be stale even though nothing was freed. ## Making it reproduce A use-after-free is a race against the collector, so the diagnosis is to lose that race deliberately. - **Build the tests with `-race`.** Beyond data races, this switches on the compiler's checkptr instrumentation, which validates `unsafe.Pointer` conversions and pointer arithmetic at runtime and aborts on a conversion whose result does not lie in a valid allocation. That turns a silent misread into a stack trace pointing straight at the accessor. - **Collect aggressively.** Run the failing test with a very low `GOGC` value, or call `runtime.GC()` between the store and the lookup in a focused test. What was a one-in-a-million window becomes deterministic. - **Run `go vet`.** Its `unsafeptr` analyzer reports likely invalid conversions of `uintptr` to `unsafe.Pointer`, which is precisely this shape, and it costs nothing to wire into CI. - **Read the fault, not just the trace.** A fault at a small or wildly out-of-range address, or a runtime abort raised while the collector is scanning, tells you the pointer was bad before it was used — a different story from a nil dereference in ordinary Go code. ## The fix Store something the runtime tracks: ``` type cache struct { hot map[uint64]*record } ``` A `*record` (or an `unsafe.Pointer`, which is equally a pointer to the runtime) keeps the record reachable and is rewritten if the memory it points into ever moves. The cache now does what a cache is supposed to do: hold its entries alive. When the records are not Go objects at all — pointers into a mapped file — the reachability problem moves one level out. The pointers must not outlive the mapping. Keep the mapping itself in the reader struct so it is reachable for as long as the reader is, make releasing it an explicit `Close` rather than something attached to collection, and where a hand-off would otherwise let the reader become unreachable while a derived pointer is still in use, call `runtime.KeepAlive` on the reader after the last use of that pointer. The better structural answer for an index library is often to cache the *offset* rather than the address. An offset is a number that means something on its own, it survives remapping the file, it can be bounds-checked, and it makes the cache impossible to get wrong in this particular way. You pay one addition per lookup. ## The rule to take away A `uintptr` is a snapshot of where something used to be. It is safe to print and safe to compare, and the moment you plan to convert it back to a pointer at some later time, you have written a use-after-free that will pass code review because it looks like arithmetic.

  • Would converting the cached uintptr back inside a single expression fix this?
    No, and it is the tempting wrong answer. The single-expression rule protects an address while you do arithmetic on it; it cannot resurrect an object that became unreachable minutes earlier when the cache was written. The value stored has to be a real reference.
  • How do you decide between runtime.KeepAlive and just holding the mapping in a struct field?
    Prefer the field: it is structural, it survives refactoring, and no reader has to reason about liveness. Reach for `runtime.KeepAlive` only at the narrow points where the owner would otherwise become unreachable while a derived pointer is still in use, and comment why it is there so nobody deletes it as dead code.
  • Why does caching the offset instead of the address remove the whole class of bug?
    Because an offset is a plain number that means something on its own terms. It can be bounds-checked against the current region, it stays correct if the file is remapped at a different address, and it never claims to be a reference — so there is nothing for the collector to be wrong about.
  • Does the race detector itself find this bug?
    Not as a data race — nothing here is concurrent. What helps is that `-race` also turns on checkptr instrumentation, which validates unsafe.Pointer conversions. Pair that with an aggressive GOGC setting or an explicit runtime.GC() so the object is actually gone by the time the cache is read.

saying these in an interview costs you the question

  • Blames the crash on a data race and reaches only for a mutex
  • Thinks a map value of type uintptr keeps its object alive
  • Proposes a finalizer to keep the record from being collected
  • Says the single-expression rule alone makes the cache legal
  • Cannot say what -race adds beyond race detection here