Skip to content

Incrémenter un compteur de façon atomique entre threads snippet

L'innocent compteur++ est en réalité trois opérations — charger, additionner, stocker — et deux threads qui l'entrelacent perdent des incrémentations.

L'innocent compteur++ est en réalité trois opérations — charger, additionner, stocker — et deux threads qui l'entrelacent perdent des incrémentations. La répartition des langages : certains rendent ++ atomique sur des types spéciaux (types atomiques en Go/Rust/Zig, AtomicInteger sur la JVM), d'autres exigent un verrou autour de variables ordinaires (C/C++, PHP), et certains offrent les deux. Quand le compteur sert aussi de drapeau sur lequel d'autres codes se branchent, les atomiques avec garanties d'ordre mémoire (ou un mutex) sont la seule réponse correcte — les courses de données sont un comportement indéfini dans les langages de la famille C, pas de simples comptes faux.

Recette exécutable · 12 langages
Concurrency & Parallelismatomicconcurrencyrace-conditionthread-safetymemory-orderinglock-free

Every language

12 langages, 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.