Chapter 13: Concurrency

clean-code concurrency threading parallel-programming

Status: Notes complete
Difficulty: Hard
Time to complete: ~50 min read


Overview

Concurrency is one of the most deceptively difficult topics in software engineering. Code that looks correct can behave incorrectly under concurrent execution in ways that are nearly impossible to reproduce reliably. Robert Martin frames concurrency not just as a performance technique, but as a decoupling strategy: it separates what gets done from when it gets done.

This chapter does not teach you everything about concurrency — entire books exist for that. Instead, it gives you the principles and heuristics to write concurrent code that does not silently corrupt data, deadlock under load, or produce results you cannot explain.

Cross-references: ch09-unit-tests, ch10-classes, ch11-systems, ch07-error-handling


The Problem: What Bad Code Looks Like

// BAD — single-threaded web handler; blocks the entire server
public class WebServer {
    public void handleRequests() {
        while (true) {
            Socket connection = serverSocket.accept();  // blocks here
            processRequest(connection);                 // then blocks here — no other requests served
        }
    }
}

While processRequest() is running (maybe doing a slow database query or an external API call), every other incoming connection just waits. On a busy server, this is catastrophic. The fix is not merely “add a thread” — it requires understanding what you’re sharing between threads and what invariants you need to protect.


Core Principles

1. Why Concurrency? Decoupling What from When

Concurrency lets the system do useful work while waiting. Two distinct motivations:

Throughput: A web server handling 1,000 concurrent requests needs threads. Without concurrency, each request blocks the next one. With a thread pool, requests overlap their I/O wait time.

Responsiveness: A UI application downloads a file in the background while remaining interactive. The download logic runs on a worker thread; the UI thread is never blocked.

Data processing pipelines: Stage 1 reads files, Stage 2 parses them, Stage 3 writes results. Pipeline stages run concurrently — while Stage 3 writes record N, Stage 2 is parsing record N+1, and Stage 1 is reading record N+2.

// GOOD — concurrent web server with thread pool
public class WebServer {
    private final ExecutorService threadPool = Executors.newFixedThreadPool(50);
 
    public void handleRequests() {
        while (true) {
            final Socket connection = serverSocket.accept();
            threadPool.submit(() -> processRequest(connection)); // non-blocking dispatch
        }
    }
}
// C++ equivalent — thread pool dispatch
class WebServer {
    boost::asio::thread_pool pool{50};
public:
    void handleRequests() {
        while (true) {
            auto conn = acceptConnection();
            boost::asio::post(pool, [conn]() { processRequest(conn); });
        }
    }
};
# Python equivalent — ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
import socket
 
class WebServer:
    def __init__(self):
        self.executor = ThreadPoolExecutor(max_workers=50)
 
    def handle_requests(self):
        while True:
            conn, addr = self.server_socket.accept()
            self.executor.submit(self.process_request, conn)

2. Myths and Misconceptions

These are common beliefs that lead engineers to write dangerous concurrent code.

Myth A: “Concurrency always improves performance”

Concurrency only helps when there is wait time to overlap. If your code is CPU-bound and does no I/O, adding threads on a 4-core machine beyond 4 threads will hurt performance (context switching overhead). If your code does lots of I/O (database calls, network), threads let you overlap that wait — and throughput improves dramatically.

// BAD assumption — parallelizing pure CPU work with too many threads
// On a 4-core machine, 100 threads computing Fibonacci does not go 25x faster
// It goes slower due to context switching
ExecutorService pool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10000; i++) {
    pool.submit(() -> computeFibonacci(40)); // CPU-only, no I/O
}
 
// GOOD — match thread count to available cores for CPU-bound work
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);

Myth B: “Design does not change with concurrency”

The entire design changes. You must think about: which data is shared, which invariants span multiple operations, who owns what. A class that was perfectly fine in single-threaded code can be dangerously wrong in multi-threaded code without a single line change.

// BAD — looks correct, but getBalance/deduct are not atomic together
public class BankAccount {
    private int balance = 1000;
 
    public int getBalance() { return balance; }
    public void deduct(int amount) { balance -= amount; }
}
 
// Thread 1 and Thread 2 both call: if (account.getBalance() >= 500) account.deduct(500)
// Both threads read balance = 1000
// Both pass the check
// Both deduct 500 → balance ends at 0 instead of 500
// RACE CONDITION — balance went negative

Myth C: “Containers like web servers handle it, so I don’t have to think about it”

