Skip to content

Is total += n safe on two threads?

Level: 101 · anyone who expected adding numbers to be the one thing threads cannot get wrong

One line: No. total += n is three steps — load the total, add, store it back — and when two handlers interleave those steps, one addition overwrites the other, so ten threads that each add 1 can leave a total of 1; safe Rust refuses to compile the plain version, yet every language, Rust included, runs a version whose every load and every store is atomic and still loses nine additions of ten.

The question

A server adds up the numbers its clients send. Integer addition is commutative and associative, so the order in which the numbers arrive cannot change the final sum — provided that every number is applied exactly once, and that each one is applied as a single indivisible step. Whether the clients send synchronously or asynchronously changes neither of those. The server does: one that handles a request at a time keeps every number however its clients behave, and one that handles requests concurrently runs total += n in several places at once.

That line is three steps:

  1. load the total,
  2. add n to what was loaded,
  3. store the result back.

Two handlers, one adding 5 and one adding 10, can take those steps in this order:

Handler A, adding 5 Handler B, adding 10 total afterwards
loads 0 0
loads 0 0
stores 0 + 5 5
stores 0 + 10 10

B's store is computed from a total that A has since changed, so A's 5 is gone. Nothing crashes and nothing reports an error; the total is simply 10 — and 15 on a run where A's store happens to come before B's load. This is a lost update.

Forcing the interleaving

A race that loses an addition on one run in a thousand cannot be an answer key. So every program below forces the bad order: ten handlers each load the total, wait at a barrier until all ten have loaded, and then each stores what it loaded plus 1. Every load comes before every store, on every run, and the total is always 1. Without the barrier, at the end of the page, shows the same bug when nothing forces it.

The programs also make each single load and each single store atomic, with an AtomicU64, an atomic.Int64, an atomic_long, a std::atomic<long> and an AtomicLong. That keeps the C and C++ programs free of undefined behaviour and lets the Rust program compile — and it is the second half of the lesson: operations that are each atomic do not make a sequence of them atomic.

Load, wait for all ten, then store The plain version: total += 1 on a shared variable
Rust total 1, in safe Rust does not compile: error[E0499]
Go total 1, and -race reports nothing compiles; -race prints WARNING: DATA RACE and exits 66
C total 1 compiles; a data race, which is undefined behaviour
C++ total 1 compiles; a data race, which is undefined behaviour
Java total 1 compiles, runs, and loses additions
Python total 1 — and 1 again with no second thread, under asyncio runs, and loses additions once a function call sits between the load and the store

Rust

Verified output of lost_update_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

10 handlers each added 1 to a total that started at 0
the totals they loaded: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
the total is 1, not 10

Barrier::new(10) makes wait() return only once ten threads are waiting in it. All ten handlers loaded 0, and all ten stored 1.

Verified output of lost_update_plain_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ rustc --edition 2024 plain.rs
error[E0499]: cannot borrow `total` as mutable more than once at a time
rustc exit status 1
no binary was built

The plain version — ten scoped threads each running total += 1 on a let mut integer — never runs. Each closure needs a mutable borrow of total, and Rust allows only one mutable borrow at a time; ten threads holding one at once would be a data race, and that is what the rule rules out. The compiler knows nothing about lost updates, though. The atomic version has no data race, so it compiles, and it loses nine additions of ten. The Rustonomicon's Data races and race conditions ↗ draws the same line: safe Rust guarantees the absence of the first, and cannot prevent the second. The Rust library's Send and Sync explains how the compiler decides what may be shared with another thread at all.

lost_update_rs.rs

lost_update_rs.rs in full — pasted here by tools/run_examples.py from the file CI runs.

//! The lost update, forced. `total += 1` is three steps -- load, add, store --
//! and nothing stops ten threads from all loading the same old total. A Barrier
//! holds every thread between its load and its store, so the interleaving that
//! loses updates happens on every run instead of one run in a thousand.
//!
//! Each load and each store is atomic, and this is safe Rust. The pair is not.
//!
//!   rustc --edition 2024 lost_update_rs.rs -o lost_update_rs && ./lost_update_rs

use std::sync::Barrier;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

