A custom slog.Handler writes to one shared io.Writer; how do you keep concurrent Handle calls from interleaving?
answer
- the logger does no locking for you
- io.Writer promises nothing about goroutines
- one record, one Write call
- WithAttrs copies the struct — what gets copied?
- the lock guards the writer, not the value
basics
~10 sFormat the whole line into a local byte slice, then lock a mutex and issue exactly one Write. Store that mutex as a pointer so every handler derived by WithAttrs shares it.
solid answer
~50 sThe `slog.Handler` contract says a handler must be safe for concurrent use, and `io.Writer` implementations carry no such guarantee, so serialising is your job. The pattern is: build the complete record — level, message, attributes, trailing newline — into a scratch `[]byte` local to the call, then lock, do a single `w.Write(line)`, unlock. Formatting outside the lock keeps the critical section to one write. The detail people miss is where the lock lives: `WithAttrs` and `WithGroup` copy the handler struct, so a `sync.Mutex` stored by value gives every derived handler its own lock, and two loggers derived from the same base can then interleave into the same file descriptor. Store it as a `*sync.Mutex` created once in the constructor and copied by pointer into every derived handler — the standard library's own handlers do exactly this.
code
go · 19 linesfunc (h *handler) Handle(_ context.Context, r slog.Record) error {
line := make([]byte, 0, 256)
line = append(line, r.Level.String()...)
line = append(line, ' ')
line = append(line, r.Message...)
r.Attrs(func(a slog.Attr) bool {
line = append(line, ' ')
line = append(line, a.Key...)
line = append(line, '=')
line = append(line, a.Value.String()...)
return true
})
line = append(line, '\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.w.Write(line)
return err
}go deeper
Know that many goroutines can be inside your Handle at once and that a handler is required to cope. The starting answer is a mutex around the write.
Explain the two-step shape: build the full line locally, then lock and issue one Write. Be ready to say why splitting the record across several Write calls corrupts output.
Show the derived-handler trap — a mutex copied by value gives each child its own lock over one shared writer — and describe a -race test that logs through derived loggers, not just the base one.
Own the decision of whether the shared handler blocks its caller when the sink stalls, and what a background queue would then owe: record cloning, a bounded queue, and a documented drop policy.
## Why this is the handler's problem `slog.Logger` does no locking. It builds a `slog.Record` and calls `Handler.Handle`, and if fifty goroutines log at once, fifty goroutines are inside your `Handle` at once. The interface documentation is explicit that a handler must be safe for concurrent use by multiple goroutines. Meanwhile `io.Writer` promises nothing about concurrency: `Write` on an `os.File` such as the process's standard output happens to reach a single syscall, but a `bytes.Buffer` in a test, or any wrapper someone hands your constructor, is not safe at all. So the handler owns the serialisation. ## Two separate hazards **Interleaved bytes.** If `Handle` writes in pieces — one `Write` for the timestamp, one per attribute, one for the newline — then two concurrent records interleave and you get a line with half of each. Even a correct-looking output format is destroyed. The fix is not more locking around each piece, it is fewer writes: assemble everything into one `[]byte` and write it once. **Concurrent access to the writer itself.** Even with one `Write` per record, two goroutines calling `Write` on the same non-thread-safe writer is a data race in the strict sense; the race detector will report it on a `bytes.Buffer`. That is what the mutex is for. ## The shape ```go func (h *handler) Handle(_ context.Context, r slog.Record) error { line := make([]byte, 0, 256) // ... append level, message, h's stored attrs, then r's attrs ... line = append(line, '\n') h.mu.Lock() defer h.mu.Unlock() _, err := h.w.Write(line) return err } ``` The formatting happens before the lock is taken, so goroutines only queue for the duration of one write. If you format inside the critical section, every logging goroutine in the process serialises behind the slowest attribute conversion, and that turns a logger into a contention point under load. A scratch slice per call is the simple version; a handler that wants to avoid a fresh allocation per record can pull the byte slice from a `sync.Pool` — but keep that decision separate from correctness. The correctness rule is one `Write` per record, under the lock. ## Where the mutex must live This is the subtle part, and it is the one interviews probe. `WithAttrs` and `WithGroup` return new handlers, and the usual implementation is `h2 := *h` — a shallow copy of the struct. Everything that must be *shared* with the parent has to be a pointer, because a shallow copy duplicates values and copies pointers. If the field is `mu sync.Mutex`, the copy has its own independent mutex. Now `base.With("a", 1)` and `base.With("b", 2)` are two handlers, each locking its own mutex, both writing to the same `os.Stdout`. Every record still takes *a* lock, so the code looks right, and it will pass a single-logger test — but under load two derived loggers happily write at the same time and the output is corrupted. Copying a `sync.Mutex` by value is also flagged by `go vet`'s copylocks check when it can see it, which is another reason the pointer form is the idiom. Store `mu *sync.Mutex`, allocate it once in the constructor alongside the writer, and let every derived handler carry the same pointer. Parent and children then share exactly one lock guarding exactly one writer, which is the invariant you actually want: the lock's scope is the *writer*, not the handler value. ## Testing it A table test that logs from several goroutines and then checks that every output line parses is worth having, but the sharper test is to run it under the race detector with `go test -race`: a `bytes.Buffer` sink makes any unsynchronised concurrent write an immediate, reported race. Remember what that proves and what it does not — the detector reports races that actually occurred in that run, so the test has to genuinely exercise concurrent logging through *derived* loggers, not just the base one, or the split-mutex bug goes unseen. ## Related decisions you should not confuse with this one Making `Handle` safe is not the same as making it fast, and it is not the same as making it non-blocking. A handler that writes synchronously under a mutex will block its caller when the sink is slow; a handler that hands the record to a background goroutine does not, but then it must clone anything it retains and must decide what happens when its queue is full. Those are design choices layered on top of the basic requirement, which stands either way: concurrent calls to `Handle` must not corrupt the output.
- Why format outside the critical section rather than writing directly into a locked buffer?Because the lock's only job is to serialise access to the writer. Formatting inside it makes every logging goroutine queue behind attribute conversion, turning the logger into a contention point under load. Building the line locally keeps the critical section to a single Write, so the lock is held for microseconds regardless of how many attributes the record carries.
- What does go vet say about a sync.Mutex stored by value in a handler struct that WithAttrs copies?Its copylocks check reports passing or copying a value containing a sync.Locker when it can see it — `h2 := *h` on a struct with a `sync.Mutex` field is exactly that pattern. It is a useful signal, but not a complete one: the deeper problem is semantic, since the copy silently stops guarding the writer the parent still shares.
- If the handler wraps a writer that is already goroutine-safe, can you drop the mutex?Only if that writer also guarantees each Write lands atomically relative to other writes, and only if you never split a record across writes. That is a promise about a value someone else passes to your constructor, so a library handler cannot rely on it. Keeping the mutex costs one uncontended lock per record.
saying these in an interview costs you the question
- Writes each field with a separate Write call
- Stores the mutex by value so derived handlers each get their own
- Assumes slog serialises Handle calls internally
- Claims any io.Writer is safe for concurrent use
- Holds the lock for the whole formatting pass