A Mutex That Wasn’t Enough: Racing on a Global Logger
Issue
The Go race detector failed CI intermittently on the gateway’s global logger. The first fix was the obvious one: put the write to log.Logger under a mutex in Global().
The race detector kept failing.
Root Cause
The mutex was doing exactly what a mutex does, and it was not enough:
Global()writeslog.Loggerunder the mutex.- Every dashboard goroutine reads
log.Loggervialog.Info()— without taking it.
A mutex creates mutual exclusion only among the accesses that participate. Guarding one side of a write/read pair guards nothing; it just makes the writer feel safe. The data race between Global()’s write and the hundreds of unsynchronised log.Info() reads was untouched.
The honest options were: take the lock on every read — putting a mutex acquisition in front of every log line in the process, on the hot path, for a value that changes at most once — or remove the write/read race by construction.
It surfaced under parallel test gateways rather than in production, because that is where multiple gateway instances initialise concurrently in a single process. Tests were the only workload that ever raced.
Solution
sync.Once.
var once sync.Once
func Global() *Logger {
once.Do(func() { /* build and assign exactly once */ })
return logger
}
The global logger is now set exactly once per process. There is no write-after-read to protect against, so readers need no synchronisation at all and the hot path stays free of lock acquisition. sync.Once also provides the happens-before edge that makes the initialised value visible to every subsequent reader — which a plain “check if nil, then assign” would not.
💡 Takeaway
- A mutex is a protocol, not a property. It protects a variable only if every access takes it. Locking the writer alone is a very convincing no-op.
- Prefer eliminating the race to synchronising it. Write-once initialisation removed the shared mutable state instead of arbitrating access to it — and cost nothing per read.
- Parallel tests are a legitimate concurrency workload. This race only ever appeared in CI. That does not make it a test bug.
- Run the race detector in CI. Nothing here was reachable by reading the code; the detector found it, twice.
