Blog

State, Ownership and Concurrency Models

Locks, atomics, channels, ownership and actors compared on one counter in C#, Java, Go and Rust, measured; what a data race really is, which bugs each model makes impossible, and which it quietly keeps.

Concurrency is not about running code at the same time. It’s about state: which piece of code may touch which data, and when. Every model in this part is an answer to that one question. Locks say “one at a time, and you must remember to ask”. Atomics say “this single operation is indivisible”. Channels and actors say “one owner, everyone else sends messages”. Rust’s ownership says “the compiler will check who may touch it”.

We’ll run the same tiny program, a shared counter, under each model in all four languages and measure it. Then we’ll look at what each model makes impossible, what it leaves to you, and what studies of real concurrency bugs say about which mistakes people actually make.

Try this first

Four threads each add 1 to the same counter, 200,000 times, with no locking:

var count int64
var wg sync.WaitGroup
for w := 0; w < 4; w++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0; i < 200_000; i++ {
			count++
		}
	}()
}
wg.Wait()
fmt.Println(count)

The total should be 800,000. Write down what you think it will be, and whether it changes between runs.

What actually happened

We ran that program five times in Go, Java and C#. Not one run reached 800,000, and no two runs agreed:

Language Final count over five runs, out of 800,000
Go 460,078, 229,950, 314,687, 220,741, 491,609
Java 258,655, 323,090, 241,994, 292,928, 305,407
C# 414,498, 431,128, 464,529, 446,910, 439,336

Nothing crashed. Nothing logged. The counter was just wrong, by a different amount each time.

count++ is not one step. It’s three: read the value, add one, write it back. Two threads can read the same value, add one to it, and write back the same result, and one of the two increments disappears.

two threads run count = count + 1 at the same time thread A counter in memory thread B 0 1. Both threads read the counter: both see 0 2. Each adds 1 in its own register: both hold 1 3. Thread A writes 1 back 4. Thread B writes 1 back. Two increments, one counted

A read-modify-write is three steps, and another thread can run between them. This is an atomicity violation: each step is fine, the sequence isn’t.

That’s a data race: two goroutines, threads or tasks touching the same memory at the same time, with at least one of them writing, and no synchronization between them. The Go memory model gives the definition precisely: “A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.”

Be careful with the word “race”. Microsoft’s threading guidance uses a broader one: “A race condition is a bug that occurs when the outcome of a program depends on which of two or more threads reaches a particular block of code first.” That covers correctly-locked code that still runs steps in the wrong order. A data race is almost always a race condition too (a racy statistics counter may not change any outcome), and most race conditions are not data races, and the tools that find the first don’t find the second.

How bad is a data race? It depends on the language:

  • Java and Go bound the damage for word-sized values: you get some value that was actually written, not garbage. Go’s memory model says this makes it “more like Java or JavaScript […] and less like C and C++, where the meaning of any program with a race is entirely undefined”. The exception in Java is 64-bit values: the specification says “a single write to a non-volatile long or double value is treated as two separate writes”, so a racy read can see half of one write and half of another. Our racy Java counter is a long. In C#, the same goes for anything wider than a word, and for structs.
  • Go stops bounding it for multiword values. Races on interfaces, maps, slices and strings involve a pointer and a length or type together, and the memory model warns that such races “can in turn lead to arbitrary memory corruption”. Maps have a built-in check, and it fired in our lab when four goroutines wrote one map: fatal error: concurrent map writes. Slices, strings and interfaces have no such check: there you get the corruption, silently.
  • Rust’s unsafe code and C++ give you undefined behaviour, which is why safe Rust refuses to compile the program above at all.

The four models on one counter

Each language gets the same job. The programs below use 4 threads and 50,000 increments each so they finish quickly; the measurements later in this part use 200,000.

4 threads, 200,000 increments each: expected 800,000 (log scale) 1 ns 10 ns 100 ns 1 µs 10 µs

Measured by checks/part11_counter/run.py on one machine (12th Gen Intel(R) Core(TM) i5-1235U, 12 logical cores), 5 runs per bar, median nanoseconds per increment. A counter is the worst case for locking: the critical section is one instruction, so the synchronization is all of the cost.

Go: mutex, atomic, or one owner

package main

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

func main() {
	const workers, each = 4, 50_000

	var mu sync.Mutex
	var guarded int64
	var counted atomic.Int64
	updates := make(chan int64, 1024)
	total := make(chan int64)

	// One goroutine owns the count. Nobody else touches it.
	go func() {
		var owned int64
		for v := range updates {
			owned += v
		}
		total <- owned
	}()

	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < each; i++ {
				mu.Lock()
				guarded++
				mu.Unlock()

				counted.Add(1)
				updates <- 1
			}
		}()
	}
	wg.Wait()
	close(updates)

	mu.Lock()
	fmt.Println("mutex:  ", guarded)
	mu.Unlock()
	fmt.Println("atomic: ", counted.Load())
	fmt.Println("channel:", <-total)
}

