Concurrency Control and Synchronization: From Mutexes to the Actor Model
A survey of concurrency control: mutexes, semaphores, deadlock, lock-free data structures, CSP, and the Actor model
Contents
A survey of the synchronization mechanisms and concurrency models that keep data consistent when multiple flows of execution touch shared resources.
Overview
A modern backend handles thousands to tens of thousands of concurrent requests. When many flows of execution reach the same shared resource, guaranteeing data consistency is what concurrency control is for. This note covers the difference between processes and threads, synchronization mechanisms, deadlock, and recent asynchronous I/O technology.
1. Processes vs threads
Basic comparison
| Property | Process | Thread |
|---|---|---|
| Memory space | Separate (its own address space) | Shared (heap and data shared within a process) |
| Stack | Separate | Separate (each thread has its own stack) |
| Creation cost | High (fork, memory copy) | Low (only a new stack is allocated) |
| Context switch | Expensive (TLB flush required) | Cheap (same address space) |
| Communication | Requires IPC (pipes, sockets, shared memory) | Direct memory access (fast but dangerous) |
| Stability | One crash does not affect other processes | One crash can take down the whole process |
Memory layout comparison
Choosing a model in backend architecture
| Model | Where it is used | Strengths | Weaknesses |
|---|---|---|---|
| Multi-process | Nginx workers, PM2 Cluster | Stability, isolation | Memory footprint, IPC overhead |
| Multi-threaded | Java Spring, Go net/http | Performance, resource sharing | Synchronization complexity |
| Event loop | Node.js, Redis | Simplicity, low overhead | Poor fit for CPU-bound work |
| Coroutines / lightweight threads | Go goroutines, Kotlin coroutines | Hundreds of thousands running at once | Harder to debug |
2. Concurrency problems
Race condition
Two or more flows of execution access shared data at the same time, so the result depends on the interleaving order.
# Race condition example: updating a bank balance
balance = 1000
# Thread A: withdraw 500
temp_a = balance # reads 1000
temp_a = temp_a - 500 # computes 500
# Thread B: withdraw 300 (reads before A writes)
temp_b = balance # reads 1000 (A's result not yet visible)
temp_b = temp_b - 300 # computes 700
balance = temp_a # stores 500
balance = temp_b # stores 700 (A's withdrawal is gone)
# Expected: 200, actual: 700Critical section
The region of code that touches a shared resource. Only one flow of execution may be inside it at a time.
Three requirements for a critical section:
- Mutual exclusion: only one process is inside the critical section at a time
- Progress: if the critical section is empty, a waiting process must be able to enter
- Bounded waiting: no process waits indefinitely
3. Synchronization mechanisms
Mutex (mutual exclusion lock)
A binary lock. Only one thread can acquire it and enter the critical section.
// Using a mutex in Go
var mu sync.Mutex
var balance int = 1000
func withdraw(amount int) {
mu.Lock() // acquire the lock
defer mu.Unlock() // release when the function returns
if balance >= amount {
balance -= amount
}
}| Property | Description |
|---|---|
| Ownership | Yes -- only the acquiring thread may release |
| Count | Binary (0 or 1) |
| Purpose | Protecting a critical section |
| Pitfall | Priority inversion |
Semaphore
A counting synchronization primitive. It caps how many holders may access a resource at once.
# Using a semaphore in Python
# DB connection pool: at most 5 concurrent connections
db_pool = threading.Semaphore(5)
def query_database(sql):
db_pool.acquire() # decrement the counter (blocks at 0)
try:
connection = get_connection()
return connection.execute(sql)
finally:
db_pool.release() # increment the counter| Property | Description |
|---|---|
| Ownership | No -- anyone may signal/release |
| Count | Any non-negative integer |
| Purpose | Limiting resource count, signalling between processes |
| Variants | Binary semaphore (0/1), counting semaphore |
The core difference between a mutex and a semaphore
Mutex: a single restroom key
→ one person enters, and only that person returns the key
Semaphore: a parking lot counter (N spaces)
→ up to N cars may enter, and any departure increments the counter
→ no notion of ownershipMonitor
A high-level synchronization construct. It encapsulates a mutex plus condition variables to make synchronization safer.
// Monitor in Java (synchronized + wait/notify)
class BoundedBuffer {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
synchronized void produce(int item) throws InterruptedException {
while (queue.size() == capacity) {
wait(); // wait while the buffer is full
}
queue.add(item);
notifyAll(); // signal consumers
}
synchronized int consume() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // wait while the buffer is empty
}
int item = queue.poll();
notifyAll(); // signal producers
return item;
}
}Condition variable
A mechanism that parks a thread until some condition holds:
- wait(): wait until the condition holds (releases the mutex automatically while waiting)
- signal(): wake one waiting thread
- broadcast(): wake every waiting thread
4. Deadlock
Definition
Two or more processes each hold a resource the other needs, so none of them can ever make progress.
The four conditions (Coffman conditions)
A deadlock requires all four conditions to hold simultaneously:
| Condition | Description | Example |
|---|---|---|
| Mutual exclusion | A resource is used by only one process at a time | DB row lock |
| Hold and wait | A process holds one resource while waiting for another | Holding lock A while waiting for lock B |
| No preemption | An allocated resource cannot be forcibly taken back | The OS cannot reclaim a lock |
| Circular wait | The waiting relation forms a cycle | A→B→C→A |
Strategies for handling deadlock
1. Prevention
Break one of the four conditions at the source.
| Condition broken | Method | Downside |
|---|---|---|
| Mutual exclusion | Make the resource shareable | Often impossible in practice |
| Hold and wait | Request every needed resource at once | Wasted resources, possible starvation |
| No preemption | Forcibly reclaim resources | Consistency problems |
| Circular wait | Order resources and acquire only in ascending order | The most practical option |
# Preventing circular wait: always acquire locks in the same order
LOCK_ORDER = {'account_lock': 1, 'transaction_lock': 2}
def transfer(from_acc, to_acc, amount):
# Always take the lock of the lower account id first
first, second = sorted([from_acc, to_acc], key=lambda a: a.id)
with first.lock:
with second.lock:
from_acc.balance -= amount
to_acc.balance += amount2. Avoidance
Use the Banker's Algorithm.
Allocate a resource only after confirming the system stays in a safe state.
- Safe state: every process can be served in some order and run to completion
- Unsafe state: a state where deadlock is possible (not necessarily inevitable)
3. Detection and recovery
Allow deadlock to happen, then detect and resolve it periodically:
- Detection: find cycles in the wait-for graph
- Recovery: kill a process, preempt resources, roll back
Deadlock in practice
| Situation | Remedy |
|---|---|
| DB transaction deadlock | Lock timeout plus retry logic |
| Distributed system deadlock | Global ordering, distributed lock manager |
| Circular microservice calls | Circuit breaker, asynchronous message queue |
5. Classic synchronization problems
Producer-consumer
A producer and a consumer synchronize across a bounded buffer.
Solution: a mutex (protecting buffer access) plus two semaphores (empty slots, filled slots)
Readers-writers
- Many threads may read at the same time
- Writing requires exclusive access
Solution: a read-write lock (RWLock)
// RWMutex in Go
var rwmu sync.RWMutex
var data map[string]string
func read(key string) string {
rwmu.RLock() // read lock (many goroutines at once)
defer rwmu.RUnlock()
return data[key]
}
func write(key, value string) {
rwmu.Lock() // write lock (exclusive)
defer rwmu.Unlock()
data[key] = value
}Dining philosophers
Five philosophers at a round table must each pick up both adjacent forks to eat. It combines deadlock, starvation, and concurrency in one problem.
6. Lock-free data structures and non-blocking synchronization
Compare-and-swap (CAS)
The atomic operation at the heart of lock-free programming:
CAS(address, expected_value, new_value):
if *address == expected_value:
*address = new_value
return true
else:
return false// AtomicInteger in Java -- built on CAS
AtomicInteger counter = new AtomicInteger(0);
// Safe increment without a lock
counter.incrementAndGet();
// Using CAS directly
int expected = counter.get();
boolean success = counter.compareAndSet(expected, expected + 1);Lock-free vs lock-based
| Property | Lock-based | Lock-free |
|---|---|---|
| Deadlock | Possible | Impossible |
| Priority inversion | Possible | Impossible |
| Performance (no contention) | Comparable | Slightly faster |
| Performance (heavy contention) | Degrades sharply | Degrades gradually |
| Implementation difficulty | Relatively easy | Very hard |
| Typical use | General synchronization | Redis, Java ConcurrentHashMap |
The ABA problem
A well-known trap in CAS:
- Thread A reads the value "A"
- Thread B changes it A→B→A (the value is "A" again)
- Thread A's CAS succeeds, unaware anything changed
Fix: attach a version number (AtomicStampedReference)
7. Modern concurrency models
Go goroutines and the CSP model
Go adopts the Communicating Sequential Processes (CSP) model. Instead of locking shared memory, values move over channels and one goroutine owns the state (per the Go documentation and Rob Pike's concurrency maxim).
// Safe communication through a Go channel
func main() {
ch := make(chan int, 10) // buffered channel
// producer goroutine
go func() {
for i := 0; i < 100; i++ {
ch <- i // send to the channel
}
close(ch)
}()
// consumer: receive from the channel
for v := range ch {
fmt.Println(v)
}
}- A goroutine starts with roughly a 2KB stack (an OS thread uses ~1MB)
- Hundreds of thousands of goroutines can run concurrently
- The Go runtime scheduler does M:N scheduling (M goroutines onto N OS threads)
Rust ownership and compile-time concurrency safety
Rust prevents data races at compile time through ownership and borrowing:
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
// Arc: atomic reference counting (thread-safe refcount)
// Mutex: protects the inner data
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}Rust's rules:
- Only one mutable reference (
&mut T) may exist at a time, which eliminates data races by construction - The
Sendtrait marks types whose ownership can move between threads - The
Synctrait marks types whose references can be shared between threads - Violations are compile errors, so the bug never reaches runtime
The async/await model
In the code below, findUser and findOrders depend on each other and are awaited in sequence. The independent fetchProfile and fetchNotifications run in parallel through Promise.all.
// Node.js asynchronous model
async function handleRequest(req) {
// While I/O is pending, the event loop serves other requests
const user = await db.findUser(req.userId);
const orders = await db.findOrders(user.id);
// parallel execution
const [profile, notifications] = await Promise.all([
fetchProfile(user.id),
fetchNotifications(user.id)
]);
return { user, orders, profile, notifications };
}8. Linux io_uring
io_uring is the high-performance asynchronous I/O interface introduced in Linux kernel 5.1.
What io_uring is
It cuts most of the overhead out of the traditional read()/write() system call path.
The core structure: ring buffers
io_uring vs traditional I/O
| Property | read()/write() | epoll | io_uring |
|---|---|---|---|
| System calls | One per I/O | Only for event detection | Batched submission |
| Data copies | user↔kernel | user↔kernel | Zero-copy possible |
| Asynchronous support | None | Semi-asynchronous | Fully asynchronous |
| Network + file | Separate APIs | Network only | Unified API |
| Overhead | High | Medium | Very low |
Where things stand in 2026
- Linux 7.0 (2026-04): added non-circular queues and BPF filtering to io_uring, improving cache efficiency and sandbox control (per kernelnewbies LinuxChanges)
- Monoio (ByteDance): a Rust async runtime built on io_uring
- Production adoption: high-performance databases such as ScyllaDB, TigerBeetle, and Redpanda are built on io_uring
The code below shows the basic io_uring usage pattern. io_uring_queue_init sets up the ring buffers, io_uring_get_sqe takes a submission queue entry, and io_uring_prep_read prepares a read. io_uring_submit hands that read to the kernel, and io_uring_wait_cqe waits for completion.
// Basic io_uring usage (conceptual)
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
// submit a read request
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
io_uring_submit(&ring);
// receive the completion
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);9. Concurrency patterns in backend practice
Pattern 1: connection pool
Pattern 2: work stealing
Each thread keeps its own work queue, and when that queue runs dry it steals work from another thread's queue:
- Java ForkJoinPool
- The Go runtime scheduler
- The Tokio runtime (Rust)
Pattern 3: the Actor model
Each actor owns its state and communicates only through messages:
- Erlang/Elixir (BEAM VM)
- Akka (Java/Scala)
- Concurrency without shared memory, which rules out deadlock structurally
Summary
Concurrency control starts with the primitives that protect a critical section: mutexes, semaphores, and monitors. When you hold more than one lock, recall the four deadlock conditions: mutual exclusion, hold and wait, no preemption, and circular wait. Breaking circular wait by imposing a lock ordering is the most practical of the four.
Under heavy contention, CAS-based lock-free structures avoid both deadlock and priority inversion, though they are much harder to get right. At larger scale, CSP and the Actor model replace shared memory with messages, and asynchronous I/O such as io_uring removes lock contention from the picture entirely. The real decision is matching the synchronization primitive and concurrency model to the workload: whether it is CPU-bound or I/O-bound, and how heavy contention actually is.
The next note covers memory management: virtual memory, paging, page replacement, and NUMA architecture.