A Go reload goroutine assigns a new *tls.Certificate that GetCertificate reads — why does -race flag it?
answer
- two goroutines, one variable
- how many goroutines run the handshake callback
- a word-sized write is still a race
- publish whole, never mutate after storing
- atomic.Pointer Store and Load
basics
~20 sTwo goroutines touch one variable with no synchronisation: the reloader writes the pointer while handshake goroutines read it. A pointer-sized write is not automatically ordered in Go. Keep the certificate in an atomic.Pointer and Load it inside the callback.
solid answer
~50 s`GetCertificate` runs on the goroutine handling each incoming connection, so at any moment many goroutines may be reading that variable while the reload goroutine writes it. Under the Go memory model an unsynchronised write concurrent with a read is a data race no matter how small the value is — "a pointer write is one instruction" is not a rule Go gives you, and the compiler is free to keep the value in a register or reorder around it. The race detector reports it because it observes the two accesses with no happens-before edge between them. The fix is `atomic.Pointer[tls.Certificate]`: the reload path builds a complete certificate and `Store`s it, the callback does `Load` and returns what it gets. Publish whole values and never mutate a certificate after storing it — a reader may already be holding it. A `sync.RWMutex` is also correct, just heavier for a one-word read-mostly value.
code
go · 14 linesvar cert *tls.Certificate // written by reload, read by every handshake
func reload(certFile, keyFile string) error {
c, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
}
cert = &c // unsynchronised write: -race reports this
return nil
}
func getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return cert, nil // unsynchronised read
}go deeper
Know that the handshake callback runs on many goroutines at once, so the certificate it reads is shared state and a plain assignment from a reload goroutine is not safe.
Explain why size does not save you — the Go memory model, not instruction width, defines the race — and show the atomic.Pointer publish-and-load shape plus the rule against mutating a certificate after storing it.
Demonstrate the test that earns trust: reloads looping in parallel with real handshakes under -race, and a reload path that keeps the old pair serving when parsing fails.
Decide whether every service writes this swap itself or imports one reviewed implementation, and insist the race test lives with that code rather than in forty copies of varying quality.
## What the race actually is Go defines a data race precisely: two goroutines access the same memory location, at least one access is a write, and there is no happens-before relationship ordering them. Nothing in that definition mentions the size of the value. A `*tls.Certificate` is one machine word, and on common hardware the store instruction is indivisible — but indivisibility of a store is not ordering. The compiler may hoist the read out of a loop, keep it in a register across the reload, or reorder it against other memory operations, and the hardware may make the write visible to other cores later than you expect. A racy program has undefined behaviour in Go; it is not "probably fine". The TLS certificate hot-swap hits this squarely because of how the callback is invoked. Each incoming connection is handled on its own goroutine, and each of those goroutines calls `GetCertificate` during its handshake. So a busy server has many concurrent readers by construction. Add one reload goroutine — woken by a timer, a signal, or a watcher — and the racy shape appears the moment somebody writes the obvious code. ```go var cert *tls.Certificate func reload() error { c, err := tls.LoadX509KeyPair("cert.pem", "key.pem") if err != nil { return err } cert = &c // unsynchronised write return nil } ``` ## What the race detector does and does not tell you Building or testing with `-race` instruments memory accesses and maintains happens-before information at runtime. When it sees the reload's write and a handshake goroutine's read with no edge between them, it prints both stacks and the goroutine that created each. That report is authoritative: a reported race is a real one. The converse is not true. The detector only sees accesses that actually execute in that run. A test that reloads once before the server starts serving proves nothing, because the two accesses never overlap. This is why the useful artefact for rotation code is a test that *renews while connections are being served*: start a server, drive handshakes from several goroutines in a loop, and run the reload repeatedly in parallel with them, all under `-race`. That test is what makes a hot-swap defensible in review. ## The fix: publish, do not mutate `atomic.Pointer[T]` gives a typed atomic pointer cell with `Load`, `Store`, `Swap` and `CompareAndSwap`. Reads and writes through it are synchronised, so the race disappears and readers stay lock-free. ```go type reloader struct { cert atomic.Pointer[tls.Certificate] } func (r *reloader) reload(certFile, keyFile string) error { c, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return err // keep serving the previously published pair } r.cert.Store(&c) return nil } func (r *reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { return r.cert.Load(), nil } ``` The discipline that matters as much as the atomic itself is **immutable publication**. Once `Store` has made a `tls.Certificate` visible, treat it as frozen. Do not append to its `Certificate` chain, do not set its `OCSPStaple`, do not swap its `PrivateKey` — a handshake goroutine may be holding that exact pointer and reading those fields right now, and mutating them is a fresh race the atomic does not cover. Build the complete replacement value first, then publish it in one `Store`. Old certificates are collected normally once the last handshake that grabbed one has finished with it. Note also that the failed-reload branch returns without storing. That is deliberate: a truncated file or a key that does not match its certificate should leave the server running on material that still works, not publish a broken pair. ## Alternatives and when they are right A `sync.RWMutex` around a plain field is equally correct: `RLock` in the callback, `Lock` in the reload. It costs more than an atomic load on a read-mostly path and it is easy to widen the critical section accidentally, but if you need to publish *several* related fields together — say a certificate and a matching root pool that must change as a unit — a mutex is the honest tool, because one atomic pointer protects one word, not an invariant across several. The other way to keep the atomic in that case is to put both fields in a struct and publish a pointer to the whole struct. The older `atomic.Value` does the same job without generics, but it panics if you store values of inconsistent concrete types and it cannot hold a nil interface, so the typed `atomic.Pointer[T]` is the cleaner choice where it is available. ## The read the callback returns One subtlety worth stating: the callback returns whatever pointer `Load` produced, and that pointer stays valid for the whole handshake even if a reload publishes a different one microseconds later. Each handshake sees a consistent snapshot; there is no torn state and no need to hold anything for the duration of the connection.
- Would a sync.RWMutex around the certificate field also be correct here?Yes — `RLock` in the callback and `Lock` in the reload is correct. It costs more than an atomic load on a path taken by every handshake, and it is easy to widen the critical section by accident. A mutex earns its place when several fields must change as a unit, since one atomic pointer protects one word, not an invariant across fields.
- What must you never do to a tls.Certificate after publishing it?Mutate any of its fields. Once the pointer is visible, handshake goroutines may be reading `Certificate`, `PrivateKey` or `OCSPStaple` concurrently, and writing them is a data race the atomic pointer does not cover. Build the complete replacement value first, then publish it with a single `Store`.
- A test reloads the key pair while serving connections under -race and passes. What has that proved?Only that the interleavings it actually executed contained no race on the accesses it observed. The detector is a runtime tool, not a static check: it reports races that happen, so the test must genuinely overlap reloads with handshakes — many goroutines driving connections while the reload loops — for the result to mean much.
- What should the reload path do when the new files fail to parse?Return the error without storing anything, so the previously published pair keeps serving, then log and alert. Publishing a half-written file or a key that does not match its certificate would break every subsequent handshake, turning a recoverable renewal glitch into an outage.
saying these in an interview costs you the question
- Says pointer assignment is atomic so there is no race
- Believes one writer and many readers cannot race
- Mutates fields of the published tls.Certificate in place
- Synchronises only the write and leaves the read bare
- Thinks a clean -race run proves the code race-free
- Stores a certificate that failed to parse, wiping the working one