const HANDLERS: usize = 10;

fn main() {
    let total = AtomicU64::new(0);
    let all_loaded = Barrier::new(HANDLERS);

    let loaded: Vec<u64> = thread::scope(|s| {
        let handlers: Vec<_> = (0..HANDLERS)
            .map(|_| {
                s.spawn(|| {
                    let seen = total.load(Ordering::SeqCst); // 1. load
                    all_loaded.wait(); // every handler has loaded
                    total.store(seen + 1, Ordering::SeqCst); // 2. add, 3. store
                    seen
                })
            })
            .collect();
        handlers.into_iter().map(|h| h.join().unwrap()).collect()
    });

    println!("{HANDLERS} handlers each added 1 to a total that started at 0");
    println!("the totals they loaded: {loaded:?}");
    println!("the total is {}, not {HANDLERS}", total.load(Ordering::SeqCst));
}

Go

Verified output of lost_update_go.go — regenerated by tools/run_examples.py, never hand-typed.

10 handlers each added 1 to a total that started at 0
the totals they loaded: [0 0 0 0 0 0 0 0 0 0]
the total is 1, not 10

Each goroutine sends what it loaded on loads, then blocks receiving from allLoaded. main receives all ten loads and only then closes allLoaded; a receive from a closed channel returns at once, so the close releases all ten together.

Verified output of lost_update_race_go_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build -race -o plain plain.go
$ ./plain
the total is 1, not 10
the race detector printed WARNING: DATA RACE
exit status 66
$ go build -race -o atomic atomic.go
$ ./atomic
the total is 1, not 10
the race detector printed nothing
exit status 0

Go's race detector watches memory accesses as the program runs and reports two goroutines using the same variable with nothing ordering them. With a plain int64 in place of the atomic.Int64, it reports a data race and the program exits 66. With the atomic, every single access is synchronized, so the detector has nothing to report — and the total is still 1. A race detector finds data races, and this lost update is built entirely from correctly synchronized pieces. The Go library's The race detector ↗ goes further, including a run on which a racy binary exits 0 because the race did not happen that time.

lost_update_go.go

lost_update_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// The lost update, forced. `total++` is three steps -- load, add, store -- and
// nothing stops ten goroutines from all loading the same old total. Each one
// reports its load on a channel and then waits for a second channel to close,
// so every load happens before any store, on every run.
//
// Each Load and each Store is atomic, so `go run -race` has no data race to
// report here. The pair is not atomic.
//
//  go build lost_update_go.go && ./lost_update_go
package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

const handlers = 10

func main() {
    var total atomic.Int64
    loads := make(chan int64)
    allLoaded := make(chan struct{})

    var wg sync.WaitGroup
    for range handlers {
        wg.Go(func() {
            seen := total.Load() // 1. load
            loads <- seen
            <-allLoaded           // every handler has loaded
            total.Store(seen + 1) // 2. add, 3. store
        })
    }

    loaded := make([]int64, 0, handlers)
    for range handlers {
        loaded = append(loaded, <-loads)
    }
    close(allLoaded)
    wg.Wait()

    fmt.Printf("%d handlers each added 1 to a total that started at 0\n", handlers)
    fmt.Println("the totals they loaded:", loaded)
    fmt.Printf("the total is %d, not %d\n", total.Load(), handlers)
}

C

Verified output of lost_update_c.c — regenerated by tools/run_examples.py, never hand-typed.

10 handlers each added 1 to a total that started at 0
the totals they loaded: 0 0 0 0 0 0 0 0 0 0
the total is 1, not 10

POSIX has a pthread_barrier_t, but macOS does not provide one, so the program builds its barrier from a mutex and a condition variable: each thread counts itself in and waits until the count reaches ten. (Not machine-checked here.)

atomic_long, atomic_load and atomic_store come from C11's <stdatomic.h>, and each load and store is one indivisible operation. With a plain long, the program would have a data race, and a data race in C is undefined behaviour ↗: the standard then places no requirement at all on what the program does, which rules it out as an answer key.

lost_update_c.c

lost_update_c.c in full — pasted here by tools/run_examples.py from the file CI runs.

