skip to content

When should you publish an expvar.Func instead of using expvar.NewInt or expvar.NewMap?

level: middleimportance: should knowfreq 32%

answer

  1. stored value versus computed value
  2. who pays, the writer or the reader
  3. one of them runs on every scrape
  4. a gauge nobody has to maintain
  5. the body executes inside a request

basics

~20 s

Use expvar.NewInt or expvar.NewMap when your code already knows the number and can add to it as work happens. Use expvar.Func when the value is better computed on demand: it stores nothing and runs on every read of the endpoint.

solid answer

~50 s

`expvar.NewInt` and `expvar.NewMap` create stored variables in the global registry; the hot path calls `Add` on them, which is cheap and safe from many goroutines, and the handler just reads the current value. `expvar.Func` stores no value at all — it wraps a `func() any` that is called each time the variable is rendered, and whatever it returns is JSON-encoded into the response. So counters (jobs done, jobs by kind) belong in `NewInt` and `NewMap`, while derived gauges (current queue depth, seconds since the last successful job) belong in a `Func`, because nothing then has to keep them up to date. The price is that a `Func` body runs inside a request: it must be fast, non-blocking and safe for concurrent use, and it must return something the JSON encoder can handle, since the encoding error is discarded rather than reported.

code

go · 17 lines
go
var (
	jobsDone = expvar.NewInt("worker.jobs_done")
	byKind   = expvar.NewMap("worker.jobs_by_kind")
	lastOK   atomic.Int64 // unix seconds of the last success
)

func init() {
	expvar.Publish("worker.seconds_since_success", expvar.Func(func() any {
		return time.Now().Unix() - lastOK.Load()
	}))
}

func finish(kind string) {
	jobsDone.Add(1)
	byKind.Add(kind, 1)
	lastOK.Store(time.Now().Unix())
}

go deeper

for a junior

Know the two shapes: expvar.NewInt hands you a counter you call Add on, while expvar.Func gives back a value computed at the moment someone reads it.

for a middle

Explain that Func stores nothing and runs per scrape, that Int and Map are atomic and cheap in the hot path, and that Publish panics on a duplicate name.

for a senior

Show judgment about what runs inside the handler: bound the work in a Func, never block on a contended lock, and make sure the returned value can actually be encoded.

for a principal

Treat published names as an interface other people's dashboards depend on; decide the prefix convention and who is allowed to add to a global registry that cannot be undone.

## Two ways to get a number out of a process Every value exposed by `expvar` is a `Var`, an interface with a single `String() string` method that must return a valid JSON value. The package ships several implementations, and the interesting split is between the ones that **store** a value and the one that **computes** it. ### Stored: NewInt and NewMap ```go var jobsDone = expvar.NewInt("worker.jobs_done") var byKind = expvar.NewMap("worker.jobs_by_kind") ``` `expvar.NewInt` creates an `*expvar.Int`, publishes it under that name, and returns it so your code can mutate it. `*Int` has `Add(delta int64)`, `Set(value int64)` and `Value() int64`, all implemented with atomic operations, so calling `Add(1)` from many goroutines is safe and costs almost nothing. `expvar.NewMap` does the same for an `*expvar.Map`, a concurrent map of name to `Var` with an `Add(key string, delta int64)` for counting by category. The writer pays: work happens, and the code that did the work increments the number. The reader pays nothing but a read of the current value. One sharp edge in `Map.Add`: if the key does not exist it creates an `*Int` for you, but if the key already holds some other kind of `Var` — a string you set earlier, say — the add is silently ignored. Nothing errors; the number simply never moves. ### Computed: expvar.Func ```go expvar.Publish("worker.queue_depth", expvar.Func(func() any { return q.Len() })) ``` `expvar.Func` is a function type whose `String()` calls the function and JSON-encodes the result. It holds no value between calls. The function runs once per render of that variable — that is, on every request to the endpoint — and never otherwise. There is no cache, no sampling interval and no background goroutine. That makes `Func` the right tool for anything you can ask for cheaply at the moment of reading: the current length of a queue, the number of items in a cache, the age of the last success, a boolean-ish readiness flag. Maintaining those as stored counters would mean writing code on every state change to keep a mirror correct, and a mirror is a thing that can drift. ## Choosing between them Ask who is in a better position to know the number. - **The writer knows it, incrementally.** Totals — jobs processed, messages published, retries attempted — are naturally counted where they happen. Use `NewInt` or `NewMap`. Reading a stored value is free, so a monitoring loop polling every ten seconds costs nothing. - **The reader can ask for it.** Point-in-time gauges are naturally derived. Use `Func`. The cost moves to the reader, which is fine when scrapes are rare and the computation is a length or a subtraction. The cost model is the whole tradeoff. A stored counter charges the hot path once per event; a `Func` charges the endpoint once per scrape. If events outnumber scrapes by orders of magnitude — the usual case — keeping the hot path atomic and the endpoint slightly busier is the right trade. ## Three failure modes worth knowing **A slow Func.** The body runs inside the HTTP handler. If it takes a mutex the hot path also takes, or waits on anything external, a scrape can stall a request and, worse, contend with real work. Keep it to reading an atomic, taking a length, or subtracting two timestamps. **An unencodable return.** `Func`'s `String()` marshals the returned value and discards the error. Return something that cannot be encoded — a channel, a function, a map with non-string keys, a struct containing one — and the variable emits an empty string in the middle of the document, producing JSON that no parser will accept. A malformed response from an unrelated variable is a confusing thing to debug at 3am. **A duplicate name.** `expvar.Publish` refuses a name that is already registered: it panics. Since publishing normally happens in `init` or in package-level variable initialisation, two packages in one binary choosing the same name kill the process at startup rather than at first request. There is no namespacing and no way to unpublish, so names in a shared binary are effectively a contract; a package prefix such as `worker.` is the usual defence. ## Writing your own Var Anything with `String() string` returning valid JSON can be published, so a custom type is an option when neither shape fits — a struct rendered as a JSON object, for instance. The handler concatenates each variable's output into one object without validating it, which is exactly why the requirement is *valid JSON value*, not *human-readable text*.

  • What happens if you call expvar.Publish twice with the same name?
    It panics. The registry refuses a reused name rather than overwriting or renaming. Because publishing usually happens in `init` or package-level variable initialisation, a collision between two packages in one binary kills the process at startup rather than at first request — which is why a package-name prefix on every published name is the usual convention.
  • What does a type need to satisfy expvar.Var?
    One method, `String() string`, returning a valid JSON *value* — a number, a quoted string, an array or an object. The handler splices each variable's output into one JSON object without validating it, so a `Var` that returns bare unquoted text produces a response no JSON parser will accept.
  • Is expvar.Map.Add safe from many goroutines, and what if the key already holds something that is not an Int?
    It is safe for concurrent use. `Add` creates an `*expvar.Int` for a key that does not exist yet, then adds to it. If the key already holds a different kind of `Var`, the add is silently dropped: no error, no panic, the number simply never moves. Mixing `Set` and `Add` on one key is how people hit it.

saying these in an interview costs you the question

  • Thinks expvar.Func caches its value between requests
  • Maintains a gauge with a ticker goroutine instead of a Func
  • Puts a slow or blocking call inside an expvar.Func body
  • Assumes a duplicate Publish name overwrites the first
  • Expects an encoding failure inside a Func to be reported