Skip to content
Happy Programming Guide
Start learning
Programming Basics

Concurrency and Multithreading

The difference between concurrency and parallelism, when threads help and when they do not, and the two bugs — race conditions and deadlocks — you must understand.

A processor chip photographed up close

Concurrency means a program deals with several things at once. Parallelism means it actually does several things at the same instant. They are not the same, and knowing which one your problem needs decides whether threads will help or just add bugs. This guide covers the distinction, the two failure modes, and how the common languages approach it.

The distinction#

A cook making three dishes is concurrent: they start the rice, and while it boils they chop vegetables. One person, several jobs in flight.

Three cooks each making one dish are working in parallel: three jobs genuinely happening at the same moment.

Concurrency is a way of structuring a program. Parallelism is a property of how it executes. A concurrent program can run on one processor core; a parallel one needs several.

Which kind of work is it?#

This single question decides your approach:

I/O-bound CPU-bound
Spends its time waiting calculating
Examples network requests, files, databases image processing, maths, compression
Best tool async, or threads multiple processes
Gains from more cores little a lot

Downloading a hundred pages is I/O-bound: the program spends almost all its time waiting for replies. Doing them concurrently is a huge win, and one core is plenty. Resizing a hundred images is CPU-bound: the only way to go faster is more cores.

I/O-bound work#

Python
import asyncio, aiohttp

async def fetch(session, url):
    async with session.get(url) as response:
        return len(await response.text())


async def main(urls):
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(*(fetch(session, u) for u in urls))
    return results


# Twenty requests take about as long as the slowest one,
# not the sum of all twenty.

The same idea in JavaScript, which is concurrent by default:

JavaScript
const results = await Promise.all(urls.map(u => fetch(u).then(r => r.text())));

Nothing here runs in parallel. There is one thread, and it switches to other work whenever something is waiting. That is enough, because waiting is what the program was doing anyway.

CPU-bound work#

Python
from concurrent.futures import ProcessPoolExecutor


def heavy(n):
    return sum(i * i for i in range(n))


with ProcessPoolExecutor() as pool:
    results = list(pool.map(heavy, [10_000_000] * 4))

Separate processes, so each gets its own core. The cost is that data must be copied between them, which makes this a poor choice when the work per item is small.

Race conditions#

The first of the two bugs. Two threads read and write the same value, and the result depends on timing:

Python
import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1        # read, add, write - three separate steps


threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)   # not 400000, and a different number each run

counter += 1 looks like one operation and is three. Two threads can both read 5, both compute 6, and both write 6 — one increment vanishes.

The fix is a lock, so only one thread can be inside the critical section at a time:

Python
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

The better fix is usually to avoid shared mutable state altogether — have each thread compute its own total and combine the results at the end. A race condition that cannot occur beats one that is locked against.

Deadlocks#

The second bug. Two threads each hold something the other needs, and neither can continue:

Python
lock_a = threading.Lock()
lock_b = threading.Lock()

def task_one():
    with lock_a:
        with lock_b:      # waits for task_two to release lock_b
            pass

def task_two():
    with lock_b:
        with lock_a:      # waits for task_one to release lock_a
            pass

Neither thread errors. The program simply stops, which makes deadlocks harder to diagnose than crashes.

Two reliable defences: always acquire locks in the same order everywhere in the program, and use a timeout so a stuck thread eventually gives up and reports it.

Python
if lock_a.acquire(timeout=5):
    try:
        ...
    finally:
        lock_a.release()
else:
    raise TimeoutError("Could not acquire lock_a")

How the languages differ#

Language Approach
Python asyncio for I/O, multiprocessing for CPU; threads limited by the GIL
JavaScript Single-threaded with an event loop; Web Workers for CPU work
Java Real threads, plus virtual threads in recent versions
C# async/await for I/O, Task and Parallel for CPU
Go Goroutines and channels — communicate rather than share
Rust The compiler prevents data races outright

When not to reach for it#

Concurrency makes programs harder to reason about, harder to debug and harder to test. Bugs appear intermittently and often vanish when you add a print statement. Before adding it, ask:

  • Is the program actually too slow? Measure before optimising.
  • Is the bottleneck what you think? Profile it.
  • Would a better algorithm do more? Often it would, with none of the complexity.
  • Is caching an option? Not doing the work at all beats doing it faster.

Questions people ask#

Is async faster than threads?

For I/O with many concurrent operations, usually yes — each task costs far less memory than a thread, so thousands are practical. For a handful of operations the difference is negligible.

What is a thread pool?

A fixed set of reusable threads that take work from a queue. Creating a thread is expensive, so reusing them avoids paying that cost repeatedly. Most languages provide one in the standard library.

How many threads should I use?

For CPU-bound work, roughly the number of cores. For I/O-bound work you can use many more, since most are waiting rather than working. Measure — the best number is workload-specific.

Why does my concurrent code work sometimes?

That is the signature of a race condition. The result depends on timing, and timing changes between runs and between machines. Look for shared state that more than one task writes to.

Where to go next#

What is an algorithm? Usually the bigger winRead next

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *