Why does atomic.AddUint64 on a struct field panic on GOARCH=386 but not on amd64?
answer
- the target decides the field's alignment
- four bytes satisfies the compiler, not the instruction
- only the first word of an allocation is promised
- put the wide counter first, or pad
- the typed atomic carries its own alignment
basics
~20 sOn 32-bit targets a 64-bit struct field only needs 4-byte alignment, while the 64-bit atomic functions require an 8-byte-aligned address, so they panic there. On 64-bit targets the field lands aligned anyway, hiding the bug.
solid answer
~50 sThe 64-bit function API in `sync/atomic` — `AddUint64`, `LoadInt64`, `CompareAndSwapInt64` and friends — requires its operand to sit on an 8-byte boundary. On amd64 and arm64 the compiler aligns every 64-bit field to 8 bytes anyway, so nobody notices. On 386, arm and the other 32-bit ports the natural alignment of a 64-bit field is only 4 bytes, so a `uint64` declared after a `uint32` sits at offset 4 and the runtime panics about an unaligned 64-bit atomic operation the first time you touch it. The only thing the package guarantees is that the first word of an allocated struct, array or slice is 64-bit aligned — hence the old advice to put the wide counter first or pad ahead of it. The durable fix is to declare the field as `atomic.Uint64`, whose type carries the alignment for you.
code
go · 14 linestype stats struct {
dropped uint32
sent uint64 // offset 4 on a 32-bit build: not 8-byte aligned
}
func (s *stats) send() { atomic.AddUint64(&s.sent, 1) } // panics on 386 and arm
// portable: the typed atomic carries its own alignment on every target
type stats2 struct {
dropped uint32
sent atomic.Uint64
}
func (s *stats2) send() { s.sent.Add(1) }go deeper
Know that 64-bit atomic operations have an alignment requirement and that declaring the counter as atomic.Uint64 avoids the whole question. Recognising the panic message as an alignment problem, not a race, is enough at this level.
Explain the mechanics: a 64-bit field aligns to 4 bytes on 32-bit ports, the atomic operation needs 8, and the only documented guarantee covers the first word of an allocation. Be ready to state both the field-ordering and typed-atomic fixes.
Show why a field reshuffle is a weak fix and a typed atomic is a structural one, and say how you would surface the failure in CI given that neither -race nor vet reports it.
Frame it as a constraint the whole codebase inherits the moment a 32-bit target is supported: every struct with a counter, every embed, forever. That cost belongs in the decision about which architectures to support at all.
## Two different alignments, and why they only disagree on 32-bit Every Go type has an alignment the compiler must honour when it places a field. On a 64-bit target that alignment for `int64`, `uint64` and `float64` is 8 bytes, so a 64-bit field always lands on an 8-byte boundary no matter what precedes it. On the 32-bit ports — 386, arm, mips, mipsle — the natural alignment of a 64-bit value is only **4** bytes, because the machine word is 4 bytes. A struct laid out as `uint32` then `uint64` therefore puts the wide field at offset 4 on a 32-bit build and at offset 8 on a 64-bit one. Separately, the 64-bit atomic *operations* need an 8-byte-aligned address on those 32-bit ports, because they are implemented with instructions (or fallback paths) that assume it. The runtime checks and panics rather than reading a torn value, so the failure is loud — but only on the target where the two alignments disagree. That is the whole shape of the bug: the amd64 developer machine and the amd64 CI runner will never reproduce it, and the 32-bit device crashes on the first counter update. ## What sync/atomic actually guarantees The package documents exactly one promise for this: *the first word in an allocated struct, array or slice can be relied upon to be 64-bit aligned*. Package-level variables of 64-bit type are aligned by the compiler as well. Everything else is on you. In particular: - A 64-bit field placed after any 32-bit field carries no guarantee on a 32-bit port. - Element *k* of a slice of structs sits at *k* times the struct's size. If that size is not a multiple of 8 — say `uint64` plus `uint32`, which is 12 bytes on a 32-bit port — then every other element is misaligned even though the first one is fine. - Embedding shifts offsets too, so a struct that was safe becomes unsafe when someone embeds it into another one, or adds a field above the counter. This is why "reorder the fields" is a fix with a short shelf life: it is correct today and silently undone by the next refactor. ## The fixes, weakest to strongest 1. **Move the 64-bit field first.** Works, guaranteed by the documented rule, and depends on nobody ever editing the struct again. 2. **Pad explicitly**, e.g. a `_ [4]byte` filler, so the offset is a multiple of 8. Same fragility, plus a comment nobody reads. 3. **Use the typed atomics.** Declaring the field as `atomic.Uint64` (or `atomic.Int64`) makes the type itself responsible: it is defined to be properly aligned on every target, and its methods take a pointer receiver, so there is no raw address for a caller to misalign. This is the fix that survives refactoring, and it also removes the second common bug — a plain read of the field that forgot to go through an atomic operation at all. ## Why the usual tools miss it The race detector instruments memory accesses for happens-before violations; alignment is not a race and `-race` says nothing about it. There is no standard `go vet` check for 64-bit atomic alignment either. Compiling for the target does not help, because the layout is legal — it is only the atomic operation that objects, at runtime, on that target. So the detection story is entirely about execution: run the test suite with a 32-bit `GOARCH` in CI. On an amd64 Linux runner, `GOARCH=386 go test ./...` usually executes directly and reproduces both the 4-byte alignment and the 32-bit `int` width, which makes it the cheapest possible leg for a project that ships 32-bit builds. ## The tell in an interview A candidate who says "Go pads structs to 8 bytes anyway" has the 64-bit target's behaviour memorised as a language rule. A candidate who says "it's a race, add a mutex" has misread the panic. The right answer names the target, names the alignment requirement, and reaches for the typed atomic rather than a field reshuffle.
- Where exactly does sync/atomic promise a 64-bit value is aligned?Only for the first word in an allocated struct, array or slice, plus package-level variables of 64-bit type, which the compiler aligns. A field placed after a 32-bit field, or an element in the middle of a slice of oddly sized structs, carries no promise on a 32-bit port. That is precisely why the documented workaround is to put the 64-bit field first.
- Would the race detector or go vet have caught this before the device did?No. `-race` instruments memory accesses for happens-before violations, not alignment, and there is no standard vet check for 64-bit atomic alignment. The panic only appears when the misaligned operation actually executes on a 32-bit target, so the cheap defence is running the suite under `GOARCH=386` in CI and the structural defence is `atomic.Uint64`.
- The counter lives in a slice element. Does putting the field first make it safe?Only if the struct's size is a multiple of 8. The first element of the allocated slice is 64-bit aligned, but element k sits at k times the struct's size; if that size is 12 bytes on a 32-bit port, every other element is misaligned. Declaring the field as `atomic.Uint64` takes that arithmetic out of your head.
saying these in an interview costs you the question
- Says Go always pads structs to 8-byte boundaries
- Blames a data race for the unaligned-atomic panic
- Thinks the panic depends on load or GOMAXPROCS
- Assumes -race or go vet would have caught it
- Believes swapping uint64 for int64 fixes the alignment
- Fixes it by reordering fields and never revisits the struct