Skip to content

Increment a counter atomically across threads snippet

The innocuous counter++ is three operations — load, add, store — and two threads interleaving it lose increments.

The innocuous counter++ is three operations — load, add, store — and two threads interleaving it lose increments. The language split: some make ++ atomic on special types (atomic types in Go/Rust/Zig, AtomicInteger on the JVM), some need a lock around plain variables (C/C++, PHP), and some give you both. When the counter is also a flag other code branches on, atomics with memory-ordering guarantees (or a mutex) are the only correct answer — data races are undefined behavior in C-family languages, not wrong counts.

Runnable recipe · 12 languages
Concurrency & Parallelismatomicconcurrencyrace-conditionthread-safetymemory-orderinglock-free

Every language

12 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
// single-threaded JS: this is ALWAYS safe — awaits interleave, threads don't:
let n = 0;
async function handler() {
  n++;                 // no other thread can touch n between load and store
  await persist(n);
}

// the multi-worker form — Atomics over a SharedArrayBuffer, not a number:
const shared = new SharedArrayBuffer(4);
const counter = new Int32Array(shared);   // ship `shared` to each worker
// in each worker: Atomics.add(counter, 0, 1)
const total = Atomics.load(counter, 0);   // sees every committed add

JS is single-threaded, so n++ across awaits cannot tear — the trap is assuming workers are too. Atomics.add works only on a SharedArrayBuffer view, never a plain number (postMessage copies; only the shared buffer aliases), and it returns the pre-increment value.

TSTypeScript
const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(shared); // Atomics only works on these views

function hit(): number {
  return Atomics.add(counter, 0, 1) + 1; // returns the PRE-increment value
}

function total(): number {
  return Atomics.load(counter, 0);
}

// the futex side — block until another thread writes the cell:
// Atomics.wait(counter, 0, oldValue, timeoutMs)  // Worker threads only
// Atomics.notify(counter, 0, 1)                  // wake one waiter

The Int32Array-over-shared-memory view IS the type contract — Atomics on anything else fails to compile. Atomics.wait blocks its thread, so it is legal in a Worker and throws on the main thread; notify is how another worker wakes it.

GoGo
import "sync/atomic"

type Counter struct {
	n atomic.Int64 // Go 1.19+ type form — zero value works, no init
}

func (c *Counter) Inc() int64 { return c.n.Add(1) }  // atomic add
func (c *Counter) Get() int64 { return c.n.Load() }  // readers Load too

// pre-1.19 field spelling, still everywhere:
// var n int64
// atomic.AddInt64(&n, 1) // writers
// atomic.LoadInt64(&n)   // readers

atomic.Int64 is the Go 1.19+ type form — zero-value usable, no constructor, but never copy it (pass *Counter). Readers must Load too: a plain read racing an Add is a data race even when counts look right; when counter and flag change together, that is mutex territory.

RsRust
use std::sync::atomic::{AtomicU64, Ordering};

let n = AtomicU64::new(0);

// pure counting: Relaxed — the add is atomic, nothing else is ordered
let before = n.fetch_add(1, Ordering::Relaxed);
let total = n.load(Ordering::Relaxed);

// the counter-as-flag case — order it against OTHER memory:
n.store(42, Ordering::Release);     // producer publishes
let v = n.load(Ordering::Acquire);  // consumer sees everything before it

Relaxed keeps the increment atomic but orders nothing else — exactly right for a pure total; reach for Release/Acquire (or SeqCst) when other variables must be seen consistently around the counter, the flag case. fetch_add returns the previous value.

PHPPHP
// PHP workers share NO memory — two requests never race a PHP variable.
// The race lives in the shared store behind them. Atomic ops belong there:

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$hits = $redis->incr('page:hits'); // one atomic server-side op

// APCu — shared memory across FPM workers on one machine:
$hits = apcu_inc('page:hits');

// the trap: read-modify-write from PHP is two round trips and loses counts:
// $n = $redis->get('page:hits'); $n++; $redis->set('page:hits', $n); // NO

No shared memory between requests means the race is in the store, not the script — INCR/apcu_inc run atomically where the data lives. Get-then-set from PHP is two round trips and loses counts under concurrency, the same lost-update as C's counter++.

PyPython
import threading

count = 0
lock = threading.Lock()

def hit() -> int:
    global count
    with lock:              # += is load/add/store — the GIL does not fuse it
        count += 1
        return count

# when you only need unique ids, not a shared total — skip the cell:
# from itertools import count
# ids = count()             # next(ids) is atomic; nothing shared to guard