/* The lost update, forced. `total += 1` is three steps -- load, add, store --
 * and nothing stops ten threads from all loading the same old total. A gate
 * made of a mutex and a condition variable holds every thread between its load
 * and its store, so every load happens before any store, on every run.
 *
 * `total` is an atomic_long, so each load and each store is one indivisible
 * operation and the program has no data race: its behaviour is defined. The
 * pair of operations is still not atomic.
 *
 *   cc -std=c17 -Wall -Wextra -pedantic -pthread lost_update_c.c -o lost_update_c && ./lost_update_c
 */
#define _POSIX_C_SOURCE 200809L

#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

enum { HANDLERS = 10 };

static atomic_long total;

static pthread_mutex_t gate_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t gate_opened = PTHREAD_COND_INITIALIZER;
static int loaded_so_far;

/* Return only once all HANDLERS threads have called this: a hand-made barrier. */
static void wait_until_all_have_loaded(void) {
    pthread_mutex_lock(&gate_lock);
    loaded_so_far++;
    if (loaded_so_far == HANDLERS) {
        pthread_cond_broadcast(&gate_opened);
    }
    while (loaded_so_far < HANDLERS) {
        pthread_cond_wait(&gate_opened, &gate_lock);
    }
    pthread_mutex_unlock(&gate_lock);
}

static void *handle(void *arg) {
    long *seen = arg;
    *seen = atomic_load(&total);     /* 1. load */
    wait_until_all_have_loaded();    /* every handler has loaded */
    atomic_store(&total, *seen + 1); /* 2. add, 3. store */
    return NULL;
}

int main(void) {
    pthread_t threads[HANDLERS];
    long loaded[HANDLERS];
    for (int i = 0; i < HANDLERS; i++) {
        pthread_create(&threads[i], NULL, handle, &loaded[i]);
    }
    for (int i = 0; i < HANDLERS; i++) {
        pthread_join(threads[i], NULL);
    }

    printf("%d handlers each added 1 to a total that started at 0\n", HANDLERS);
    printf("the totals they loaded:");
    for (int i = 0; i < HANDLERS; i++) {
        printf(" %ld", loaded[i]);
    }
    printf("\nthe total is %ld, not %d\n", atomic_load(&total), HANDLERS);
    return 0;
}

C++

Verified output of lost_update_cpp.cpp — regenerated by tools/run_examples.py, never hand-typed.

10 handlers each added 1 to a total that started at 0
the totals they loaded: 0 0 0 0 0 0 0 0 0 0
the total is 1, not 10

std::latch, new in C++20, counts down: each thread calls count_down() after its load and then wait(), which returns once the count has reached zero. Each std::jthread joins in its destructor, so the end of the block waits for all ten. A data race on a plain long is undefined behaviour ↗ in C++ as in C.

lost_update_cpp.cpp

lost_update_cpp.cpp in full — pasted here by tools/run_examples.py from the file CI runs.

// The lost update, forced. `total += 1` is three steps -- load, add, store --
// and nothing stops ten threads from all loading the same old total. A
// std::latch holds every thread between its load and its store, so every load
// happens before any store, on every run.
//
// `total` is a std::atomic<long>, so each load and each store is indivisible
// and the program has no data race. The pair of operations is not atomic.
//
//   c++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread lost_update_cpp.cpp -o lost_update_cpp && ./lost_update_cpp

#include <array>
#include <atomic>
#include <cstddef>
#include <iostream>
#include <latch>
#include <thread>
#include <vector>

constexpr std::size_t handlers = 10;

int main() {
    std::atomic<long> total{0};
    std::latch all_loaded{handlers};
    std::array<long, handlers> loaded{};

    {
        std::vector<std::jthread> threads;
        for (std::size_t i = 0; i < handlers; ++i) {
            threads.emplace_back([&, i] {
                long seen = total.load();  // 1. load
                loaded[i] = seen;
                all_loaded.count_down();
                all_loaded.wait();         // every handler has loaded
                total.store(seen + 1);     // 2. add, 3. store
            });
        }
    } // each std::jthread joins as it is destroyed

    std::cout << handlers << " handlers each added 1 to a total that started at 0\n";
    std::cout << "the totals they loaded:";
    for (long seen : loaded) {
        std::cout << ' ' << seen;
    }
    std::cout << "\nthe total is " << total.load() << ", not " << handlers << "\n";
}

Java

Verified output of lost_update_java.java — regenerated by tools/run_examples.py, never hand-typed.

10 handlers each added 1 to a total that started at 0
the totals they loaded: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
the total is 1, not 10

CyclicBarrier's await() returns once ten threads are waiting. It throws two checked exceptions, which the lambda given to start may not throw, hence the small helper. With a plain long field in place of the AtomicLong, the program still compiles and runs, and Without the barrier shows that version losing additions.

lost_update_java.java

lost_update_java.java in full — pasted here by tools/run_examples.py from the file CI runs.

// The lost update, forced. `total += 1` is three steps -- load, add, store --
// and nothing stops ten threads from all loading the same old total. A
// CyclicBarrier holds every thread between its load and its store, so every
// load happens before any store, on every run.
//
// `total` is an AtomicLong, so each get() and each set() is atomic. The pair is
// not.
//
//   java lost_update_java.java      (Java 25: a compact source file, which imports java.base)

final int HANDLERS = 10;

void main() throws InterruptedException {
    AtomicLong total = new AtomicLong();
    CyclicBarrier allLoaded = new CyclicBarrier(HANDLERS);
    long[] loaded = new long[HANDLERS];

    List<Thread> handlers = new ArrayList<>();
    for (int i = 0; i < HANDLERS; i++) {
        int slot = i;
        handlers.add(Thread.ofPlatform().start(() -> {
            long seen = total.get();  // 1. load
            loaded[slot] = seen;
            awaitAll(allLoaded);      // every handler has loaded
            total.set(seen + 1);      // 2. add, 3. store
        }));
    }
    for (Thread handler : handlers) {
        handler.join();
    }

    IO.println(HANDLERS + " handlers each added 1 to a total that started at 0");
    IO.println("the totals they loaded: " + Arrays.toString(loaded));
    IO.println("the total is " + total.get() + ", not " + HANDLERS);
}

void awaitAll(CyclicBarrier barrier) {
    try {
        barrier.await();
    } catch (InterruptedException | BrokenBarrierException e) {
        throw new IllegalStateException(e);
    }
}

Python

Verified output of lost_update_py.py — regenerated by tools/run_examples.py, never hand-typed.

10 handlers each added 1 to a total that started at 0
the totals they loaded: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
the total is 1, not 10

threading.Barrier(10) does the holding. seen = total and total = seen + 1 are separate statements with the barrier between them, so a plain module-level int loses the updates as surely as the atomics did.

Verified output of lost_update_asyncio_py.py — regenerated by tools/run_examples.py, never hand-typed.

10 handlers, add_without_await: the total is 10
10 handlers, add_with_await_between: the total is 1

The second program has no second thread. asyncio runs every handler on one thread and moves from one to another only where a handler awaits. The handler with nothing to await between its load and its store keeps all ten additions; the one that awaits between them loses nine, exactly as the threads did. asyncio.sleep(0) waits for no time at all — it stands in for the database query, the HTTP call or the log write that sits between the read and the write in a real handler.

This is the answer to but I used async, not threads. A single-threaded event loop cannot have a data race, and it does nothing about an update lost across an await.

lost_update_py.py and lost_update_asyncio_py.py

lost_update_py.py in full — pasted here by tools/run_examples.py from the file CI runs.

"""The lost update, forced. `total += 1` is three steps -- load, add, store --
and nothing stops ten threads from all loading the same old total. A Barrier
holds every thread between its load and its store, so every load happens before
any store, on every run.

    python3 lost_update_py.py
"""

import threading

HANDLERS = 10

total = 0
all_loaded = threading.Barrier(HANDLERS)
loaded = [None] * HANDLERS


def handle(slot: int) -> None:
    global total
    seen = total  # 1. load
    loaded[slot] = seen
    all_loaded.wait()  # every handler has loaded
    total = seen + 1  # 2. add, 3. store


