Concurrency Primitives: Locks, Semaphores, and Channels
Concurrency bugs are among the hardest to reproduce and diagnose. They appear under load, disappear under a debugger, and often manifest as data corruption rather than crashes. Understanding the primitives used to coordinate concurrent execution is the foundation for writing concurrent code that doesn’t silently corrupt state.
The Problem: Shared Mutable State
When two threads operate on the same data simultaneously, the result depends on the exact timing of their operations. This timing is not deterministic - it depends on the scheduler, CPU speed, load, and factors outside your control.
The canonical example: a counter incremented by two threads.
counter = 0
def increment():
global counter
counter += 1 # looks atomic, isn't
counter += 1 compiles to three operations: read the current value, add 1, write the new value. If two threads execute this concurrently:
Thread 1: read counter (0)
Thread 2: read counter (0)
Thread 1: add 1 = 1
Thread 2: add 1 = 1
Thread 1: write 1
Thread 2: write 1
Both threads read 0, both compute 1, both write 1. The counter should be 2 but is 1. One increment was lost. This is a race condition.
The primitives below are different solutions to this problem.
Mutex: Mutual Exclusion
A mutex (mutual exclusion lock) ensures that only one thread can hold the lock at a time. Any thread that tries to acquire a held lock blocks until the holding thread releases it.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
with lock:
counter += 1 # only one thread in here at a time
The with lock block acquires the lock on entry and releases it on exit, even if an exception occurs. The three-operation sequence is now atomic from the perspective of other threads - no other thread can interleave.
The deadlock risk. If two threads each hold one lock and wait for the other’s lock, both block forever.
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread1():
with lock_a:
time.sleep(0.01) # simulate work
with lock_b: # waits for thread2 to release lock_b
pass
def thread2():
with lock_b:
time.sleep(0.01)
with lock_a: # waits for thread1 to release lock_a - deadlock
pass
The fix is consistent lock ordering: always acquire lock_a before lock_b everywhere. Both threads trying to acquire in the same order means one will succeed and the other will wait rather than deadlock.
Read-Write Locks
A plain mutex serializes all access - only one thread can read or write at a time. But if reads are much more common than writes and reads don’t conflict with each other, this is unnecessarily restrictive.
A read-write lock (RWLock) allows either multiple concurrent readers or one exclusive writer.
import threading
class SafeCache:
def __init__(self):
self._data = {}
self._lock = threading.RLock() # reentrant lock
def get(self, key):
# Multiple readers can hold a read lock simultaneously
with self._lock:
return self._data.get(key)
def set(self, key, value):
# Writer gets exclusive access
with self._lock:
self._data[key] = value
Python’s stdlib doesn’t have a dedicated RWLock, but many concurrent data structures benefit from the concept. In Java, ReentrantReadWriteLock is the standard implementation. In Go, sync.RWMutex provides RLock()/RUnlock() for reads and Lock()/Unlock() for writes.
Semaphore: Counting Permits
A semaphore maintains a count. Acquiring the semaphore decrements the count; releasing it increments. If the count is 0, acquiring blocks until another thread releases.
Where a mutex allows one thread, a semaphore allows N.
import threading
# Allow at most 5 concurrent database connections
db_semaphore = threading.Semaphore(5)
def query_database(sql):
with db_semaphore: # blocks if 5 connections already active
conn = db_pool.get_connection()
result = conn.execute(sql)
db_pool.return_connection(conn)
return result
Semaphores are the right tool for rate limiting concurrent access to a resource: thread pool limits, connection pool management, limiting concurrent external API calls.
A binary semaphore (count of 1) is functionally equivalent to a mutex, but there’s a semantic difference: a mutex is owned by the thread that acquired it (only that thread should release it). A semaphore has no ownership - any thread can release it. This makes semaphores useful for signaling between threads.
Condition Variables: Waiting for State
Sometimes a thread needs to wait until a condition is true, not just until a lock is available.
import threading
buffer = []
MAX_SIZE = 10
lock = threading.Lock()
not_full = threading.Condition(lock)
not_empty = threading.Condition(lock)
def producer():
with not_full:
while len(buffer) >= MAX_SIZE:
not_full.wait() # releases lock and sleeps until notified
buffer.append(produce_item())
not_empty.notify()
def consumer():
with not_empty:
while len(buffer) == 0:
not_empty.wait()
item = buffer.pop(0)
not_full.notify()
return item
wait() atomically releases the lock and suspends the thread. When another thread calls notify(), the waiting thread reacquires the lock and checks the condition again. The while loop (not if) is necessary because wake-ups can be spurious.
Channels: Communication Over Sharing
Channels take a different approach: instead of sharing memory and using locks to coordinate, threads communicate by passing messages through a channel. The Go language popularized this model with the maxim “do not communicate by sharing memory; instead, share memory by communicating.”
// Go example
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i // send to channel, blocks if channel full
}
close(ch)
}
func consumer(ch <-chan int) {
for value := range ch { // receives until channel closed
fmt.Println(value)
}
}
func main() {
ch := make(chan int, 5) // buffered channel, capacity 5
go producer(ch)
consumer(ch)
}
The channel owns the data at the moment of transfer. The producer writes to the channel and no longer owns the value. The consumer reads from the channel and now owns it. No two goroutines hold the same data simultaneously - the channel handles the ownership transfer.
Unbuffered channels (capacity 0) synchronize: the sender blocks until a receiver is ready, the receiver blocks until a sender is ready. Buffered channels decouple producer and consumer speed up to the buffer capacity.
Channels are not universally better than locks. For simple shared counters or cache access, a mutex is cleaner. Channels shine for pipelines, work distribution, and coordinating independent goroutines.
Which to Use
Mutex: protecting a shared data structure. Simple, direct, familiar in every language.
Read-write lock: when reads are frequent and reads don’t conflict with each other. A database query cache, a configuration store read by many threads and written rarely.
Semaphore: limiting concurrent access to a resource. Connection pools, rate limiting, thread count caps.
Condition variable: waiting for a state change in shared data. Producer-consumer, bounded buffers, waiting for initialization.
Channel: coordinating independent units of work through message passing. Pipelines, work queues, fan-out/fan-in patterns.
The bugs that come from getting this wrong - race conditions, deadlocks, livelocks, starvation - are exactly the bugs that are hard to reproduce and easy to introduce. The primitives exist precisely because unsynchronized concurrent access doesn’t produce obvious errors; it produces occasional wrong results under specific timing conditions. Using the right primitive, used correctly, makes concurrent behavior deterministic regardless of scheduling.