skip to content

Why does atomic.AddUint64 on a struct field panic on GOARCH=386 but not on amd64?

level: middleimportance: should knowfreq 32%

answer

  1. the target decides the field's alignment
  2. four bytes satisfies the compiler, not the instruction
  3. only the first word of an allocation is promised
  4. put the wide counter first, or pad
  5. the typed atomic carries its own alignment

basics

~20 s

On 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 s

The 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 lines
go
type 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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