jongkwan.dev
Development · Essay №080

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

Jongkwan Lee2026년 4월 28일13 min read
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

PropertyProcessThread
Memory spaceSeparate (its own address space)Shared (heap and data shared within a process)
StackSeparateSeparate (each thread has its own stack)
Creation costHigh (fork, memory copy)Low (only a new stack is allocated)
Context switchExpensive (TLB flush required)Cheap (same address space)
CommunicationRequires IPC (pipes, sockets, shared memory)Direct memory access (fast but dangerous)
StabilityOne crash does not affect other processesOne crash can take down the whole process

Memory layout comparison

Choosing a model in backend architecture

ModelWhere it is usedStrengthsWeaknesses
Multi-processNginx workers, PM2 ClusterStability, isolationMemory footprint, IPC overhead
Multi-threadedJava Spring, Go net/httpPerformance, resource sharingSynchronization complexity
Event loopNode.js, RedisSimplicity, low overheadPoor fit for CPU-bound work
Coroutines / lightweight threadsGo goroutines, Kotlin coroutinesHundreds of thousands running at onceHarder 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.

python
# 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: 700

Critical 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:

  1. Mutual exclusion: only one process is inside the critical section at a time
  2. Progress: if the critical section is empty, a waiting process must be able to enter
  3. 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.

go
// 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
    }
}
PropertyDescription
OwnershipYes -- only the acquiring thread may release
CountBinary (0 or 1)
PurposeProtecting a critical section
PitfallPriority inversion

Semaphore

A counting synchronization primitive. It caps how many holders may access a resource at once.

python
# 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
PropertyDescription
OwnershipNo -- anyone may signal/release
CountAny non-negative integer
PurposeLimiting resource count, signalling between processes
VariantsBinary semaphore (0/1), counting semaphore

The core difference between a mutex and a semaphore

text
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 ownership

Monitor

A high-level synchronization construct. It encapsulates a mutex plus condition variables to make synchronization safer.

java
// 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:

ConditionDescriptionExample
Mutual exclusionA resource is used by only one process at a timeDB row lock
Hold and waitA process holds one resource while waiting for anotherHolding lock A while waiting for lock B
No preemptionAn allocated resource cannot be forcibly taken backThe OS cannot reclaim a lock
Circular waitThe waiting relation forms a cycleA→B→C→A

Strategies for handling deadlock

1. Prevention

Break one of the four conditions at the source.

Condition brokenMethodDownside
Mutual exclusionMake the resource shareableOften impossible in practice
Hold and waitRequest every needed resource at onceWasted resources, possible starvation
No preemptionForcibly reclaim resourcesConsistency problems
Circular waitOrder resources and acquire only in ascending orderThe most practical option
python
# 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 += amount

2. 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

SituationRemedy
DB transaction deadlockLock timeout plus retry logic
Distributed system deadlockGlobal ordering, distributed lock manager
Circular microservice callsCircuit 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)

go
// 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:

text
CAS(address, expected_value, new_value):
    if *address == expected_value:
        *address = new_value
        return true
    else:
        return false
java
// 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

PropertyLock-basedLock-free
DeadlockPossibleImpossible
Priority inversionPossibleImpossible
Performance (no contention)ComparableSlightly faster
Performance (heavy contention)Degrades sharplyDegrades gradually
Implementation difficultyRelatively easyVery hard
Typical useGeneral synchronizationRedis, Java ConcurrentHashMap

The ABA problem

A well-known trap in CAS:

  1. Thread A reads the value "A"
  2. Thread B changes it A→B→A (the value is "A" again)
  3. 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).

go
// 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:

rust
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 Send trait marks types whose ownership can move between threads
  • The Sync trait 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.

javascript
// 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

Propertyread()/write()epollio_uring
System callsOne per I/OOnly for event detectionBatched submission
Data copiesuser↔kerneluser↔kernelZero-copy possible
Asynchronous supportNoneSemi-asynchronousFully asynchronous
Network + fileSeparate APIsNetwork onlyUnified API
OverheadHighMediumVery 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.

c
// 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.