It prints:

mutex:   200000
atomic:  200000
channel: 200000

Three models, one program. The mutex guards a critical section; the atomic makes the read-modify-write indivisible; the channel gives the counter a single owner goroutine, and the workers only send it messages.

That last one is the Go slogan, from Effective Go: “Do not communicate by sharing memory; instead, share memory by communicating.” The same page also says what most quotes leave out: “This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable, for instance.” And note what the slogan is: a convention. Nothing stops a goroutine from sending a pointer and then writing through its own copy.

Two Go details worth knowing:

  • A sync.Mutex has no owner. The package docs: “A locked Mutex is not associated with a particular goroutine. It is allowed for one goroutine to lock a Mutex and then arrange for another goroutine to unlock it.” It’s also not reentrant: locking it twice in one goroutine deadlocks.
  • Don’t copy a mutex. “A Mutex must not be copied after first use”, and copying a struct that contains one copies its lock state. go vet‘s copylock analyzer catches the common cases.

Java: synchronized, atomics, or a queue

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.AtomicLong;

static final Object LOCK = new Object();
static long guarded;

void main() throws InterruptedException {
    final int workers = 4;
    final int each = 50_000;
    AtomicLong counted = new AtomicLong();
    BlockingQueue<Long> updates = new ArrayBlockingQueue<>(1024);
    long[] owned = {0};

    Thread owner = Thread.ofPlatform().start(() -> {
        try {
            for (long v = updates.take(); v != 0; v = updates.take()) {
                owned[0] += v;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });

    List<Thread> threads = new ArrayList<>();
    for (int w = 0; w < workers; w++) {
        threads.add(Thread.ofVirtual().start(() -> {
            for (int i = 0; i < each; i++) {
                synchronized (LOCK) {
                    guarded++;
                }
                counted.incrementAndGet();
                try {
                    updates.put(1L);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
    updates.put(0L);
    owner.join();

    synchronized (LOCK) {
        IO.println("synchronized: " + guarded);
    }
    IO.println("atomic:       " + counted.get());
    IO.println("queue:        " + owned[0]);
}

It prints:

synchronized: 200000
atomic:       200000
queue:        200000

The workers here are virtual threads (Java 21, JEP 444). They don’t change any of the rules above: the memory model, the locks and the races are identical. What changes is the cost of blocking, which is why the JEP says they “exist to provide scale (higher throughput), not speed (lower latency)”, and that they “should never be pooled: A new virtual thread should be created for every application task”.

Older advice said to replace synchronized with ReentrantLock in virtual-thread code, because a virtual thread inside a synchronized block was pinned to its carrier thread. JEP 491 fixed that in Java 24: it eliminates “nearly all cases of virtual threads being pinned to platform threads”. On Java 21 the old advice still applies; from 24 on it doesn’t.

Two Java specifics:

  • Java monitors are reentrant: “A thread t may lock a particular monitor multiple times; each unlock reverses the effect of one lock operation.” Go’s mutex isn’t; Rust’s is unspecified and won’t return.
  • volatile is about visibility, not atomicity. It makes writes visible to other threads. It does not make i++ a single step. For that, use AtomicLong or a lock.

C#: lock, Interlocked, or a channel

using System.Threading.Channels;

const int workers = 4;
const int each = 50_000;

var gate = new Lock();
long guarded = 0;
long counted = 0;
var updates = Channel.CreateBounded<long>(1024);

// One task owns the count.
var owner = Task.Run(async () =>
{
    long owned = 0;
    await foreach (var v in updates.Reader.ReadAllAsync())
    {
        owned += v;
    }
    return owned;
});

var work = new Task[workers];
for (var w = 0; w < workers; w++)
{
    work[w] = Task.Run(async () =>
    {
        for (var i = 0; i < each; i++)
        {
            lock (gate)
            {
                guarded++;
            }
            Interlocked.Increment(ref counted);
            await updates.Writer.WriteAsync(1);
        }
    });
}
await Task.WhenAll(work);
updates.Writer.Complete();

Console.WriteLine($"lock:        {guarded}");
Console.WriteLine($"interlocked: {Interlocked.Read(ref counted)}");
Console.WriteLine($"channel:     {await owner}");

It prints:

lock:        200000
interlocked: 200000
channel:     200000

Lock is the dedicated type added in .NET 9 and C# 13; the docs say to “lock a dedicated object instance of the System.Threading.Lock type for best performance”. It’s reentrant, like a Java monitor. The guidance also says what not to lock on: don’t lock on this, on a type (lock(typeof(X))), or on a string, because code outside your class can lock the same object and deadlock you.

Two Lock traps worth knowing. The type must be exactly System.Threading.Lock: the docs warn that if “the type of the expression is anything else, such as Object or a generic type like T”, you silently get the old Monitor implementation instead. And the compiler enforces a rule: you can’t await inside a lock.

lock (gate)
{
    await Task.Delay(1);   // error CS1996: Cannot await in the body of a lock statement
}

That’s C#’s version of a rule Tokio gives Rust programmers by convention: don’t hold a lock across an await point, because the continuation may resume on another thread while the lock is still held. C# makes it a compile error; in Rust, MutexGuard not being Send catches many cases; in Go, there’s nothing to catch, so it’s on you. When you do need mutual exclusion around an await, use an async-aware primitive: SemaphoreSlim.WaitAsync in .NET, or Tokio’s Mutex in Rust.

Rust: the compiler checks who may touch the state

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

fn main() {
    const WORKERS: u64 = 4;
    const EACH: u64 = 50_000;

    let guarded = Mutex::new(0u64);
    let counted = AtomicU64::new(0);
    let (sender, receiver) = mpsc::channel::<u64>();

    thread::scope(|scope| {
        for _ in 0..WORKERS {
            // Borrowed, not cloned: thread::scope guarantees these threads end first.
            let guarded = &guarded;
            let counted = &counted;
            let sender = sender.clone();
            scope.spawn(move || {
                for _ in 0..EACH {
                    *guarded.lock().expect("not poisoned") += 1;
                    counted.fetch_add(1, Ordering::SeqCst);
                    sender.send(1).expect("the receiver is alive");
                }
            });
        }
        // The loop below ends when every sender is gone, so drop the one main holds.
        drop(sender);
        let owned: u64 = receiver.iter().sum();
        println!("mutex:   {}", guarded.lock().expect("not poisoned"));
        println!("atomic:  {}", counted.load(Ordering::SeqCst));
        println!("channel: {owned}");
    });
}

It prints:

mutex:   200000
atomic:  200000
channel: 200000

thread::scope (stable since Rust 1.63) lets these threads borrow local variables, because the scope “guarantees all threads will be joined at the end of the scope”. For threads that outlive the scope, you’d wrap the state in an Arc and clone that instead.

There’s no unsynchronized version to show, because it doesn’t compile:

use std::thread;

fn main() {
    let mut count = 0u64;
    thread::scope(|scope| {
        scope.spawn(|| {
            count += 1;
        });
        scope.spawn(|| {
            count += 1;
        });
    });
    println!("{count}");
}
error[E0499]: cannot borrow `count` as mutable more than once at a time

This is the ownership model doing concurrency work. One mutable borrow at a time means no two threads can write the same variable, so a data race can’t be expressed. The Nomicon states the guarantee and its limits in the same breath: “Safe Rust guarantees an absence of data races”, but “Rust does not prevent general race conditions”, and “it is considered “safe” for Rust to get deadlocked or do something nonsensical with incorrect synchronization”.

Two marker traits carry the rules across thread boundaries. Send means a value can be moved to another thread; Sync means &T can be shared with one. Rc<T> is neither, because its reference count isn’t atomic, so our lab’s attempt to move one into a thread failed with error[E0277]: `Rc<i32>` cannot be sent between threads safely. Arc<T> is the atomic version. A MutexGuard isn’t Send either, which is what stops you from locking on one thread and unlocking on another.

Notice where the data lives: Mutex<T> contains the value, so you cannot reach the data without locking. In C#, Java and Go, the lock and the data it protects are two separate things, joined only by a comment and a convention.

And safe Rust still deadlocks. Our lab locked the same mutex twice in one thread, which the docs say is “left unspecified” except that “this function will not return on the second call”. It printed the first line and then hung until we killed it, five seconds later.

Explain it like I’m ten

Four people have to update one scoreboard.

  • No rules: everyone walks up and writes at once. Two people read “12”, both write “13”, and one point vanishes.
  • A key (a lock): only the person holding the key may write. Everyone else waits in line. Nobody loses a point, but the line is the slow part.
  • A single scorekeeper (a channel or an actor): nobody else touches the board. The others shout “one more” and get on with their game.
  • A rule the school enforces (ownership): the board is locked in a case, and you physically can’t reach it without the key. You can’t forget the rule, because you can’t break it.

The precise version

  • Losing a point is a lost update, caused by a read-modify-write that another thread interleaves with.
  • The key is a mutex; the queue for it is contention, and it’s why a lock costs more when more threads want it.
  • The scorekeeper is message passing: the state has one owner, and the messages are the only way in.
  • The locked case is Rust’s ownership and Send/Sync: the compiler refuses code that shares mutable data without synchronization.
  • Where the analogy breaks: a single scorekeeper can become the bottleneck, and if two scorekeepers wait for each other’s notes, nobody scores at all. That’s a deadlock, and no model prevents it for you.

Deadlock, and what causes it

A deadlock needs four conditions at once, from Coffman, Elphick and Shoshani’s 1971 survey: mutual exclusion, hold-and-wait, no preemption, and circular wait. Break any one and the deadlock can’t happen.

Most practical rules are one of those four in disguise:

  • Take locks in a fixed global order (breaks circular wait). Microsoft’s Lock docs say it plainly: “ensure that all code paths that might enter any two of those locks on the same thread enter them in the same order”.
  • Use a timeout (TryEnter, try_lock, a context deadline) so a waiter can give up (breaks no preemption).
  • Don’t hold a lock while calling code you don’t control, including callbacks, event handlers and remote calls. You don’t know what it locks.
  • Hold one lock at a time where you can, and keep critical sections short.
  • Watch self-deadlock. In Lu’s 2008 study of 105 real concurrency bugs, “Some (22%) of the examined deadlock bugs are caused by one thread acquiring resource held by itself.” Java monitors and .NET’s Lock let you re-enter; Go and Rust don’t.

Which bugs each model makes impossible

Slogans get this part wrong. Every model removes a class of bug and leaves others.

Model Makes impossible Still possible
Locks data races on the guarded data, when every access takes the lock forgetting the lock, deadlock, lost wakeups, atomicity across two locked sections
Atomics data races on that one variable order violations between variables, check-then-act across two atomics
Channels / actors data races on state with one owner deadlock and goroutine leaks (nobody receives), message-order bugs, unbounded queues
Ownership (Rust) data races, at compile time deadlock, order violations, leaks, logic races
Immutability data races on the data itself stale reads, races on the variable that points to it

The research backs up the “still possible” column better than most blog posts do:

  • Locks are not where most bugs are. Lu et al. (ASPLOS 2008) studied 105 concurrency bugs in MySQL, Apache, Mozilla and OpenOffice. Of the 74 non-deadlock bugs, “Almost all (97%) […] belong to one of the two simple bug patterns: atomicity-violation or order-violation”, and “Three quarters (73%) of the examined non-deadlock bugs are fixed by techniques other than adding/changing locks.”
  • Message passing moves the bugs, it doesn’t remove them. Tu et al. (ASPLOS 2019) studied 171 Go concurrency bugs in Docker, Kubernetes, etcd, gRPC, CockroachDB and BoltDB: “around 58% of blocking bugs are caused by message passing”, against 42% from shared memory, even though shared-memory primitives were used more often. Their Observation 8 is the other half: “There are much fewer non-blocking bugs caused by message passing than by shared memory accesses.” Channels trade data races for deadlocks and leaks.
  • Safe Rust still gets stuck. Qin et al. (PLDI 2020) studied 100 Rust concurrency bugs: “all blocking bugs we studied are in safe code”, 30 of them double locks, and “Surprisingly, 25 of our studied non-blocking bugs happen in safe code.”

Finding them: what the tools can and can’t do

  • Go’s race detector (go build -race) found our unsynchronized counter immediately: WARNING: DATA RACE, with both stacks. Its limits are stated in the docs: “The race detector only finds races that happen at runtime, so it can’t find races in code paths that are not executed”, and it costs: “memory usage may increase by 5-10x and execution time by 2-20x.” Run it in tests and against a realistic workload, not in production.
  • It finds data races only. Tu’s study reproduced 20 non-blocking Go bugs and the detector caught 10 of 17 of the race-type ones. Atomicity violations built from correct locks are invisible to it.
  • Go’s deadlock detector is weaker still: it fires only when every goroutine is asleep. In the same study it “can only detect two blocking bugs […] and fail in all other cases”.
  • Go 1.27 added a goroutine leak profile (goroutineleak in runtime/pprof), which reports goroutines “blocked on some concurrency primitive […] that cannot possibly become unblocked”. It works through reachability, so it “may fail to identify leaks caused by blocking on concurrency primitives reachable through global variables”.
  • C# and Java have no equivalent of -race in the box. You get analyzers, stress tests and review. That makes conventions (immutable by default, one owner per piece of state) carry more weight.

The bug the language fixed

Go 1.22 changed loop variables to be per-iteration, which removed an entire bug class: a goroutine closing over the loop variable used to see whatever value the loop had reached.

package main

import (
	"fmt"
	"sort"
	"sync"
)

func main() {
	var mu sync.Mutex
	var seen []int
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			seen = append(seen, i)
			mu.Unlock()
		}()
	}
	wg.Wait()
	sort.Ints(seen)
	fmt.Println(seen)
}

It prints:

[0 1 2 3 4]

The same file in a module whose go.mod says go 1.21 printed [5 5 5 5 5] in our lab: every goroutine saw the same variable, and by the time they ran, the loop had finished with it. (That output isn’t guaranteed either. The old version is itself a race, so it can print other things.) The behaviour follows the go line in go.mod, not the toolchain version. Tu’s study found 11 bugs caused by anonymous functions capturing shared state, nine of them races between a parent goroutine and the child it started.

The same API, opposite guarantees

Two “get it or create it” methods that look identical and behave differently under contention. Java’s ConcurrentHashMap.computeIfAbsent says: “The entire method invocation is performed atomically. The supplied function is invoked exactly once per invocation of this method if the key is absent”.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

void main() throws InterruptedException {
    var map = new ConcurrentHashMap<String, String>();
    var factoryCalls = new AtomicInteger();
    var threads = new Thread[8];
    for (int t = 0; t < threads.length; t++) {
        threads[t] = Thread.ofPlatform().start(() ->
                map.computeIfAbsent("connection", key -> {
                    factoryCalls.incrementAndGet();
                    return "opened";
                }));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("factory calls: " + factoryCalls.get() + ", entries: " + map.size());
}

It prints:

factory calls: 1, entries: 1

.NET’s ConcurrentDictionary.GetOrAdd made the opposite trade: “the valueFactory delegate is called outside the locks to avoid the problems that can arise from executing unknown code under a lock”, so “valueFactory may be called multiple times, but only one key/value pair will be added to the dictionary.” In our lab, with eight threads released together, the factory ran eight times in every one of five runs, and one entry was added.

If your factory opens a connection, starts a timer or charges a card, that difference is the whole ballgame. In .NET, either make the factory cheap and side-effect-free, or store a Lazy<T> created with LazyThreadSafetyMode.ExecutionAndPublication, and use only the instance GetOrAdd returns, so exactly one lazy is ever evaluated. In Java, keep the mapping function short and don’t touch the map from inside it: the documentation says “The mapping function must not modify this map during computation.”

Actors: one owner, taken further

The actor model comes from Hewitt, Bishop and Steiger (1973), which reduced everything to one idea: “all of the modes of behavior can be defined in terms of one kind of behavior: sending messages to actors.” Erlang made it practical, with processes that “share no data with each other”.

Akka on the JVM describes the payoff: “instead of having to synchronize access using locks you can write your actor code without worrying about concurrency at all”, because each actor has “exactly one mailbox” and Akka “ensures that this implementation detail does not affect the single-threadedness of handling the actor’s state”.

In .NET, Orleans takes the same idea to a cluster with virtual actors, which “exist perpetually”: “An actor cannot be explicitly created nor destroyed, and its virtual existence is unaffected by the failure of a server that executes it.” Its execution rule is the important one: “Actor activations are single threaded and do work in chunks, called turns.” That’s the default, and reentrancy is opt-in: turn it on and requests interleave again, with the race conditions that implies.

What actors don’t fix: ordering. Erlang guarantees order only per sender pair, “if A sends a signal S1 to B, and later sends signal S2 to B, S1 is guaranteed not to arrive after S2”; messages from different senders can interleave any way at all. Akka says the same. A single owner removes data races and leaves order violations, which were about a third of the non-deadlock bugs in Lu’s study.

Async is not parallelism

C#’s async/await and Java’s virtual threads solve a different problem from the ones above: waiting, not sharing.

Microsoft’s own wording: “The async and await keywords don’t cause extra threads to be created. Async methods don’t require multithreading because an async method doesn’t run on its own thread.” Stephen Cleary’s summary is the one to remember: “There is no thread”. An awaited I/O operation has no thread parked on it; a thread is borrowed briefly when it completes.

Two traps that come from mixing the models:

  • Blocking on async code. Calling .Result or .Wait() on a task in a context with a single thread, such as a UI app or classic ASP.NET, deadlocks: “The top-level method is blocking the context thread, waiting for GetJsonAsync to complete, and GetJsonAsync is waiting for the context to be free so it can complete.” ASP.NET Core has no SynchronizationContext, so that exact deadlock is gone there, but blocking still starves the thread pool.
  • ConfigureAwait(false) everywhere. Stephen Toub’s FAQ splits it: “if you’re writing app-level code, do not use ConfigureAwait(false)”, and “if you’re writing general-purpose library code, use ConfigureAwait(false)”.

In Rust, async has the same shape and one extra rule: tokio::spawn requires tasks to be Send, and “Tasks are Send when all data that is held across .await calls is Send”. Tokio’s own docs push back on the common advice to reach for an async mutex: “Contrary to popular belief, it is ok and often preferred to use the ordinary Mutex from the standard library in asynchronous code”, as long as the lock isn’t held across an await.

Measured: what each model costs

Each language ran the same job: 4 threads, 200,000 increments each, 5 runs per variant, on one machine with 12 logical cores. The numbers are median nanoseconds per increment; “1 thread” is the same total work done by one thread, which shows the cost of the primitive without contention. That comparison doesn’t hold for the channel and queue rows: they always have a second thread receiving, so their “1 thread” figure includes a handover too, and in Java it came out slower than the contended run. Two more things the numbers include: the benchmark programs pick the variant by a string inside the loop, and they use platform threads in Java and long-running tasks in C#, rather than the virtual threads and Task.Run of the samples above, to keep the scheduler out of a CPU-bound loop.

Language Model 4 threads 1 thread Correct every run
Go no synchronization 4 ns 4 ns no
Go mutex 107 ns 30 ns yes
Go atomic add 42 ns 13 ns yes
Go channel to one owner 185 ns 112 ns yes
Java no synchronization 123 ns 39 ns no
Java synchronized 260 ns 73 ns yes
Java atomic add 106 ns 43 ns yes
Java queue to one owner 426 ns 468 ns yes
C# no synchronization 30 ns 11 ns no
C# lock on a Lock 330 ns 38 ns yes
C# Interlocked.Increment 71 ns 23 ns yes
C# channel to one owner 1896 ns 781 ns yes
Rust mutex 134 ns 17 ns yes
Rust atomic add 30 ns 9 ns yes
Rust channel to one owner 220 ns 42 ns yes

What the run shows:

  • Unsynchronized was fastest in three of the four languages, and wrong in all of them. It lost between 308,391 and 579,259 of 800,000 increments, differently in every run. A benchmark of incorrect code is not a benchmark.
  • Atomics were the cheapest correct option everywhere: 30 ns in Rust, 42 ns in Go, 71 ns in C#, 106 ns in Java.
  • Contention, not the lock, is the cost. The same total work on one thread cost 30 ns per increment in Go and 38 ns in C#; with four threads fighting for it, 107 ns and 330 ns. That’s 3.6× and 8.6× more for exactly the same work.
  • Message passing costs a hop. Sending 1 to an owner cost 185 ns in Go, 220 ns in Rust, 426 ns in Java and 1896 ns in C#, where each send also pays for await WriteAsync and its state machine, while Go, Rust and Java use a plain blocking send. Channels are for handing over work, not for counting.
  • This is the worst case for synchronization. The critical section is one increment, so the locking is all of the cost. When the protected work is a hash lookup or a network write, these differences shrink into the noise.
  • One machine, one shape of workload. A 12th Gen Intel(R) Core(TM) i5-1235U with 12 logical cores, 5 runs per bar. Don’t compare the languages with each other: the programs use each language’s idiomatic types, and the JIT, the allocator and the scheduler all differ.

Across languages

C# Java Go Rust
Unsynchronized shared write compiles; lost updates compiles; lost updates compiles; lost updates, -race finds it doesn’t compile
Mutual exclusion lock on System.Threading.Lock (.NET 9), Monitor, SemaphoreSlim synchronized, ReentrantLock sync.Mutex, sync.RWMutex Mutex<T>, RwLock<T> (the data is inside)
Reentrant? yes yes no unspecified; won’t return
Atomics Interlocked, Volatile java.util.concurrent.atomic, volatile sync/atomic std::sync::atomic with explicit ordering
Message passing System.Threading.Channels (bounded needs capacity > 0) BlockingQueue, SubmissionPublisher channels (unbuffered by default) mpsc (unbounded by default), sync_channel(0)
Lock held across an await compile error (CS1996) n/a (virtual threads block) n/a MutexGuard isn’t Send, so often a compile error
Cheap concurrency unit Task on the thread pool virtual threads (Java 21) goroutines async tasks (Tokio), OS threads
Data-race detector none built in none built in -race (ThreadSanitizer) not needed for safe code; Miri and loom for unsafe and atomics

Trade-offs

  • Locks are simple and local; they scale badly under contention. Our counter cost 3.6 to 8.6 times more per increment under four threads than on one, for exactly the same work. Part of that cost is the cache line bouncing between cores, the same false sharing that makes two unrelated variables in one cache line slow each other down.
  • Atomics are the cheapest correct option for one variable, and only for one variable. Two atomics can’t be updated together atomically. Memory ordering (Relaxed, Acquire, SeqCst) is easy to get subtly wrong; default to the strongest until measurement says otherwise.
  • Message passing gives you one owner and costs you a hop. Sending 1 to an owner was the slowest option everywhere we measured. It pays off when the message carries real work, not one increment, and when you want backpressure.
  • Unbounded queues hide problems. Rust’s mpsc::channel and .NET’s unbounded channels never block a producer, so a slow consumer turns into growing memory instead of visible backpressure. Bound them, and decide what happens when they’re full: .NET makes you choose, and three of its four BoundedChannelFullMode values drop data.
  • Ownership catches races at compile time, and asks for more design up front. Arc<Mutex<T>>, lifetimes and Send/Sync bounds are real work. You trade run-time debugging for compile-time argument.
  • Immutability removes the question. Copy-on-write and persistent structures make readers safe without locks, at the cost of allocation. In .NET, System.Collections.Immutable gives “implicit thread safety in multi-threaded applications (no locks required to access collections)” — but the variable holding the collection still needs an atomic swap.
  • Actors isolate state and add a scheduler. You get single-threaded reasoning per entity and a distributed system’s worth of new questions: mailbox growth, message order, retries, and where the state lives when a node dies.

Common mistakes

  • Locking the increment but not the read. A counter guarded on write and read without the lock is still a data race, and in Go a racy read of a multiword value can corrupt memory.
  • Assuming volatile makes i++ atomic. It doesn’t, in Java or C#. Use atomics.
  • Check-then-act across two operations. if (!map.ContainsKey(k)) map.Add(k, v) is a race even with a concurrent map. Use the atomic method (GetOrAdd, computeIfAbsent, LoadOrStore) and know its guarantees.
  • Holding a lock while calling out. A callback, an HTTP request or a database call under a lock turns a local lock into a distributed one.
  • Sending a pointer and then writing through it. Go, Java and .NET channels pass references; the sender can still mutate what it sent. Send values, or don’t keep the reference.
  • Starting a goroutine or task and never waiting for it. Nobody sees its failure, and it may outlive the request. Go: give it a context; .NET: keep the Task; Java: use an executor or structured concurrency.
  • Unbounded fan-out. 10,000 goroutines all calling one database is not concurrency, it’s a denial-of-service attack on yourself. Bound the workers, bound the queue.
  • Publishing an object through an unsynchronized field. Another thread can see the reference before the object’s fields are written. In Java that’s why double-checked locking needs volatile, and why a static final field or a holder class is the simpler answer.
  • Reaching for parallelism first. Most latency in a service is waiting on I/O, which async/await, virtual threads and goroutines already solve. Parallel CPU work is a different, rarer problem.

Interview questions

Try to answer each one before opening the model answer.

1. What is a data race, and how is it different from a race condition?

Show a strong answer
  • Data race: two threads access the same memory concurrently, at least one writes, and the accesses aren’t synchronized. Go’s memory model and Rust’s Nomicon define it this way.
  • Race condition: the result depends on timing. A correctly locked program can still have one, for example a check and an act that another thread interleaves between.
  • So: every data race is a race condition; most race conditions aren’t data races. Race detectors find the first kind.
  • Consequences differ by language: Java and Go bound single-word races to some written value; Go’s multiword values (slices, maps, interfaces, strings) can corrupt memory; in C, C++ and Rust’s unsafe code it’s undefined behaviour.

Likely follow-up: “How would you find one?” -race in Go tests and staging, ThreadSanitizer for C++, stress tests and code review elsewhere, plus designs that keep shared mutable state small.

2. Compare locks, atomics, channels and ownership.

Show a strong answer
  • Locks: protect a section of code that touches state. Simple, composable up to a point; cost rises with contention; deadlock is possible; the compiler doesn’t check that you took the lock.
  • Atomics: one indivisible operation on one variable. Cheapest correct option for counters and flags; can’t span two variables; memory ordering is subtle.
  • Channels or actors: one owner for the state, everyone else sends messages. Removes data races on that state; adds queues, deadlocks and leaks; costs a hop per message.
  • Ownership (Rust): the type system checks the rules. Data races are compile errors; deadlocks and logic races remain.
  • Choosing: state that’s touched from everywhere wants one owner; a hot counter wants an atomic; a short critical section wants a lock.

Likely follow-up: “Is message passing safer?” Not overall. In Tu’s Go study, message passing caused about 58% of the blocking bugs, and far fewer non-blocking ones. It trades races for deadlocks.

3. What does Rust’s ownership give you that a mutex in Java doesn’t?

Show a strong answer
  • The check is at compile time. One mutable borrow at a time means shared mutable state without synchronization doesn’t compile: cannot borrow as mutable more than once at a time.
  • The lock and the data are one thing. Mutex<T> holds the value, so the data can’t be read without locking. In Java the pairing is a convention.
  • Thread boundaries are typed. Send and Sync say what can move or be shared; Rc and MutexGuard aren’t Send, so misuse is a compile error.
  • What it doesn’t give you: deadlock freedom, atomicity across operations, or protection from logic races. Qin’s study found all 59 blocking bugs in safe code, 30 of them double locks.

Likely follow-up: “What does that cost?” More design work up front (Arc<Mutex<T>>, lifetimes, Send bounds), and compile errors where other languages would let you find out in production.

4. Why is count++ unsafe, and what are the fixes?

Show a strong answer
  • It’s three steps: read, add, write. Another thread can interleave between them, so one increment overwrites another. Our four-thread runs landed between 27% and 62% of the expected total, and never twice the same.
  • Fixes, cheapest first: an atomic increment; a lock around the read-modify-write; giving the counter to one owner (channel, actor, queue); or per-thread counters summed at the end.
  • Per-thread counters avoid contention entirely and are what metrics libraries do.
  • Not a fix: volatile. It gives visibility, not atomicity.

Likely follow-up: “How would you count requests per second in a hot path?” Per-core or per-thread counters, aggregated on read, so the hot path never contends.

5. When would you choose actors or a single-owner goroutine over locks?

Show a strong answer
  • When one piece of state is touched by many callers and the operations are more than a single increment: a game room, a device session, an order’s lifecycle.
  • When you want serialized reasoning: one actor handles one message at a time, so its state behaves like single-threaded code.
  • When the state can be partitioned by key, so there’s no global bottleneck: one actor per entity, as Orleans does with virtual actors.
  • Not when the work is a tiny update on a shared variable: the message hop costs more than the atomic.
  • Watch: mailbox growth, message ordering (guaranteed only per sender pair in Erlang), and what happens when the owner dies.

Likely follow-up: “How do you avoid the actor becoming a bottleneck?” Partition by key, keep per-message work small, batch, and move read-only queries to a replica or a snapshot.

6. What causes deadlock and how do you prevent it?

Show a strong answer
  • Four conditions together (Coffman et al., 1971): mutual exclusion, hold-and-wait, no preemption, circular wait. Break one.
  • Practical rules: a global lock order (breaks circular wait), timeouts on acquisition (breaks no preemption), one lock at a time, and never hold a lock while calling unknown code.
  • Self-deadlock: re-entering a non-reentrant lock. Go’s mutex and Rust’s Mutex hang; Java monitors and .NET’s Lock are reentrant. 22% of the deadlocks in Lu’s study were self-deadlocks.
  • In message passing: a deadlock looks like everyone waiting on a channel. Use buffered channels, timeouts or cancellation, and make sure someone always receives.
  • Detection: Go panics if all goroutines are asleep, which rarely fires in a real server; thread dumps and lock-order analyzers elsewhere.

Likely follow-up: “Your service hangs under load, no CPU used. What do you do?” Capture stacks (SIGQUIT thread dump, pprof goroutine, dotnet-dump), look for threads blocked on locks or channel operations, and find the cycle or the missing receiver.

7. How do virtual threads and async/await change the design?

Show a strong answer
  • They make waiting cheap, not sharing safe. The memory model, races and locks are unchanged.
  • Java 21 virtual threads: “not faster threads […] they exist to provide scale (higher throughput), not speed (lower latency)”; never pool them; from Java 24, synchronized no longer pins a virtual thread (JEP 491).
  • C# async/await: no extra threads; an awaited I/O has no thread. Don’t block on tasks (.Result, .Wait()), don’t async void, and you can’t await inside a lock.
  • Rust async: spawned tasks must be Send; don’t hold a std MutexGuard across an await; Tokio recommends the std mutex when the lock isn’t held across awaits.
  • Design consequence: with cheap concurrency, the bottleneck moves to shared resources, so bound your fan-out to databases and downstream services.

Likely follow-up: “Do virtual threads remove the need for reactive frameworks?” For throughput on blocking I/O, largely yes; reactive styles still help for streaming, backpressure and composition.

8. How do you design a cache that many threads use?

Show a strong answer
  • Use the concurrent map’s atomic get-or-create, and know its semantics: Java’s computeIfAbsent runs the function once, atomically; .NET’s GetOrAdd may run the factory several times (eight out of eight threads in our lab).
  • Avoid expensive work under a lock: in .NET store a Lazy<T> (ExecutionAndPublication); in Java keep the mapping function short, and never touch the map from inside it, because it runs while that bin is locked.
  • Prevent the stampede: single-flight the expensive load per key, so one loader fills the entry and the rest wait for it.
  • Bound the cache by size or time, and decide the eviction policy before you need it.
  • Watch reads too: a racy read of a shared structure is still a data race. Use the concurrent type or an immutable snapshot swapped atomically.

Likely follow-up: “What about a distributed cache?” Then the same race is across processes: use a lock key or a compare-and-set in the store, expect duplicate loads, and make the load idempotent (Part 4).

Sources

What to remember

  • Concurrency questions are state questions: who may touch this, and when.
  • count++ is read, add, write. Without synchronization, increments vanish, silently and differently on every run.
  • A lock protects data only if every access takes it; an atomic protects one variable; a channel or actor gives state one owner; Rust’s ownership makes the compiler check.
  • Every model removes one class of bug and leaves others. Message passing trades data races for deadlocks and leaks.
  • Locks deadlock when four conditions hold at once. A global lock order and timeouts are the everyday fixes.
  • Know your library’s guarantees: Java’s computeIfAbsent runs once; .NET’s GetOrAdd may run many times.
  • Virtual threads and async/await make waiting cheap. They change nothing about sharing.

Don’t ask which primitive is fastest. Ask who owns this piece of state, and make the answer impossible to get wrong.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.