Atomics¶
debra/atomics is nim-debra's custom atomics module. It exists alongside —
and intentionally diverges from — std/atomics. This guide explains why
the module exists, what it offers over std/atomics, and when you should
(or shouldn't) reach for it.
If you only ever need 1/2/4/8-byte atomics with sequentially-consistent
ordering and you trust your platform, std/atomics is fine. The rest of
this guide is for callers who need stronger compile-time guarantees,
DWCAS (16-byte CAS), or LCRQ-style algorithms.
1. Why a separate module exists¶
debra/atomics is not a thin wrapper around std/atomics. It is a
parallel implementation that hard-bakes four invariants std/atomics
either lacks or enforces only at runtime. See the module preamble
(src/debra/atomics.nim lines 1–25) for the canonical version of this
list:
- Reject
Atomic[ref T]at compile time.std/atomicssilently accepts a GC-managedrefpayload and falls back to a non-lock-free spinlock object.debra/atomicsrefuses to compile in that case and directs you toAtomic[ptr T]plusretain/releaseDestructorfromdebra/refptr. - Require
Atomic[T]to be lock-free at compile time. Astatic asserton__atomic_always_lock_freefails the build on any target where the underlying op would synthesize a lock. No silent downgrade to a spinlock object. - Validate
MemoryOrderper op at compile time. Passing a release-class order to a pure load (or an acquire-class order to a pure store) is a compile error, not a runtime contract you have to remember. - Statically assert
alignof(Atomic[T]) >= sizeof(T). This catches under-aligned types (e.g.uint64on a 32-bit ABI whose natural alignment is 4 bytes) at the build, not on first cache-line straddle in production.
For the v0.10.0 release, debra/atomics adds a fifth invariant — DWCAS
(16-byte / 128-bit atomics) as Atomic[Pair[A, B]]. That capability
does not exist in std/atomics at all, and is the proximate reason
nim-debra ships its own atomics module rather than re-exporting the
standard one.
2. Side-by-side with std/atomics¶
| Feature | std/atomics |
debra/atomics |
|---|---|---|
Atomic[ref T] |
Silently accepted, becomes a spinlock object | Compile error with remediation hint |
Non-lock-free Atomic[T] |
Allowed, runs as a spinlock object | Compile error (gate 2) |
Bad MemoryOrder per op |
Allowed; std accepts release on a load, etc. | Compile error: validLoadOrder / validStoreOrder / validCasFailureOrder |
Under-aligned Atomic[T] |
Allowed; depends on the natural alignment of T |
Compile error: alignof(Atomic[T]) >= sizeof(T) is asserted |
| 16-byte atomics (DWCAS) | Not supported | Atomic[Pair[A, B]] with load/store/exchange/compareExchange{Strong,Weak} |
| Per-callsite memory-order relaxation | N/A | dwcasOrderRelaxedCAS: template |
| MSVC | Supported | Supported (full Atomic[T] surface + DWCAS via _InterlockedCompareExchange128) |
| 32-bit targets | Supported | Supported for ≤8 bytes; DWCAS rejected at compile time on 32-bit ABIs |
Concrete: the silent-spinlock footgun¶
import std/atomics
type Big = object
data: array[6, uint64] # 48 bytes — way too wide to be lock-free
var x: Atomic[Big] # std/atomics: compiles. Becomes a spinlock object.
# No warning, no error. You get a mutex disguised
# as an atomic, with all the latency that implies.
import debra/atomics
type Big = object
data: array[6, uint64]
var x: Atomic[Big] # debra/atomics: COMPILE ERROR
# "Atomic[Big] is not lock-free on this target..."
# "Pass -d:CacheLineBytes=... if your platform..."
The debra/atomics form fails the build and tells you what to fix. The
std/atomics form ships a hidden contention pile.
3. What you get in v0.10.0 that std/atomics doesn't¶
v0.10.0 adds DWCAS — 16-byte / 128-bit atomic compare-and-swap and the load/store/exchange ops that go with it. The surface is:
| Symbol | Purpose |
|---|---|
Pair[A, B] |
16-byte-aligned object with fields first: A, second: B. The DWCAS cell type. |
Atomic[Pair[A, B]] |
Atomic over a 16-byte pair. Requires a 64-bit target and arch-specific compile flags. |
load / store / exchange |
16-byte ops on Atomic[Pair[A, B]]. Always seq_cst at the instruction level. |
compareExchangeStrong (3 overloads) |
Strong 16-byte CAS. (success, failure), single-order, and default-order forms. |
compareExchangeWeak (3 overloads) |
Weak 16-byte CAS. (success, failure), single-order, and default-order forms. May fail spuriously only on ARMv8.0 LL/SC; equivalent to Strong elsewhere. See §5. |
compareExchange aliases (3 overloads) |
Unsuffixed-name aliases routing to compareExchangeStrong, for std/atomics-style spelling. |
fetchAdd / fetchSub / fetchAnd / fetchOr / fetchXor |
16-byte componentwise atomic RMW on Atomic[Pair[A, B]] where A, B: SomeInteger. Both halves updated as a single 128-bit transaction via a CAS-loop (cmpxchg16b / casp / _InterlockedCompareExchange128 is CAS-only — there is no native 128-bit fetch instruction). |
dwcasOrderRelaxedCAS: |
Template that wraps a single call site to suppress the seq_cst-upgrade warning. |
In addition, v0.10.0 ships CI infrastructure that std/atomics does not
and cannot reasonably provide on its own:
- Cross-compiler macro probe. Before any DWCAS test runs, CI verifies
that the C compiler defines
__GCC_HAVE_SYNC_COMPARE_AND_SWAP_16(or the arch equivalent) on every supported (compiler × OS × arch) cell. objdumpregex check. After compilation, CI disassembles the binary and confirms the emit really inlined tocmpxchg16b(x86_64) orcasp/caspal(arm64). No silent fallback to a library call.- Compile-time error with exact-flag hint. When the build is missing
-mcx16(gcc x86_64) or-march=armv8.1-a+lse -mno-outline-atomics(gcc arm64), the error message names the missing flag — you don't have to triage a generic "lock-free not available" message.
4. When NOT to use debra/atomics¶
Don't reach for this module just because nim-debra uses it. Concrete
"std/atomics + Lock is the simpler answer" cases:
- You don't need lock-free guarantees. A single mutex around a
shared mutable struct is dramatically easier to reason about than a
hand-rolled CAS loop. Reach for
Lockfirst. - You're on a compiler that isn't gcc / clang / llvm_gcc /
nintendoswitch / vcc (MSVC).
debra/atomicserrors out at import time on unsupported toolchains. v0.10.0 adds full MSVC support; the vcc arms route to_InterlockedCompareExchange*for 1/2/4/8 byte ops and_InterlockedCompareExchange128for DWCAS. Other backends (tcc, bcc, etc.) remain unsupported. - You're on a 32-bit ABI and want 16-byte atomics. DWCAS requires a
64-bit target. The 32-bit ABI fails gate 1 (
sizeof(pointer) == 8) at compile time. - You want fetch-and-add on a 16-byte cell where the halves are not
integers. The 16-byte
fetchAdd/fetchSub/fetchAnd/fetchOr/fetchXorops require both halves to beSomeInteger. Floats or pointer halves are not supported (bitwise ops have no meaningful semantics on floats; arithmetic on pointer halves likewise). UsecompareExchange*directly for non-integer pair payloads. - You want a portable
std/atomics-shaped surface across all Nim targets. Usestd/atomics.debra/atomicsis intentionally narrower.
5. Memory-order policy¶
debra/atomics validates memory orders at compile time. The rules:
| Op family | Allowed orders |
|---|---|
load |
moRelaxed, moConsume, moAcquire, moSequentiallyConsistent |
store |
moRelaxed, moRelease, moSequentiallyConsistent |
exchange |
Any |
compareExchange* (success) |
Any |
compareExchange* (failure) |
Must be a load-class order, must not be stronger than success; when success == moRelease, failure MUST be moRelaxed (see below) |
| Any DWCAS op (16-byte) at the instruction level | Always moSequentiallyConsistent regardless of what you pass |
C11-strict failure-order validity¶
Per C11 §7.17.7.4, the failure order of a compareExchange is the load
order taken on the failed CAS path. It must satisfy:
failure != moRelease(failure is a pure load; loads cannot release).failure != moAcquireRelease(same reason).failurecannot be stronger than the load component ofsuccess.
The third rule is stricter than the naive ord(failure) <= ord(success)
comparison. In particular, when success == moRelease, failure MUST be
moRelaxed. moRelease has no load-acquire on the success path, so the
failure path (which is a pure load) cannot acquire either —
success=moRelease, failure=moAcquire is invalid even though
ord(moAcquire) <= ord(moRelease) numerically.
nim-debra v0.10.0 enforces this at compile time via validCasFailureOrder
(src/debra/atomics.nim). The LCRQ producer publish (the canonical
release/relaxed use case) passes moRelease/moRelaxed and is accepted;
mixing moRelease/moAcquire is rejected with a compile-time
assert that names the offending pair.
The DWCAS seq_cst upgrade¶
All 16-byte ops emit seq_cst at the C / machine level. This is a
deliberate choice driven by the gcc-x86_64 __sync_* legacy: those
builtins do not take a memory-order argument and behave as seq_cst.
Rather than expose a per-backend asymmetry (clang and arm64 can take
sub-seq_cst orders), v0.10.0 normalizes to seq_cst everywhere.
When you pass a sub-seq_cst order to a 16-byte op, you get a compile- time warning at every call site:
nim-debra DWCAS upgrades memory order to moSequentiallyConsistent at the
instruction level. Pass moSequentiallyConsistent to silence this warning,
or wrap the call site in `dwcasOrderRelaxedCAS:` if the relaxation is
intentional.
If the relaxation is audited and intentional (notably: the LCRQ producer
publish CAS, which mathematically wants moRelease / moRelaxed), wrap
just that call site:
The warning continues to fire at every unwrapped call site. The wrap is per-site, not per-module — you can't accidentally silence the warning globally.
When to use Strong vs Weak compareExchange¶
The Strong/Weak distinction matters only on ARMv8.0 cores that use the
LL/SC primitives ldaxp / stlxp. On all other supported targets the
two are equivalent in both cost and behaviour:
- x86_64 (gcc and clang). Both
compareExchangeStrongandcompareExchangeWeaklower tocmpxchg16b, which is always-strong. Theweakflag is a no-op. - arm64 with FEAT_LSE / LSE2 (Apple Silicon, modern server chips).
Both lower to
caspal(single-instruction CAS). Theweakflag is a no-op. Verified byobjdumpof a release-mode probe on Apple Silicon: both procs emit identicalcaspalinstructions, noldaxp/stlxppairs are emitted on either path. Empirical measurement under pure contention on Apple Silicon (cycle-13 CI run): thecompareExchangeWeakfailure rate matchescompareExchangeStrongexactly (13.86% real contention loss on the measured workload, 0% spurious-failure delta). The Strong/Weak distinction collapses on this microarchitecture. - arm64 ARMv8.0 (LL/SC fallback, e.g. Raspberry Pi 3, Cortex-A53).
Weak uses
ldaxp/stlxpand is permitted to fail spuriously; Strong wraps the LL/SC pair in a retry loop.
Practical guidance:
- Default to Strong on all targets. It is the safer choice (no spurious-failure branch to handle) and equivalent cost on every target v0.10.0 explicitly supports (CI cells are x86_64 and Apple-Silicon-equivalent ARMv8.1+).
- Use Weak only if you have measured your specific workload and
chip and confirmed (a) Strong is showing CAS-loop overhead from
contention, AND (b) your target is ARMv8.0 LL/SC, AND (c) the
surrounding loop already reloads
expectedon the failure path.
The unsuffixed-name aliases compareExchange route to Strong, matching
the std/atomics default. Weak must be spelled out explicitly.
6. Compatibility matrix¶
Supported compilers¶
| Compiler | Supported | Notes |
|---|---|---|
| gcc | Yes | DWCAS via __sync_val_compare_and_swap on __int128. Requires -mcx16 on x86_64 (shipped via nim.cfg); requires -march=armv8.1-a+lse -mno-outline-atomics on aarch64. |
| clang | Yes | DWCAS via __atomic_compare_exchange_n on __int128. Inlines cmpxchg16b on x86_64 under -mcx16; inlines caspal on aarch64 under default LSE (Apple Silicon native, Linux ARM requires -march=armv8.1-a+lse). |
| llvm_gcc | Yes | Treated as clang-shape (__atomic_* path) |
| nintendoswitch | Accepted | Treated as clang-shape (__atomic_* path) |
| vcc (MSVC) | Yes (x64 and ARM64 seq_cst) | Full Atomic[T] surface via _Interlocked* intrinsics family (<intrin.h>); DWCAS via _InterlockedCompareExchange128. No -m-style flags required — MSVC always emits cmpxchg16b on x86_64 / casp+LL/SC on ARM64. The DWCAS comparand array is declared __declspec(align(16)) __int64 _cmp[2] (load-bearing: MSDN requires 16-byte alignment for _InterlockedCompareExchange128's ComparandResult parameter). DWCAS sites wrap the intrinsic with a full hardware fence before and after to upgrade MSVC's default release-acquire to seq_cst: _mm_mfence() on x64, __dmb(_ARM64_BARRIER_SY) on ARM64. ARM64 Windows is not in the v0.10.0 CI matrix (windows-2022 x64 only) but the dispatch is symmetric. |
Supported architectures (for DWCAS)¶
Dispatch is by compiler, not architecture. Three arms cover both x86_64 and aarch64:
- GCC (any arch):
__sync_val_compare_and_swapon__int128. Required because GCC's__atomic_*_nfamily at 16 bytes silently falls back tolibatomic(__atomic_load_16,__atomic_compare_exchange_16, etc.) on BOTH x86_64 (regardless of-mcx16) AND aarch64 (regardless of LSE flags) — only the legacy__sync_*builtins reliably inline tocmpxchg16b/caspunder GCC. - Clang / llvm_gcc / nintendoswitch (any arch):
__atomic_compare_exchange_non__int128. Clang's__atomic_*family inlines correctly under-mcx16(x86_64) or default LSE (aarch64). - vcc (MSVC) (x86_64 or ARM64):
_InterlockedCompareExchange128from<intrin.h>. The intrinsic is guaranteed lock-free on every Windows-on-AMD64 target (cmpxchg16b is mandatory in the platform ABI since Windows Vista) and Windows-on-ARM64 target (MSVC emitscaspunder FEAT_LSE or LL/SC otherwise). load/store/exchange synthesize from_InterlockedCompareExchange128(no native 128-bit load/store/exchange on MSVC). The 1/2/4/8-byte surface uses the_Interlocked*family directly (_InterlockedExchange*/_InterlockedCompareExchange*/_InterlockedExchangeAdd*/_InterlockedAnd|Or|Xor*); see Phase B insrc/debra/atomics.nimfor the full importc binding table. - Comparand alignment (load-bearing). MSDN requires the
ComparandResultparameter of_InterlockedCompareExchange128to be 16-byte aligned. Each of the 5 DWCAS emit sites declares the local comparand array as__declspec(align(16)) __int64 _cmp[2]; dropping that pragma is undefined behavior per MSDN and would silently miscompare on platforms that enforce alignment (ARM64 in particular). - Full hardware fence wrap for seq_cst. MSVC's
_Interlocked*intrinsics document only release-acquire semantics by default (x86 hardware happens to give seq_cst forlock cmpxchg16b, but the intrinsic's contract is release-acquire). To matchstd/atomicsSequentially Consistent semantics, DWCAS sites bracket the intrinsic with a full hardware fence on both sides of the CAS:_mm_mfence()(from<intrin.h>) on x64,__dmb(_ARM64_BARRIER_SY)on ARM64 Windows. Dispatch is by the MSVC predefine_M_ARM64via a#ifdefinside the emit body._mm_mfencelowers tomfenceon x86;__dmb _SYlowers todmb syon ARM64. Both give full system-domain ordering, lifting the intrinsic from release-acquire to seq_cst on both architectures. - ARM64 Windows CI coverage. ARM64 Windows (Snapdragon X+,
Windows-on-ARM) is not in the v0.10.0 CI matrix (windows-2022 x64
only). The emit dispatch is symmetric (
#ifdef _M_ARM64selects__dmb); the surface is identical to x64 from a callsite perspective. PR welcome on regressions.
| Arch | Supported | Required compiler flag |
|---|---|---|
| x86_64 (amd64) | Yes | -mcx16 (both gcc and clang). MSVC: no flag required (_InterlockedCompareExchange128 is unconditional). Without -mcx16 on gcc/clang, both __sync_* and __atomic_* reject __int128 CAS. |
| arm64 (aarch64) | Yes | -march=armv8.1-a+lse -mno-outline-atomics (gcc); default LSE on Apple Silicon, -march=armv8.1-a+lse on Linux ARM (clang). MSVC: no flag required (_InterlockedCompareExchange128 emits casp/LL+SC as appropriate). |
| 32-bit (i386, armv7) | No | Gate 1 (sizeof(pointer) == 8) fails at compile time |
CI cell coverage¶
| Cell | DWCAS coverage | Notes |
|---|---|---|
| ubuntu-24.04 + gcc + x86_64 | Yes | Macro probe + objdump verify cmpxchg16b |
| ubuntu-24.04 + clang + x86_64 | Yes | Macro probe + objdump verify cmpxchg16b |
| ubuntu-24.04-arm + gcc + arm64 (native) | Yes | Macro probe + objdump verify casp / caspal |
| ubuntu-24.04-arm + clang + arm64 (native) | Yes | Macro probe + objdump verify casp / caspal |
| macos-15 + clang + arm64 | Yes | Macro probe + objdump verify casp / caspal |
| windows-2022 + MSVC + x86_64 | Yes | Full test suite under --cc:vcc; macro probe + objdump steps skipped (MSVC has no __GCC_HAVE_SYNC_COMPARE_AND_SWAP_16 and dumpbin output differs from objdump). The vcc DWCAS path is exercised end-to-end by the test suite. |
| ubuntu-24.04 + TSAN | Yes | Contention tests under thread sanitizer |
| macos-15 + Apple Clang + x86_64 (Rosetta) | Yes | Cross-compiled on arm64 macos-15 with -arch x86_64, executed via Rosetta 2 across all 4 MM × 2 backend cells; otool -tV verifies cmpxchg16b emit + libatomic-fallback regression gate. Restores macOS x86_64 coverage retired when GitHub sunset macos-13 (2025-12-08). |
The cross-compiler macro probe is the gate that fails the build before
any DWCAS code runs if the toolchain doesn't actually have the
underlying capability. It is intentionally redundant with the per-op
_Static_assert inside the emit body — the probe runs once per CI
cell, the static asserts run at every call site.
Regression test: libatomic-fallback detection¶
CI's objdump verification step is a release-blocking gate that fails the
build if any __atomic_*_16 libatomic symbol — or __aarch64_cas16_*
outline-atomics symbol — appears as a call target (either bl <symbol>
or b <symbol> tail-call) in either the DWCAS objdump fixture or the
tests/test_dwcas_roundtrip.nim test binary.
This catches accidental reintroduction of the GCC __atomic_*_n libcall
footgun: at width 16, GCC's generic __atomic_*_n builtins silently
lower to libatomic libcalls instead of inlining the appropriate DWCAS
instruction. The codebase uses the legacy __sync_* builtins on GCC
paths specifically to dodge this — see the atomic128 reference and
PR #14 cycle 12 for the historical incident.
Applied to: ubuntu-24.04 (gcc x86_64), ubuntu-24.04-arm (gcc
aarch64), macos-15 (Apple Clang arm64). Skipped on windows-2022
(MSVC uses the _Interlocked* family, not libatomic).
7. Worked example: LCRQ-style cell¶
The driving use case for DWCAS in v0.10.0 is the LCRQ cell, where a
producer publishes (index, value) atomically and a consumer claims
the same pair atomically. The pair shape is Pair[uint64, T] where
first is the index / generation half and second is the payload.
import debra/atomics
type
CellValue = ptr Node # raw pointer payload; trivially copyable
Cell = object
state {.align: 16.}: Atomic[Pair[uint64, CellValue]]
# Producer: publish (idx, value) only if the cell is still empty
# (i.e. its generation half matches the expected pre-publish state).
proc publish(cell: var Cell, idx: uint64, value: CellValue): bool =
var expected = Pair[uint64, CellValue](first: idx, second: nil)
let desired = Pair[uint64, CellValue](first: idx or 0x1'u64, second: value)
# The producer publish is the one place we genuinely want
# release/relaxed semantics — wrap it to silence the upgrade warning.
dwcasOrderRelaxedCAS:
result = cell.state.compareExchangeStrong(
expected, desired, moRelease, moRelaxed
)
# Consumer: claim (idx, value) atomically by swapping in the
# "claimed" sentinel (high bit set on the generation half).
proc claim(cell: var Cell, idx: uint64): CellValue =
var snapshot = cell.state.load(moAcquire)
while (snapshot.first and 0x1'u64) != 0'u64 and snapshot.first == (idx or 0x1):
var expected = snapshot
let desired = Pair[uint64, CellValue](
first: snapshot.first or 0x8000_0000_0000_0000'u64,
second: snapshot.second,
)
if cell.state.compareExchangeWeak(expected, desired):
return snapshot.second
snapshot = expected # CAS-failure overwrote expected with current value
result = nil
Two things to note about this example:
expectedanddesiredare distinct locals. ThecompareExchange*doc-comments document this as a contract: on CAS failure,expectedis overwritten in place with the current value of the cell. Aliasingexpectedanddesiredis defined-but-confusing on the wider 16-byte surface and the doc-comments call it out explicitly.- The consumer uses
compareExchangeWeakinside the loop. On ARMv8.0 LL/SC cores (no LSE), weak-CAS may fail spuriously (stlxpis permitted to fail without contention). The loop reloadsexpectedfrom the failure path and retries. On x86_64 (cmpxchg16b) and on arm64 with FEAT_LSE / LSE2 (caspal, e.g. Apple Silicon, modern server chips), weak is identical to strong — both lower to the same single instruction and the spurious-failure branch never fires. See §5 "When to use Strong vs Weak" for the full guidance: default to Strong unless you have measured a Weak win on a specific ARMv8.0 target.
For the full LCRQ algorithm see the v0.10.0+ wave specs in the
lockfreequeues project; the cell above is just the DWCAS substrate.
See also¶
docs/api.md— auto-generated API reference for the fulldebra/atomicssurface (signatures, doc-comments, source links).src/debra/atomics.nim— the module preamble at lines 1–25 is the canonical statement of the four (now five) invariants this module enforces overstd/atomics.THIRD_PARTY_LICENSES.md— DWCAS compiler-dispatch pattern adapted from atomic128 by patternnoster (MIT). Pinned upstream commitd45ba3d348a9620a25552f9cf50dc7ccef05ef90.