← System Design Concepts

Interview Brush-Up · Scaling and state

Concurrency vs parallelism: same-sounding words, different problems

In one line: Concurrency keeps many tasks moving with what you have; parallelism adds workers to run them at once.

Concurrency vs parallelism: one worker switching, or many workers at once

“What’s the difference between concurrency and parallelism?” It’s a common interview question, and most answers stop at “parallelism runs things at the same time”. That’s half the answer. The two words sound alike, but they solve two different problems, and picking the wrong one is how you end up with a service that’s slow for no obvious reason.

Concurrency: one worker, many tasks moving

Say you have three tasks, A, B and C, and one person to do them. They spend some time on A, move to B, switch back to A, then move to C. At no moment are two tasks being worked on at once. But after a while, all three have moved forward.

One worker switching between tasks A, B and C

That’s concurrency: structuring the work so many tasks make progress, even with one worker. A single CPU core does exactly this. The operating system gives each thread a tiny time slice and switches between them so quickly that they all seem to run together.

Parallelism: more workers, same moment

Now hand the three tasks to three people. All three run at the same time.

Three workers running tasks A, B and C at the same time

That’s parallelism: adding more resources (cores, processes, machines) so work truly happens simultaneously. It needs hardware that can do more than one thing at once. On a single core, parallelism isn’t possible.

Rob Pike, one of the creators of Go, put it in one line:

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

Concurrency is about how you structure the work. Parallelism is about how it executes.

They’re independent ideas

Because they answer different questions, you can have either one without the other:

Not parallel Parallel
Not concurrent One task, one core, start to finish One big computation split across cores (like a GPU adding millions of numbers in one step)
Concurrent Many tasks taking turns on one core (a Node.js server, Python’s asyncio) Many tasks spread over many cores, each core switching between its share

Real systems usually sit in the bottom-right cell. That leads to the question that actually matters.

Parallelism sounds better. Is it?

It depends on what your tasks spend their time doing.

Suppose you have 100 tasks and 4 CPU cores. You can’t run all 100 in parallel; at most 4 run at any moment. So the scheduler does both: each core runs one task at a time and switches between its share of the other 96. All 100 keep progressing.

100 tasks on 4 cores: 4 run at once, each core switches between its 25

Whether that’s fast depends on the kind of work.

When tasks mostly wait: concurrency wins

Most backend work is I/O-bound. A request handler calls the database, waits 50 ms, calls a payment API, waits 200 ms. While it waits, the CPU is doing nothing. Concurrency fills those gaps: while one task waits, another runs.

Here are 100 API calls that each take 200 ms, made one after another:

import time


def call_api(i: int) -> int:
    time.sleep(0.2)  # a 200 ms network call that blocks
    return i


start = time.perf_counter()
results = [call_api(i) for i in range(100)]
print(f"{len(results)} calls in {time.perf_counter() - start:.2f}s")
100 calls in 20.07s

And the same 100 calls made concurrently with asyncio, on a single thread:

import asyncio
import time


async def call_api(i: int) -> int:
    await asyncio.sleep(0.2)  # the same 200 ms call, but it gives way while waiting
    return i


async def main():
    start = time.perf_counter()
    results = await asyncio.gather(*(call_api(i) for i in range(100)))
    print(f"{len(results)} calls in {time.perf_counter() - start:.2f}s")


asyncio.run(main())
100 calls in 0.22s

That’s about 90× faster with no extra cores. Nothing ran in parallel. The program just stopped wasting the waiting time. This is why a single Node.js process, which runs your JavaScript on one thread, can serve thousands of connections.

When tasks mostly compute: parallelism wins

Now take CPU-bound work: resizing images, hashing passwords, crunching numbers. There’s no waiting to fill. Switching between tasks on one core doesn’t make the total any shorter. It only adds the cost of switching. The only way to finish sooner is more cores.

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    """Deliberately CPU-heavy: no waiting, just computation."""
    return sum(all(n % d for d in range(2, int(n**0.5) + 1)) for n in range(2, limit))


if __name__ == "__main__":
    jobs = [300_000] * 8

    start = time.perf_counter()
    serial = [count_primes(j) for j in jobs]
    print(f"1 core:  {time.perf_counter() - start:.1f}s")

    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        parallel = list(pool.map(count_primes, jobs))
    print(f"4 cores: {time.perf_counter() - start:.1f}s")

On my laptop:

1 core:  4.4s
4 cores: 1.6s

Two things to notice:

  • It’s about 2.7× faster, not 4×. Starting processes, moving data between them, and the CPU often running a little slower when every core is busy all eat into the gain. Parallelism always has overhead.
  • It uses processes, not threads. In the standard CPython build, the Global Interpreter Lock (GIL) lets only one thread run Python code at a time. Swap ProcessPoolExecutor for ThreadPoolExecutor above and the run takes 4.4s, no faster than one core. Threads gave concurrency but no parallelism. (Python 3.13 and later offer an optional free-threaded build without the GIL, but the default build still has it.)

Parallelism has a ceiling

Adding cores only speeds up the part of the work that can be split. If 10% of a job must run in order (reading the input, merging the results), then even with unlimited cores the job can never be more than 10× faster. This is Amdahl’s law, and it’s why “just add more machines” stops working sooner than people expect.

Which one do you need?

Your tasks mostly… Reach for In practice
Wait (network, disk, database) Concurrency asyncio, threads, an event loop, non-blocking I/O
Compute (CPU-heavy) Parallelism Multiple processes, more cores, more machines
Both Both A few worker processes (often one per core), each running an async event loop

The last row is how most production web servers are set up: parallel across cores, concurrent within each one.

Checklist

  • ✅ Concurrency = many tasks making progress with the resources you have.
  • ✅ Parallelism = more resources so tasks run at the same time.
  • ✅ First ask: do my tasks wait or compute?
  • ✅ Waiting work: concurrency gives big wins without extra hardware.
  • ✅ Computing work: only more cores help, and overhead and Amdahl’s law cap the gain.
  • ✅ In standard Python, CPU-bound parallelism needs processes, not threads.

It’s not that one is better than the other. It’s about which one suits the problem in front of you.