skip to content

Why does `go test -race` switch coverage counters to -covermode=atomic?

level: middleimportance: should knowfreq 38%

answer

  1. counters are shared variables
  2. three modes, one header line
  3. plain increment, many goroutines
  4. the detector flags the instrumentation
  5. an atomic add fixes both problems

basics

~20 s

Coverage counters are ordinary shared variables. With set or count mode, two goroutines bumping the same counter is an unsynchronised write that the race detector reports and that loses increments. Atomic mode updates counters atomically, so go test picks it whenever -race is on.

solid answer

~40 s

Coverage works by inserting a counter update at the top of every basic block, and those counters are package-level variables shared by every goroutine. `-covermode=set` writes 1, `-covermode=count` does a plain read-modify-write increment; neither is synchronised. In a test that runs the same code from several goroutines, `count` loses increments, and — more visibly — the race detector flags the counter itself, so your build fails on a race that lives in the instrumentation rather than in your code. `-covermode=atomic` emits an atomic add instead, which is race-free and gives correct counts, at the cost of a slightly slower instrumented binary. That is why `go test`'s default mode is `set` normally but `atomic` when `-race` is enabled, and it is why you should not force `-covermode=count` alongside `-race`.

code

text · 5 lines
text
$ go test -race -coverprofile=cover.out ./store
ok      example.com/m/store     1.204s  coverage: 74.1% of statements

$ head -1 cover.out
mode: atomic

go deeper

for a junior

Know that go test has a -covermode flag with the values set, count and atomic, and that set is the default. You are not expected to reason about counter synchronisation yet.

for a middle

Explain the mechanism: instrumentation inserts a counter update per basic block, those counters are shared across goroutines, and only the atomic mode's update is safe under concurrent execution.

for a senior

Show that you would read the mode header rather than assume it, keep one mode across the pipeline, and resist forcing count under the race detector to shave instrumented-binary overhead.

for a principal

Own the pipeline-wide choice: which mode CI standardises on, whether frequency data is worth the extra cost anywhere, and who is allowed to override it for a one-off investigation.

## The three coverage modes `go test -covermode` takes one of three values, and it decides what the inserted counter does: - **`set`** (the default) — the counter is a boolean: did this block ever run? Cheapest, and enough for a percentage or a red/green HTML report. - **`count`** — the counter is an integer incremented on every execution. You get execution frequency, which `go tool cover -html` renders as shades of green: hotter blocks are darker. Useful for spotting a block that runs once versus a million times. - **`atomic`** — the same integer counter as `count`, but incremented with an atomic add rather than a plain `x++`. The reported percentage is identical in all three modes; only the counter's fidelity and cost differ. The mode is recorded in the first line of the profile (`mode: set`, `mode: count`, `mode: atomic`), which is how `go tool cover` knows how to interpret the last field on every block line. ## Why concurrency breaks the first two modes The instrumentation is source rewriting. A block that looked like ```go if err != nil { return err } ``` becomes, conceptually, a counter update followed by the original statements — and that counter is a package-scoped array element shared by the whole process. Nothing in Go makes a plain assignment or increment safe across goroutines. With `count`, two goroutines executing the same block concurrently perform read-modify-write on the same word. Increments are lost; the recorded frequency is wrong. That alone rarely matters, because you usually only care whether the block ran at all. The consequence that actually bites is the race detector. `-race` instruments memory accesses and reports any pair of conflicting unsynchronised accesses to the same address where at least one is a write. Coverage counters are exactly that: written from every goroutine, with no lock and no atomic. So combining `-race` with `set` or `count` would produce race reports pointing at counter variables in generated coverage code — noise that has nothing to do with the program you are testing, and, because a detected race makes `go test` exit non-zero, a red build. ## What atomic mode changes `atomic` mode replaces the increment with an atomic add on the counter. The race detector understands atomic operations as synchronising accesses, so no report is produced, and no increments are lost, so counts from concurrent tests are exact. The cost is real but small: an atomic add is more expensive than a plain increment, and it is executed at the top of every basic block, so hot loops in an instrumented, race-enabled binary get measurably slower. That is the reason `atomic` is not simply the default everywhere — for the common single-goroutine unit test, `set` is cheaper and answers the same question. ## The rule the toolchain applies `go test` chooses the mode for you: `set` by default, and `atomic` when `-race` is enabled. You can pass `-covermode` explicitly, and the useful case is asking for `count` (or `atomic`) on a run that has no `-race`, when you want frequency data. What you should not do is force `count` on a `-race` run to save a little time: you are re-introducing the unsynchronised counter the default was protecting you from. ## How this shows up in practice A CI job that runs `go test -race -coverprofile=cover.out ./...` will produce a profile whose header reads `mode: atomic` without anyone asking for it. Someone later writes a script that merges or post-processes profiles and assumes `mode: set`; the counters are now frequencies, not booleans, and a naive parser that treats the last field as a boolean still works (non-zero means covered) while a script that sums them gets something quite different. Reading the mode line rather than assuming it is the durable fix. The other practical note: because the mode is per-profile, you cannot meaningfully combine a `set` profile and a `count` profile. Pick one mode for the whole pipeline — in practice, whatever `-race` forces, because most CI suites run with the race detector on.

  • What extra information does -covermode=count give you over set?
    Execution frequency per block instead of a yes/no. The HTML report shades hotter blocks darker green, which is handy for spotting a block executed once by a single test versus one hammered by a loop. The reported percentage is unchanged — the same blocks are covered either way.
  • Does atomic mode change the coverage percentage compared with set mode?
    No. The percentage is the share of statements whose counter is non-zero, and all three modes agree on which blocks ran. Atomic mode only changes how the counter is updated: it makes concurrent updates exact and race-detector-safe, at the cost of a slower instrumented binary.
  • How would a consumer of a coverage profile know which mode produced it?
    The first line of the profile is the mode header — `mode: set`, `mode: count` or `mode: atomic`. Any tool reading the profile should parse that line rather than assume, because the meaning of the final field on every block line depends on it, and because -race silently selects atomic.

saying these in an interview costs you the question

  • Thinks atomic mode makes the tested code thread-safe
  • Forces -covermode=count together with -race to save time
  • Believes atomic mode reports a different percentage
  • Assumes every coverage profile is mode: set
  • Says count mode is simply more accurate, ignoring lost increments