Go's sync/atomic 64-Bit Alignment Trap: The Counter That Panics on 32-Bit ARM

2026-09-05

This code tracks cache stats using atomic counters. It runs flawlessly for months in CI, staging, and on every developer's x86-64 laptop. Then the ops team deploys it to a fleet of 32-bit ARM edge devices — and it crashes on the first request.

package cache

import "sync/atomic"

type Stats struct {
    lastLabel byte
    hits      int64
    misses    int64
}

func (s *Stats) Hit()  { atomic.AddInt64(&s.hits, 1) }
func (s *Stats) Miss() { atomic.AddInt64(&s.misses, 1) }

func (s *Stats) Snapshot() (int64, int64) {
    return atomic.LoadInt64(&s.hits), atomic.LoadInt64(&s.misses)
}

// Called from many goroutines
func Track(s *Stats, hit bool) {
    if hit {
        s.Hit()
    } else {
        s.Miss()
    }
}

The Bug

On 32-bit ARM (and 386, and 32-bit MIPS), the program panics:

panic: unaligned 64-bit atomic operation

The culprit is the single innocent byte at the top of the struct. Here's why:

Go's sync/atomic functions like AddInt64 require the target address to be 8-byte aligned. On 64-bit platforms, the compiler naturally aligns int64 fields to 8 bytes, so this is free. On 32-bit platforms, the compiler only guarantees 4-byte alignment for int64 fields — because the natural word size is 4.

With lastLabel byte sitting first, hits lands at offset 4 (after 3 bytes of padding), not 8. The atomic instruction traps on the misaligned load, and Go's runtime turns that trap into a panic.

The documented guarantee is narrow: "the first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned." That's it. Anything after a smaller field is on its own.

Worst of all, this passes every test on your developer machine and every CI runner, because they're all 64-bit. It only manifests on the target hardware.

The Fix

Either put int64 atomic fields first in the struct so they inherit the struct's leading alignment, or — better — use the wrapper types added in Go 1.19, which handle alignment internally and prevent accidental non-atomic reads:

type Stats struct {
    hits      atomic.Int64  // self-aligned, self-documenting
    misses    atomic.Int64
    lastLabel byte
}

func (s *Stats) Hit()  { s.hits.Add(1) }
func (s *Stats) Miss() { s.misses.Add(1) }

func (s *Stats) Snapshot() (int64, int64) {
    return s.hits.Load(), s.misses.Load()
}

The atomic.Int64 type contains a hidden noCopy and an aligned internal field. You can no longer accidentally read s.hits without going through Load(), and the alignment is a property of the type itself — not the struct layout.

If you're stuck on an older Go version, order matters: put every atomically-accessed int64/uint64/float64 field at the top of the struct, before any smaller fields. Add a comment, because a future refactor that "cleans up field ordering by size" will silently re-introduce the crash.

Key Takeaway: On 32-bit platforms, sync/atomic on 64-bit values requires 8-byte alignment that the compiler doesn't guarantee for interior struct fields — use atomic.Int64, or place such fields first in the struct.

All newsletters