False. The container manages the thread lifecycle, but it does not know what shared state your code accesses. If two threads both call UserService.getCachedUser() and that cache is a HashMap (not a ConcurrentHashMap), you have a race condition — and the container cannot protect you.


3. Concurrency Defense Principles

3a. Single Responsibility Principle for Concurrency

Concurrency code has its own lifecycle, its own failure modes, and its own testing challenges. It is a separate concern. Do not mix business logic with synchronization code inside the same class.

// BAD — business logic mixed with synchronization
public class OrderProcessor {
    private final List<Order> pendingOrders = new ArrayList<>();
 
    public synchronized void addOrder(Order o) {
        pendingOrders.add(o);
        // ... 50 lines of business logic while holding the lock
        sendConfirmationEmail(o);  // slow I/O while synchronized!
    }
}
 
// GOOD — separate the concurrency concern
public class OrderQueue {
    private final BlockingQueue<Order> queue = new LinkedBlockingQueue<>();
 
    public void enqueue(Order o) { queue.put(o); }
    public Order dequeue() throws InterruptedException { return queue.take(); }
}
 
public class OrderProcessor {
    private final OrderQueue orderQueue;
 
    public void process(Order o) {
        // Pure business logic, no synchronization
        validateOrder(o);
        chargePayment(o);
        sendConfirmationEmail(o);
    }
}

3b. Limit the Scope of Shared Data

The more places in the code that touch shared mutable data, the more places a race condition can hide. Encapsulate shared data and provide the narrowest possible interface to it. Keep synchronized blocks small — only the lines that actually need synchronization.

// BAD — synchronized on the entire method while doing slow work
public synchronized void processUserActivity(String userId) {
    User user = userMap.get(userId);      // needs lock
    String report = generateReport(user); // slow, doesn't need lock — but we're holding it!
    emailReport(report);                  // very slow, doesn't need lock — still holding it!
    userMap.put(userId, user);            // needs lock
}
 
// GOOD — minimize synchronized scope
public void processUserActivity(String userId) {
    User user;
    synchronized (userMap) {
        user = userMap.get(userId);    // grab data quickly
    }                                  // release lock immediately
 
    String report = generateReport(user); // slow work outside lock
    emailReport(report);                  // slow I/O outside lock
 
    synchronized (userMap) {
        userMap.put(userId, user);     // write back quickly
    }
}
// C++ — std::lock_guard for scoped locking
class UserRegistry {
    std::unordered_map<std::string, User> userMap;
    std::mutex mtx;
public:
    User getUser(const std::string& id) {
        std::lock_guard<std::mutex> lock(mtx); // released at end of scope
        return userMap.at(id);
    }
 
    void updateUser(const std::string& id, const User& u) {
        std::lock_guard<std::mutex> lock(mtx);
        userMap[id] = u;
    }
};
# Python — threading.Lock for critical section
import threading
 
class UserRegistry:
    def __init__(self):
        self._users = {}
        self._lock = threading.Lock()
 
    def get_user(self, user_id: str):
        with self._lock:                  # released automatically
            return self._users.get(user_id)
 
    def update_user(self, user_id: str, user):
        with self._lock:
            self._users[user_id] = user

3c. Use Copies of Data

If a shared object is read frequently but rarely written, consider handing each thread its own copy. This eliminates synchronization entirely for the reading path.

// GOOD — avoid sharing by copying
public class ReportGenerator {
    // Instead of passing the shared userList (with synchronization),
    // take a snapshot at the start of the report generation
    public Report generate(List<User> sharedUserList) {
        List<User> snapshot;
        synchronized (sharedUserList) {
            snapshot = new ArrayList<>(sharedUserList); // defensive copy
        }
        // Work on the snapshot — no locks needed for the rest of the method
        return buildReport(snapshot);
    }
}

3d. Threads Should Be as Independent as Possible

The ideal thread is one that runs in complete isolation — it reads from its own input, writes to its own output, and never touches shared state at all. Structure work to partition data so that independent subsets are processed by independent threads.

// GOOD — partition data; each thread owns its shard, no shared state
public void processAllUsers(List<User> users) {
    int numThreads = Runtime.getRuntime().availableProcessors();
    List<List<User>> partitions = partition(users, numThreads);
    List<Future<?>> futures = new ArrayList<>();
 
    for (List<User> partition : partitions) {
        futures.add(executor.submit(() -> {
            for (User u : partition) {
                processUser(u); // each thread processes its own slice
            }
        }));
    }
    // Wait for all to complete
    for (Future<?> f : futures) f.get();
}

