Why Concurrency is Hard
Concurrency introduces non-determinism. Without proper synchronization, you get race conditions, deadlocks, and starvation.
This guide covers the fundamental patterns used to serialize access to shared resources.
1. Mutex (Mutual Exclusion)
The simplest synchronization primitive. Only one thread can hold the lock at a time.
Use Case: Protecting a shared counter or critical section.
package main
import (
"sync"
)
type SafeCounter struct {
mu sync.Mutex
v map[string]int
}
func NewSafeCounter() *SafeCounter {
return &SafeCounter{v: make(map[string]int)}
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock() // Lock before accessing map
defer c.mu.Unlock() // Ensure unlock happens even if panic
c.v[key]++
}
[!WARNING] Forget to unlock? Deadlock. Using
deferin Go ortry-finallyin Java/Python is critical.
2. Semaphore
A permit counter that allows up to N concurrent accesses. Unlike a mutex, ownership rules depend on the semaphore API.
Use Case: Limiting concurrent database work. This is a concurrency limit, not a requests-per-second rate limit.
3. Read-Write Lock (RWMutex)
Allows multiple readers OR one writer.
Use Case: Caches where reads vastly outnumber writes.
- RLock(): Allows concurrent readers, but blocks for an active writer and, in Go, when a writer is waiting to acquire the lock.
- Lock(): Blocks until all readers finish. Exclusive access.
4. Monitor Pattern
Combines a Mutex with Condition Variables (Wait/Notify).
Pattern:
- Acquire Lock.
- Check Condition. While false, Wait (release lock, sleep), then recheck after waking.
- Do work.
- Notify waiting threads.
- Release Lock.
Java Example (Synchronized):
public synchronized void produce(Item item) throws InterruptedException {
while (queue.isFull()) {
wait(); // Releases lock and sleeps
}
queue.add(item);
notifyAll(); // Wakes up consumers
}
5. Communicating Sequential Processes (CSP)
Popularized by Go. "Do not communicate by sharing memory; share memory by communicating."
Key Idea: Use Channels to pass data between Goroutines. No explicit locks.
package main
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Send jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
for j := 1; j <= 5; j++ {
<-results // Wait for every submitted job before main exits.
}
}
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
6. Actor Model
Popularized by Erlang and Akka (Scala/Java).
Key Concepts:
- Actor: Fundamental unit of computation.
- Mailbox: Actors communicate only by sending messages.
- State: Private, never shared. Modified only by processing messages.
- No Locks: Messages are processed sequentially.
Comparison
| Pattern | Shared State? | Locking? | Best For |
|---|---|---|---|
| Mutex | Yes | Explicit | Fine-grained state protection |
| CSP (Channels) | Avoided by ownership discipline | Channel synchronization | Stream processing, Pipelines |
| Actor Model | Actor-local by design | Serialized mailbox handling | Distributed systems, Fault tolerance |
| Atomics | Yes | Hardware | Counters, Flags (High Perf) |
Related Concepts
- Process vs Thread
- Distributed Transactions — compensating actions differ from shared-memory locking
- Event Sourcing
Worked bound: concurrency is not throughput
Suppose a worker usually spends 200 ms on a downstream call. Ten permits allow at most ten in-flight calls, corresponding to roughly 50 completions/s under steady conditions. If that call slows to two seconds, the same ten permits yield roughly five completions/s. A semaphore prevents unbounded in-flight work; it does not promise a fixed throughput.
Bound the waiting queue too. Give waiting callers cancellation and a deadline. Otherwise memory and tail latency can grow even though active concurrency looks healthy. Test cancellation while waiting for a permit, failure after acquiring it, and shutdown with work in flight. Every successful acquisition must have exactly one release.
Go’s synchronization package documents the primitives. The Go memory model explains the ordering needed for shared data. Sending a pointer through a channel does not make later unsynchronized mutations safe.
Keep reading
Process vs Thread
Understanding the fundamental units of execution in operating systems. A deep dive into memory models, context switching costs, and concurrency implementation details.
Docker Internals
What actually is a container? Just a Linux process with a mask on. Deep dive into Namespaces, Cgroups, and Union Filesystems (OverlayFS).
Event Sourcing & CQRS
Comprehensive guide to Event Sourcing and Command Query Responsibility Segregation (CQRS) patterns, covering immutable event logs, state reconstruction, read/write separation, and real-world implementations in banking, e-commerce, and audit systems.
Ticket Booking: Durable Holds and Concurrent Checkout
A worked seat-booking design that prevents stale holds from becoming sales and handles payment completion after expiry.