Back to Home

Synchronization in Go: atomics and memory barriers

Analysis of synchronization mechanisms at the processor level: instruction and memory reordering, memory fences, atomic operations. Practical example of implementing atomic.And on Go Assembler.

Low-level synchronization in Go: from CPU to atomics
Advertisement 728x90

Low-Level Synchronization in Go: Atomics, Barriers, and Instruction Reordering

Understanding processor-level synchronization mechanisms isn't an academic luxury—it's a practical necessity for developers building multithreaded Go applications. In this article, we break down how the CPU optimizes code execution, why this breaks expected logic in concurrent programs, and which hardware primitives help restore order.

How the Processor Tricks the Programmer: Reordering and Out-of-Order Execution

When you write x = 1; ready = true, you expect strict sequential order. The processor? Not so much. It reorders independent instructions to fill pipeline stalls. This is called instruction reordering. The goal: maximize utilization of execution units and hide memory latencies.

CPUs use out-of-order execution: instructions run not in program order, but as their operands become ready. For example:

Google AdInline article slot
MOVQ A(SB), AX ; load from memory
MOVQ B(SB), BX ; load from memory
ADDQ DX, CX    ; add registers

The processor can start with ADDQ because the operands are already in registers, while sending loads to the background—they'll take hundreds of cycles. The logical result for the current thread stays the same, but other threads might see something different.

Pipeline stages where chaos emerges:

  • Fetch → Decode → Register Rename → Dispatch → Execute → Commit

Key stage: Dispatch. Here, the scheduler picks instructions with ready operands, ignoring the original order. The Reorder Buffer (ROB) ensures instruction commits to architectural state happen in the correct sequence—but only for the current core.

Google AdInline article slot

Memory Reordering: When the Store Buffer Betrays You

The problem gets worse at the memory level. Writes to RAM aren't instantaneous—they land in the store buffer, then get asynchronously flushed to cache. For one core:

x = 1
ready = true

For another core, it might look like:

ready = true
x = 0

Because the ready=true write could leave the store buffer earlier. This is memory reordering—reordering of memory operations visible to other threads. Classic data race example:

Google AdInline article slot
G1: 
x = 1
ready = true

G2:
if ready {
  print(x) // might print 0!
}

Memory Barriers: MFENCE and Other Tools to Restore Order

Solution: memory fences (memory barriers). These instructions force synchronization of operation visibility between cores. On x86, the main barrier is MFENCE:

MOVQ $1, x(SB)
MFENCE           ; barrier
MOVQ $1, ready(SB)

MFENCE guarantees that all stores before it are visible to all cores before operations after it begin executing. It forbids four types of reordering:

  • Store → Store
  • Load → Load
  • Load → Store
  • Store → Load

Important: x86 has a relatively strong memory model—many reorderings are already hardware-forbidden. But Store→Load is still possible, so MFENCE remains essential for strict synchronization.

Types of barriers on different architectures:

  • x86: MFENCE (full), SFENCE (store→store), LFENCE (load→load)
  • ARM: DMB (Data Memory Barrier), ISH (inner shareable domain)
  • RISC-V: FENCE (flexible, with direction specifiers)

Atomic Operations: High-Level Abstraction over LOCK

Go hides barriers behind atomic operations from the sync/atomic package. Under the hood, they use the LOCK prefix, which makes read-modify-write operations indivisible across all cores. Example XADDQ:

TEXT ·Xadd64(SB), NOSPLIT, $0-24
	MOVQ	ptr+0(FP), BX
	MOVQ	delta+8(FP), AX
	MOVQ	AX, CX
	LOCK
	XADDQ	AX, 0(BX)   ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
	ADDQ	CX, AX
	MOVQ	AX, ret+16(FP)
	RET

LOCK works by locking the cache line in the MESI protocol:

  • Core acquires exclusive rights to the cache line
  • Performs the operation
  • Releases the line—changes become visible to other cores

Atomicity ≠ ordering! An atomic operation guarantees indivisibility but doesn't necessarily create a memory barrier. On x86, simple load/store are atomic but don't provide ordering.

Writing Our Own Atomic AND: Hands-On with Go Assembler

The Go standard library doesn't provide atomic.And. Let's implement it ourselves. Create the file Xand8_x86.s:

#include "textflag.h"

TEXT ·And8(SB), NOSPLIT, $0-9
	MOVQ	ptr+0(FP), BX      // BX = pointer
	MOVB	mask+8(FP), AX     // AX = mask (8 bits)
	LOCK
	ANDB	AX, 0(BX)          // *ptr &= mask
	RET

And the corresponding main.go:

package main

import "C"

func And8(ptr *uint8, mask uint8)

func main() {
	var smth uint8 = 3
	And8(&smth, uint8(2))
	println("Result:", smth) // outputs 2
}

Build and run with go run .—the ASM file will be picked up automatically. Note: we use ANDB with the LOCK prefix, even though x86 has no XAND instruction. This works—LOCK is compatible with basic logical operations.

Key Takeaways

  • Instruction reordering — CPU optimization that changes the execution order of independent instructions to improve performance.
  • Memory reordering — desynchronization of write operation visibility between cores due to the store buffer.
  • Memory fences (barriers) — instructions that forcibly synchronize memory operation order between threads.
  • Atomic operations — indivisible read-modify-write actions, implemented via LOCK and encapsulating barriers.
  • LOCK + ANDB — a working way to implement a bitwise atomic operation missing from the Go standard library.

— Editorial Team

Advertisement 728x90

Read Next