The GIL makes += ALMOST safe — a thread can yield between the load and the store — and free-threaded CPython (3.13+ no-GIL builds) deletes the 'almost', making the lock non-optional. itertools.count() removes the shared cell entirely when unique ids are all you need.

C#C#
using System.Threading;

long hits = 0;

long ticket = Interlocked.Increment(ref hits); // returns the new value
long total = Volatile.Read(ref hits);

// the raw CAS every "update only if unchanged" loop is built on:
long seen, next;
do {
    seen = Volatile.Read(ref hits);
    next = seen + 100;
} while (Interlocked.CompareExchange(ref hits, next, seen) != seen);

int++/long++ is never atomic in .NET — Interlocked is the lock-free primitive, and CompareExchange is the raw CAS behind every check-and-update loop (it returns the original value: != seen means you lost the race, retry). On 32-bit runtimes use Interlocked.Read for tear-proof 64-bit loads.

JvJava
import java.util.concurrent.atomic.AtomicLong;

AtomicLong hits = new AtomicLong();
long ticket = hits.incrementAndGet(); // the atomic ++ — returns the NEW value
long total = hits.get();

// high-contention variant — striped cells, summed only on read:
// import java.util.concurrent.atomic.LongAdder;
// LongAdder adder = new LongAdder();
// adder.increment();
// adder.sum();

The JVM makes ++ atomic only on the Atomic* types — a plain long increment races. incrementAndGet vs getAndIncrement is new-value vs old-value. LongAdder stripes the count across cells and sums on read: built for many threads hammering one counter.

SwSwift
// Swift has NO atomic type — OSAtomicIncrement is deprecated. Actors
// serialize access instead; a locked class covers non-async contexts.

actor Counter {
    private var n = 0
    func increment() -> Int { n += 1; return n } // serialized by the actor
    var value: Int { n }
}
// let t = await counter.increment()

final class LockedCounter { // when you cannot await
    private let lock = NSLock()
    private var n = 0
    func increment() -> Int {
        lock.lock(); defer { lock.unlock() }
        n += 1; return n
    }
}

Swift ships no atomic language type and OSAtomicIncrement is deprecated — the language answer is an actor, which serializes every access by construction (each hop is an await). When await is impossible, the manual form is a class with NSLock or os_unfair_lock.

KtKotlin
import java.util.concurrent.atomic.AtomicLong

val hits = AtomicLong()
val ticket = hits.incrementAndGet() // JVM: the same AtomicLong as Java
val total = hits.get()

// multiplatform code that cannot touch java.util.concurrent:
// import kotlin.concurrent.atomic.AtomicInt // Kotlin 2.1+, experimental
// val n = AtomicInt(0)
// n.increment(); n.load()
// kotlinx.atomicfu is the established delegate-based alternative

Same JVM contract as Java — AtomicLong for a shared counter, LongAdder under heavy contention. kotlin.concurrent.atomic (and kotlinx.atomicfu) exist for multiplatform code where java.util.concurrent is off-limits; on the JVM they lower to the same instructions.

RbRuby
require 'thread'

class Counter
  LOCK = Mutex.new

  def self.hit
    LOCK.synchronize { @@count += 1 } # class vars are the shared cell
  end

  def self.total
    LOCK.synchronize { @@count }
  end
end

# lock-free spelling — concurrent-ruby wraps the CAS loop:
# require 'concurrent-ruby'
# hits = Concurrent::AtomicFixnum.new
# hits.increment  # -> the new value; hits.value to read

MRI's GVL is the same almost-safe illusion as Python's GIL — and JRuby/TruffleRuby have no GVL at all. synchronize is the guarantee; Concurrent::AtomicFixnum wraps the CAS loop for you. @@ class variables are the classic shared cell nobody guards.

ZigZig
const std = @import("std");

var counter = std.atomic.Value(u64).init(0);

pub fn hit() void {
    _ = counter.fetchAdd(1, .monotonic); // returns the old value
}

pub fn total() u64 {
    return counter.load(.monotonic); // readers load atomically too
}

// the builtin underneath it all:
// _ = @atomicRmw(u64, &cell, .Add, 1, .monotonic);
// plain `cell +%= 1` on a shared cell is a data race — UB, like C

std.atomic.Value(u64) is the typed cell — plain +%= on a shared variable is a data race, undefined behavior in Zig exactly as in C. Ordering is explicit at every call site: .monotonic for pure counting, .seq_cst when other loads/stores must order against it; @atomicRmw is the builtin layer underneath.