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.
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.
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.Mutexhas 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.
volatileis about visibility, not atomicity. It makes writes visible to other threads. It does not makei++a single step. For that, useAtomicLongor 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
Lockdocs 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
Locklet 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 (
goroutineleakinruntime/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
-racein 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
.Resultor.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 noSynchronizationContext, 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 WriteAsyncand 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::channeland .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 fourBoundedChannelFullModevalues drop data. - Ownership catches races at compile time, and asks for more design up front.
Arc<Mutex<T>>, lifetimes andSend/Syncbounds 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.Immutablegives “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
volatilemakesi++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 theTask; 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 astatic finalfield 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.
SendandSyncsay what can move or be shared;RcandMutexGuardaren’tSend, 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
Mutexhang; Java monitors and .NET’sLockare 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,
synchronizedno 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’tasync void, and you can’t await inside a lock. - Rust async: spawned tasks must be
Send; don’t hold astdMutexGuardacross 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
computeIfAbsentruns the function once, atomically; .NET’sGetOrAddmay 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
- Labs:
system-design/checks/part11_counter/(the counter under each model, and the behaviour cases quoted above, on OpenJDK 25, .NET SDK 10.0.302, Go 1.26 and rustc 1.95); the C#, Java, Go and Rust programs above are run by the series’ code verifiers - C. A. R. Hoare, “Communicating Sequential Processes”, CACM 21(8), 1978; C. Hewitt, P. Bishop, R. Steiger, A Universal Modular ACTOR Formalism for Artificial Intelligence, IJCAI 1973; E. W. Dijkstra, Cooperating Sequential Processes (EWD 123); E. G. Coffman, M. Elphick, A. Shoshani, “System Deadlocks”, ACM Computing Surveys 3(2), 1971
- S. Lu, S. Park, E. Seo, Y. Zhou, Learning from Mistakes: A Comprehensive Study on Real World Concurrency Bug Characteristics, ASPLOS 2008; T. Tu, X. Liu, L. Song, Y. Zhang, Understanding Real-World Concurrency Bugs in Go, ASPLOS 2019; B. Qin, Y. Chen, Z. Yu, L. Song, Y. Zhang, Understanding Memory and Thread Safety Practices and Issues in Real-World Rust Programs, PLDI 2020
- Go: the memory model, Effective Go, the race detector, package sync, package sync/atomic, the specification, Go 1.22 release notes and the loop variable change, Go 1.27 release notes, Go Concurrency Patterns: Pipelines
- Rust: the Book, fearless concurrency and Send and Sync; the Nomicon on races and Send and Sync; Mutex, Arc, mpsc, thread::scope; Tokio: spawning and shared state
- .NET: the lock statement, System.Threading.Lock, managed threading best practices, Interlocked, System.Threading.Channels, immutable collections, ConcurrentDictionary.GetOrAdd, the task asynchronous programming model, Orleans; S. Cleary, There is no thread and Don’t block on async code; S. Toub, ConfigureAwait FAQ
- Java: JLS chapter 17, threads and locks, JEP 444, virtual threads, JEP 491, synchronize virtual threads without pinning, ConcurrentHashMap, java.util.concurrent
- Actors in practice: Erlang: concurrent programming and processes; Akka: actors
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
computeIfAbsentruns once; .NET’sGetOrAddmay run many times. - Virtual threads and
async/awaitmake 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.