4. Know Your Library

Using the right concurrency primitives prevents you from re-inventing synchronization incorrectly.

Java (java.util.concurrent)

Class/InterfaceUse Case
ConcurrentHashMapThread-safe map without full lock contention
CopyOnWriteArrayListFrequently read, rarely written list
BlockingQueueProducer-consumer handoff
ExecutorServiceThread pool management
Future / CompletableFutureAsync computation result handling
ReentrantLockMore flexible than synchronized (tryLock, timed lock)
ReentrantReadWriteLockMany readers, few writers — concurrent reads
AtomicInteger / AtomicReferenceLock-free single-variable updates
CountDownLatchWait for N events before proceeding
SemaphoreLimit concurrent access to a resource pool
// GOOD — use ConcurrentHashMap instead of synchronized HashMap
// BAD
Map<String, Integer> counts = Collections.synchronizedMap(new HashMap<>());
// Entire map locked on each operation; get+put not atomic together
 
// GOOD
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.compute("key", (k, v) -> v == null ? 1 : v + 1); // atomic update

C++ (<thread>, <mutex>, <atomic>, <condition_variable>)

#include <thread>
#include <mutex>
#include <atomic>
#include <condition_variable>
 
// std::atomic for lock-free counter
std::atomic<int> requestCount{0};
requestCount.fetch_add(1, std::memory_order_relaxed);
 
// std::condition_variable for signaling between threads
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
 
// Producer signals:
{
    std::lock_guard<std::mutex> lock(mtx);
    ready = true;
}
cv.notify_one();
 
// Consumer waits:
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; }); // spurious wakeup safe

Python (threading, concurrent.futures, asyncio)

import threading
import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
 
# threading.Lock — mutual exclusion
lock = threading.Lock()
with lock:
    shared_counter += 1
 
# concurrent.futures — high-level thread/process pools
with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(fetch_url, urls))  # I/O-bound
 
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(compute_hash, data_chunks))  # CPU-bound
 
# asyncio — single-threaded cooperative concurrency for I/O
async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        return await asyncio.gather(*tasks)

Python’s GIL (Global Interpreter Lock) means threads do not run Python bytecode in parallel. Use ThreadPoolExecutor for I/O-bound work (threads release GIL during I/O), and ProcessPoolExecutor for CPU-bound work (separate processes bypass the GIL).


5. Know Your Execution Models

Three classic concurrent execution patterns appear in almost every system. Knowing them saves you from re-discovering their failure modes.

5a. Producer-Consumer

One or more producer threads create items and place them in a bounded buffer. One or more consumer threads take items and process them. Producers must block when the buffer is full; consumers must block when it is empty. The buffer is the shared resource.

// GOOD — Java BlockingQueue as the bounded buffer
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
 
public class JobQueue {
    private final BlockingQueue<Job> queue = new LinkedBlockingQueue<>(100); // capacity 100
 
    // Producer
    public void submitJob(Job job) throws InterruptedException {
        queue.put(job); // blocks if queue is full — no busy-waiting
    }
 
    // Consumer
    public Job takeJob() throws InterruptedException {
        return queue.take(); // blocks if queue is empty
    }
}
// C++ — std::queue + condition variables
#include <queue>
#include <mutex>
#include <condition_variable>
 
class BoundedBuffer {
    std::queue<int> buffer;
    const size_t maxSize;
    std::mutex mtx;
    std::condition_variable notFull, notEmpty;
public:
    explicit BoundedBuffer(size_t max) : maxSize(max) {}
 
    void produce(int item) {
        std::unique_lock<std::mutex> lock(mtx);
        notFull.wait(lock, [this] { return buffer.size() < maxSize; });
        buffer.push(item);
        notEmpty.notify_one();
    }
 
    int consume() {
        std::unique_lock<std::mutex> lock(mtx);
        notEmpty.wait(lock, [this] { return !buffer.empty(); });
        int item = buffer.front();
        buffer.pop();
        notFull.notify_one();
        return item;
    }
};
# Python — queue.Queue handles all synchronization internally
import queue
import threading
 
job_queue = queue.Queue(maxsize=100)
 
def producer():
    for job in generate_jobs():
        job_queue.put(job)       # blocks if full
    job_queue.put(None)          # sentinel to signal done
 
def consumer():
    while True:
        job = job_queue.get()    # blocks if empty
        if job is None:
            break
        process(job)
        job_queue.task_done()
 