threads = [threading.Thread(target=handle, args=(i,)) for i in range(HANDLERS)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"{HANDLERS} handlers each added 1 to a total that started at 0")
print("the totals they loaded:", loaded)
print(f"the total is {total}, not {HANDLERS}")

lost_update_asyncio_py.py in full — pasted here by tools/run_examples.py from the file CI runs.

"""The lost update with no second thread. An asyncio program runs every handler
on one thread, and a handler is never interrupted between two lines -- only at
an `await`. So a handler that loads, adds and stores with no `await` in between
keeps every update, and one that awaits between its load and its store loses
them, exactly as the threads did.

`asyncio.sleep(0)` waits for no time at all. It is the plainest possible
`await`, and it stands in for the database call or the log write that sits
between a load and a store in a real handler.

    python3 lost_update_asyncio_py.py
"""

import asyncio

HANDLERS = 10

total = 0


async def add_without_await() -> None:
    global total
    seen = total  # 1. load
    total = seen + 1  # 2. add, 3. store


async def add_with_await_between() -> None:
    global total
    seen = total  # 1. load
    await asyncio.sleep(0)  # the event loop runs the other handlers here
    total = seen + 1  # 2. add, 3. store


async def main() -> None:
    global total
    for handler in (add_without_await, add_with_await_between):
        total = 0
        await asyncio.gather(*(handler() for _ in range(HANDLERS)))
        print(f"{HANDLERS} handlers, {handler.__name__}: the total is {total}")


asyncio.run(main())

Without the barrier

Take the barrier away and let four threads each add 1 a million times, with nothing coordinating them. How many additions are lost is exactly what an answer key may not record, so here it is as real runs — five of each, from demo/unforced.sh, which builds each program the way the runner builds an example:

Real runs — rustc 1.98.0, go1.25.5, Apple clang 21.0.0, OpenJDK 25.0.4.1, Python 3.14.7; x86-64 Mac, 12 hardware threads, macOS 26; 5 runs each, 2026-09-14
additions lost per run, out of 4,000,000 (Python: 800,000)
Rust    load(), then store(load + 1)           2679754   2492334   2716252   2712742   2345325
Go      total++                                2874681   2736159   2907351   2874666   2870733
C       total += 1, built without -O           2541068   2954206   3009775   2795474   2965646
C++     total += 1, built with -O2                   0         0         0         0         0
Java    total += 1                             2329496   2158253   2781497   2140704   2753537
Python  total += 1                                   0         0         0         0         0
Python  total = total + one()                   290621     30620    191020     72452    165651

Rust, Go, C and Java lost more than half of four million additions, and a different number on every run. Two rows lost nothing, and neither is safe:

  • C++ built with -O2 lost nothing in these runs. The optimizer is free to turn a loop of a million += 1s into far fewer loads and stores, which leaves almost nothing to interleave — and a data race is still undefined behaviour, so the next compiler or the next change to the loop can behave differently. The Rust library's Data races ↗ shows the same bug in C going quiet when the optimizer is turned on.
  • Python's total += 1 lost nothing, and total = total + one() lost tens or hundreds of thousands. CPython with its global interpreter lock hands the lock to another thread only at certain points in the bytecode. A function call is one of them; the loop around a bare += evidently offered none between its load and its store. Which points those are is an implementation detail that has changed between versions, and a free-threaded build has no global lock to hand over at all. (Not machine-checked here.) The forced program above loses the update on every run, whatever the build.

To run it yourself, from the lesson folder: bash demo/unforced.sh, or bash demo/unforced.sh 20 for twenty runs of each.

What to do

  • Do not decide that an operation is too small to race. Ask whether another handler's store can fall between a load and a store of the same shared value. +=, ++, total = total + f() and a SELECT followed by an UPDATE all have that gap.
  • An atomic load and an atomic store are not an atomic add. Use the one-step operation — fetch_add, Add, atomic_fetch_add, addAndGet — or hold a lock across all three steps, as the next lesson does.
  • In async code, every await between a read and a write is a place where another handler runs.
  • A clean run under a race detector is not proof that no update can be lost — only that no data race happened on that run.

See also