threading.Thread(target=producer).start()
threading.Thread(target=consumer).start()

5b. Readers-Writers

A shared resource is read frequently but written infrequently. Multiple readers can safely read simultaneously, but a writer needs exclusive access. The failure mode is writer starvation: if readers constantly hold the lock, writers can never get in.

// GOOD — ReentrantReadWriteLock
import java.util.concurrent.locks.ReentrantReadWriteLock;
 
public class SharedConfig {
    private Map<String, String> config = new HashMap<>();
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
 
    public String get(String key) {
        rwLock.readLock().lock();       // multiple threads can hold read lock simultaneously
        try {
            return config.get(key);
        } finally {
            rwLock.readLock().unlock();
        }
    }
 
    public void set(String key, String value) {
        rwLock.writeLock().lock();      // exclusive — all readers and writers blocked
        try {
            config.put(key, value);
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}
// C++ — std::shared_mutex (C++17)
#include <shared_mutex>
#include <unordered_map>
 
class SharedConfig {
    std::unordered_map<std::string, std::string> config;
    mutable std::shared_mutex rwMutex;
public:
    std::string get(const std::string& key) const {
        std::shared_lock<std::shared_mutex> lock(rwMutex); // shared (read) lock
        return config.at(key);
    }
 
    void set(const std::string& key, const std::string& value) {
        std::unique_lock<std::shared_mutex> lock(rwMutex); // exclusive (write) lock
        config[key] = value;
    }
};
# Python — threading.RLock (or use a read-write lock from rwlock package)
import threading
 
class SharedConfig:
    def __init__(self):
        self._config = {}
        self._lock = threading.RLock()  # re-entrant lock; use rwlock for true read-write
 
    def get(self, key: str) -> str:
        with self._lock:
            return self._config.get(key)
 
    def set(self, key: str, value: str):
        with self._lock:
            self._config[key] = value

Python’s threading module does not have a built-in read-write lock. For true concurrent reads, use threading.Event + a counting semaphore, or install readerwriterlock from PyPI.

5c. Dining Philosophers (Deadlock and Livelock)

Five philosophers sit at a round table. Five forks lie between them. Each philosopher needs both the left and right fork to eat. If every philosopher picks up their left fork simultaneously, all are waiting for a right fork that no one will release: deadlock.

// BAD — symmetric pickup causes deadlock
public class Philosopher implements Runnable {
    private final Object leftFork, rightFork;
 
    public void run() {
        while (true) {
            synchronized (leftFork) {           // philosopher 0 picks left fork
                synchronized (rightFork) {      // philosopher 0 waits for right fork
                    eat();                       // deadlock: all waiting for right forks
                }
            }
        }
    }
}
 
// GOOD — break symmetry: one philosopher picks right fork first
public class Philosopher implements Runnable {
    private final Object firstFork, secondFork;
 
    public Philosopher(int id, Object[] forks) {
        if (id == forks.length - 1) {           // last philosopher picks in reverse order
            firstFork = forks[0];
            secondFork = forks[id];
        } else {
            firstFork = forks[id];
            secondFork = forks[id + 1];
        }
    }
 
    public void run() {
        while (true) {
            synchronized (firstFork) {
                synchronized (secondFork) {
                    eat();
                }
            }
        }
    }
}

Livelock is related: philosophers each pick up a fork, detect the other is taken, put theirs back, and retry — all in perfect synchrony. They’re not blocked (deadlock) but they never make progress either.

The general principle: deadlock requires four conditions (Coffman conditions):

  1. Mutual exclusion — resources cannot be shared
  2. Hold and wait — holding one resource while waiting for another
  3. No preemption — resources cannot be taken forcibly
  4. Circular wait — a circular chain of waiting exists

Break any one condition to prevent deadlock. Breaking circular wait by imposing an ordering on lock acquisition (like the philosopher solution above) is the most practical approach.


6. Beware Dependencies Between Synchronized Methods

If a class has more than one synchronized method, callers who invoke two of those methods together may be doing something that is not atomic at the class level — even though each method is individually synchronized.

// BAD — two synchronized methods; caller cannot make them atomic together
public class UserAccount {
    private int balance;
    private String lastTransactionId;
 
    public synchronized int getBalance() { return balance; }
    public synchronized void setBalance(int b) { balance = b; }
    public synchronized String getLastTransactionId() { return lastTransactionId; }
 
    // Caller code (BAD):
    // Thread 1 and Thread 2 both execute:
    //   if (account.getBalance() >= 100) {  // Thread 1 reads 150, Thread 2 reads 150
    //       account.setBalance(account.getBalance() - 100);  // RACE: both deduct, balance goes to 50 instead of 50
    //   }
    // Each method is synchronized; the combination is not.
}
 
// GOOD — provide a single synchronized operation that does the compound action
public class UserAccount {
    private int balance;
 
    public synchronized boolean deductIfSufficient(int amount) {
        if (balance >= amount) {
            balance -= amount;
            return true;
        }
        return false;
    }
}

The rule: if clients must call multiple synchronized methods together, move the compound operation into the class itself. Client-side locking (where callers lock on the object) is fragile and spreads the synchronization concern outside the class.


7. Keep Synchronized Sections Small

Every lock acquisition has cost: thread scheduling, memory barrier overhead, and contention when threads compete. Keep what’s inside a lock to the absolute minimum.

// BAD — entire method synchronized, including slow I/O
public class AuditLogger {
    private final List<String> log = new ArrayList<>();
 
    public synchronized void logEvent(String userId, String action) {
        String entry = buildLogEntry(userId, action);  // string formatting — doesn't need lock
        String enriched = fetchUserDetails(userId);    // HTTP call — DEFINITELY doesn't need lock
        log.add(enriched + ": " + entry);             // only this line needs the lock
    }
}
 
// GOOD — build the entry outside the lock, only protect the mutation
public class AuditLogger {
    private final List<String> log = new ArrayList<>();
 
    public void logEvent(String userId, String action) {
        String entry = buildLogEntry(userId, action);
        String enriched = fetchUserDetails(userId);   // slow I/O outside lock
 
        synchronized (log) {
            log.add(enriched + ": " + entry);         // only the mutation is locked
        }
    }
}

8. Writing Correct Shut-Down Code Is Hard

Graceful shutdown under concurrent execution is a separate, difficult problem. Threads waiting on a BlockingQueue.take() call will never wake up if the queue is empty and no one puts a new item in. Interrupt handling must be designed in from the start.

// BAD — worker threads deadlock on shutdown because they're blocked in queue.take()
public class WorkerPool {
    private final BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
    private final List<Thread> workers = new ArrayList<>();
 
    public void shutdown() {
        for (Thread t : workers) {
            t.interrupt(); // interrupt() wakes threads blocked in sleep/wait/take
        }
        // Problem: if a thread is not in an interruptible call, interrupt() is ignored
    }
}
 
// GOOD — poison pill pattern: send a sentinel value that tells threads to exit
public class WorkerPool {
    private static final Task POISON_PILL = new Task("SHUTDOWN");
    private final BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
    private final List<Thread> workers;
 
    private class Worker implements Runnable {
        public void run() {
            try {
                while (true) {
                    Task task = queue.take();
                    if (task == POISON_PILL) break; // clean exit
                    processTask(task);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt(); // restore interrupt flag
            }
        }
    }
 
    public void shutdown() {
        for (int i = 0; i < workers.size(); i++) {
            queue.put(POISON_PILL); // one pill per worker
        }
    }
}

Key rules for shutdown:

  • Think about shutdown in the design phase, not as an afterthought
  • Use the interrupt mechanism correctly — always restore the interrupt flag after catching InterruptedException
  • Use poison pills for BlockingQueue-based designs
  • Use ExecutorService.shutdown() + awaitTermination() when using the Executor framework

9. Testing Threaded Code

Concurrent bugs are time-dependent, platform-dependent, and load-dependent. They may occur once in a million executions. Standard unit tests that pass once give false confidence.

Guidelines for testing concurrent code:

  1. Treat spurious failures as threading issues — if a test fails once and then passes, do not dismiss it as a fluke. Investigate.

  2. Get the non-threaded code working first — test the business logic in isolation before introducing threads. A bug in the non-threaded logic will be nearly impossible to diagnose under concurrency.

  3. Make the threaded code pluggable — design the code so it can run with 1 thread, 2 threads, or 1,000 threads without changing the business logic.

// GOOD — pluggable thread count for testing
public class TaskProcessor {
    private final int threadCount;
    private final ExecutorService executor;
 
    public TaskProcessor(int threadCount) {
        this.threadCount = threadCount;
        this.executor = Executors.newFixedThreadPool(threadCount);
    }
    // In tests: new TaskProcessor(1) for predictability, new TaskProcessor(100) for stress
}
  1. Run with more threads than processors — with more threads than cores, the OS must context-switch more frequently, exposing race conditions that don’t appear when each thread runs on its own core uninterrupted.

  2. Run on different platforms — Windows and Linux have different thread scheduling behaviors. A race condition visible on one platform may be hidden on another.

  3. Instrument code to force failures (jiggling) — insert Thread.sleep(1), Thread.yield(), or Thread.currentThread().interrupt() at strategic points to change the scheduling order and expose races.

// GOOD — instrument to force a context switch at a critical point (test/debug only)
public void transfer(Account from, Account to, int amount) {
    synchronized (from) {
        Thread.yield(); // force context switch HERE during testing
        synchronized (to) {
            from.deduct(amount);
            to.credit(amount);
        }
    }
}

Remove instrumentation before deploying to production.


Comparison / Summary Table — Concurrent Execution Models

ModelPatternShared ResourceKey ProblemJava SolutionC++ SolutionPython Solution
Producer-ConsumerBounded buffer between stagesThe buffer (queue)Blocking on full/emptyBlockingQueuestd::queue + condition_variablequeue.Queue
Readers-WritersMany read, few writeThe shared dataWriter starvationReentrantReadWriteLockstd::shared_mutexthreading.RLock (partial)
Dining PhilosophersN resources, N+1 contendersShared resource poolDeadlock / livelockBreak symmetry in lock orderingBreak symmetry in lock orderingBreak symmetry in lock ordering

When to Apply / Common Exceptions

Apply concurrent design when:

  • I/O wait time is significant (database calls, HTTP requests, file I/O)
  • You need to keep a UI or service responsive while doing background work
  • You have embarrassingly parallel data-processing workloads

Be cautious when:

  • The code is purely CPU-bound — more threads than cores hurts on CPUs without hyperthreading
  • The shared state is complex — sometimes a simpler single-threaded design with an event queue is cleaner and safer
  • You’re in Python doing CPU-bound work — the GIL means threads don’t help; use ProcessPoolExecutor or asyncio

Prefer these over raw threads when possible:

  • ExecutorService / CompletableFuture (Java) over manually managing Thread objects
  • concurrent.futures (Python) over threading.Thread directly
  • std::async (C++) over raw std::thread for simple cases

Checklist

  • Concurrency code is in its own class, separate from business logic (SRP)
  • Shared mutable data is minimized; access points are clearly identified
  • synchronized / lock sections are as small as possible — no slow I/O inside locks
  • Thread-safe collections used (ConcurrentHashMap, BlockingQueue) instead of manually synchronized HashMap/ArrayList
  • No two synchronized methods called together from outside the class without an atomic compound operation inside the class
  • Shutdown logic designed from the start — interrupt flags restored, poison pills used
  • Tests run with 1, 2, and many-threads to expose race conditions
  • Spurious test failures treated as threading bugs, not flukes
  • Code reviewed by someone who understands the Java Memory Model / C++ memory model

Key Takeaways

  1. Concurrency decouples what from when — it is a design strategy, not just a performance hack.
  2. Concurrency is hard — not because of complexity in a single thread, but because of the combinatorial explosion of possible interleavings between threads.
  3. Keep concurrency code separate — it has its own lifecycle, challenges, and failure modes; don’t mix it with business logic.
  4. Minimize shared data — every piece of shared mutable state is a potential race condition. Prefer copies, immutable objects, and message passing.
  5. Use the right library primitivesBlockingQueue, ConcurrentHashMap, ReentrantReadWriteLock exist precisely to save you from implementing correct synchronization yourself.
  6. Know the three execution models — Producer-Consumer, Readers-Writers, and Dining Philosophers cover the overwhelming majority of concurrent design problems.
  7. Synchronized sections should be tiny — hold locks for the minimum possible time; never do I/O while holding a lock.
  8. Shutdown is a first-class concern — design it from day one; it is harder than startup.
  9. Test with stress — run tests with more threads than processors, on different platforms, and instrument code to force interleavings.
  10. Spurious failures are bugs — a test that fails intermittently is testing a race condition, not a fluke.

  • ch09-unit-tests — Testing principles apply to concurrent tests too; the FIRST principles help structure them
  • ch10-classes — SRP for classes applies equally to concurrency: keep synchronization separate
  • ch11-systems — Systems-level thinking about construction and concern separation
  • ch07-error-handlingInterruptedException handling is part of concurrency error handling
  • ch17-smells-and-heuristics — Several heuristics in Chapter 17 address concurrency smells directly

Last Updated: 2026-04-14