Concurrency vs Parallelism: How Computers Juggle Tasks

By Brexis Wazik 10 min read -

Right now, your computer is doing dozens of things at once. Music is playing, a file is downloading, messages are syncing, and somewhere a web server you’ll never see is answering thousands of strangers in the same breath. None of that “at once” is quite what it looks like.

Behind the magic is a small set of ideas that every serious engineer eventually has to understand cold. Get them right and your programs fly. Get them wrong and you get bugs that vanish the moment you look at them.

This is the field guide. We’ll start from the very basics and build up to the patterns that run the modern internet.

Why this matters

The two most expensive bugs in software are the ones that only show up sometimes. A counter that’s off by three. A server that freezes once a week at 3 a.m. A test that passes on your laptop and fails in production. Almost all of them trace back to misunderstanding how tasks share time and memory.

Knowing this stuff lets you do three things most developers can’t:

  • Pick the right tool so your code is fast instead of accidentally slow.
  • Avoid races and deadlocks instead of debugging them at midnight.
  • Know when more cores will help and when they’re a waste of money.

You don’t need a PhD. You need a clear mental model. Here it is.

Concurrency vs parallelism: the distinction that fixes everything

People use these words as if they mean the same thing. They don’t, and getting this clear early makes everything else easier.

Concurrency is about structure. It means organizing a program so many tasks can make progress over overlapping time. Each task moves forward a little, then the next one does, then back to the first. They’re all “in flight,” but at any single instant only one might actually be running. A single-core computer pulls this off through time-slicing: switching between tasks so fast it looks simultaneous.

Parallelism is about execution. It means literally running multiple tasks at the exact same instant. That requires multiple cores (a core is one processing unit that runs instructions). Two cores can each run a task in the same nanosecond.

The coffee shop that explains it all

Picture a coffee shop with one barista handling two orders. They start an espresso, and while it brews they steam milk for the other order, then come back. Both orders are progressing, but only one pair of hands is ever moving. That’s concurrent but not parallel.

Now add a second barista. Both orders get worked on at the same moment. That’s parallel.

One core is one barista juggling by switching. Two cores are two baristas genuinely working side by side.

Rob Pike, who designed the Go programming language, put it memorably: “Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.”

The key insight: concurrency enables parallelism but doesn’t require it. You can be concurrent on one core (time-slicing), and you can be parallel without elaborate structure (splitting one giant sum across cores). They’re related cousins, not twins.

Which tool to use depends on what’s slowing you down

Before you choose anything, ask one question: what is my program actually spending its time doing? There are two flavors of work, and they want opposite solutions.

I/O-bound work is dominated by waiting. “I/O” means input/output: reading a disk, calling a network service, querying a database. While your program waits 100 milliseconds for a database to reply, the CPU sits idle doing absolutely nothing. Concurrency is a massive win here. During the wait, let another task use the CPU. This works even on a single core.

CPU-bound work is dominated by computation: hashing passwords, resizing images, crunching numbers. There’s no waiting to hide. The CPU is busy the whole time. Concurrency alone buys you nothing. To go faster you need true parallelism, which means more cores doing real work at once.

Here’s the cheat sheet:

AspectI/O-boundCPU-bound
BottleneckWaiting (disk, network, DB)Calculation
Is the CPU busy?Mostly idle, waitingFully busy
Best toolAsync / event loop or threadsMultiple processes / parallel threads
Helped by more cores?Not much, hiding waits is enoughYes, that’s the whole point

Match the tool to the bottleneck and most performance problems solve themselves.

Three ways to do many things at once

There are three common models, and the differences matter.

Processes are independent running programs, each with its own private memory. Two processes can’t corrupt each other’s data because they’re isolated, and if one crashes the others survive. The downside: they’re heavyweight (slow to create), and talking between them needs IPC (inter-process communication: pipes, sockets, shared-memory segments), which is extra work.

Threads are execution streams inside a single process. Multiple threads share the same memory. They’re lightweight, fast to create, and they communicate just by reading and writing shared variables. But that shared memory is exactly what causes the nastiest bugs, because the operating system’s scheduler can interrupt (or “preempt”) a thread at almost any instruction.

Async / event loops run a single thread that picks up ready tasks and runs each a piece at a time. Tasks cooperatively give up control at marked pause points (often the keyword await). Because a task is never interrupted mid-step, there are far fewer races. The catch: one greedy task that never yields freezes everything.

A real example: how Node.js cheats

Node.js runs your JavaScript on a single thread with an event loop. Underneath sits a C library called libuv. For network I/O, libuv asks the operating system’s native facilities (epoll on Linux, kqueue on macOS, IOCP on Windows) to notify it when data is ready, no extra threads needed.

But some operations can’t be done asynchronously by the OS, like file access, crypto hashing, and DNS lookups. For those, libuv quietly uses a small thread pool of four threads by default (tunable with UV_THREADPOOL_SIZE). When a worker finishes, its result is queued back onto the event loop.

So “single-threaded Node” secretly keeps worker threads around for the blocking jobs. The clean abstraction hides a practical compromise, which is a theme you’ll see everywhere.

The core danger: race conditions

A race condition is when your program’s correctness depends on the timing of how threads interleave, and some interleavings produce wrong answers. The name comes from threads racing each other to touch shared data.

The classic example everyone must understand is the lost update. Consider counter++, adding one to a shared counter. It looks like one step, but the CPU actually does three: READ the value from memory, MODIFY it in a register, and WRITE it back. With two threads and counter = 0:

Thread A           Thread B           counter in memory
READ  (sees 0)                        0
                   READ  (sees 0)     0
ADD -> 1                              0
                   ADD -> 1           0
WRITE 1                               1
                   WRITE 1            1   <-- should be 2!

Thread B read the old value before A wrote its result. One increment simply vanished. Run two threads each incrementing a shared counter a million times and you’ll get a final number less than two million, and a different wrong number every run.

Two terms make this precise:

  • The critical section is the code that touches shared data and must not be interleaved (here, the read-modify-write).
  • Mutual exclusion is the guarantee that only one thread at a time may be inside the critical section.

A warning worth tattooing somewhere: don’t assume counter++, i++, list.append, or a dictionary update is atomic (indivisible). It usually isn’t. Before you trust any fix, reproduce the lost update yourself. Seeing a number below two million makes it real in a way no paragraph can.

The toolbox: how you keep shared data safe

These are the low-level tools that keep shared state correct.

  • Lock / Mutex (short for “mutual exclusion”): a thread calls acquire() before the critical section and release() after. Only the holder proceeds; everyone else waits. Wrap counter++ in a lock and the result is always two million.
  • Semaphore: a counter that permits up to N threads at once. A mutex is just a semaphore with N=1. Use it to cap concurrency, like “at most 10 simultaneous database connections.”
  • Atomic operations: hardware-guaranteed indivisible operations such as compare-and-swap (CAS) or atomic fetch-add. An atomic increment can’t be split apart, so it fixes the counter without a lock, which is faster for simple cases.
  • Memory barriers and visibility: modern CPUs cache values in per-core caches and reorder instructions for speed. So even without a torn write, one thread’s update may not be visible to another. A memory barrier forces the update to be published across cores. This is why Java has volatile, C++ has std::atomic, and Go ships a race detector. Locks and atomics double as barriers.

The subtle trap: don’t assume a write in one thread is instantly seen by another without a lock, atomic, volatile, or barrier. Visibility is not free.

The three classic failures

Even with the right tools, three failure modes haunt concurrent code.

Deadlock

Deadlock is when two or more threads each hold a resource the other needs, and nobody lets go. Frozen forever. It requires all four Coffman conditions at once:

  1. Mutual exclusion - a resource can’t be shared.
  2. Hold and wait - a thread holds one resource while waiting for another.
  3. No preemption - resources are given up only voluntarily.
  4. Circular wait - a cycle where A waits on B waits on … waits on A.

The beautiful part: break any one condition and deadlock becomes impossible.

Thread A holds Lock1, wants Lock2
Thread B holds Lock2, wants Lock1
     (a circle = circular wait = stuck)

FIX: everyone always grabs Lock1 BEFORE Lock2.
     No circle can form. Deadlock gone.

The most practical fix is lock ordering: define one global order for acquiring locks and follow it everywhere. That kills the circular-wait condition for good.

Livelock

In a livelock, threads aren’t blocked. They’re busy running and changing state, yet make no real progress. It’s often caused by retry logic that’s too polite.

Picture two people meeting in a narrow hallway. Both step left to let the other pass. Both step right. Both step left again. They keep moving, never collide, and never get through. That’s livelock: motion without progress.

Starvation

Starvation is when one thread is perpetually denied a resource because others keep cutting ahead, like a low-priority thread that never gets scheduled, or a writer blocked forever by an endless stream of readers. The fix is fairness policies: locks that make threads take turns.

Amdahl’s Law: the hard ceiling on speedup

Adding more cores doesn’t give unlimited speedup, and Amdahl’s Law tells you exactly how much you can get. Let p be the fraction of your program that can run in parallel and s the number of processors:

Speedup = 1 / ( (1 - p) + p/s )

As s grows toward infinity, p/s shrinks to zero, so speedup approaches 1 / (1 - p). The serial (non-parallel) part sets a hard cap.

Here’s what that means in practice. Suppose 90% of your work can be parallelized (p = 0.9). The maximum speedup, even with infinite cores, is 1 / (1 − 0.9) = 10x. Never more. Push to 95% parallel and the cap becomes 20x. That last 5–10% of serial code is what limits you, not the number of cores.

There’s an optimistic counterpoint called Gustafson’s Law: in the real world, when you have more cores you usually tackle a bigger problem, and the parallel fraction grows with problem size. So practical scaling can beat Amdahl’s fixed-size pessimism.

The lesson either way: don’t throw cores at a problem without profiling first. Shave the sequential code, then parallelize what’s left.

Where modern code actually lives: higher-level models

Hand-written locks are error-prone, so most modern systems prefer safer abstractions that make whole categories of bugs impossible.

  • Message passing / Actors: no shared memory at all. Each “actor” has private state and a mailbox, and they communicate only by sending immutable messages. An actor handles one message at a time, so its state needs no locks. (Erlang, Elixir, Akka.)
  • CSP / Channels (Go): lightweight goroutines (tiny concurrent functions) pass values through channels instead of touching shared memory. Go’s motto: “Don’t communicate by sharing memory; share memory by communicating.” A channel both moves data and synchronizes, because a send waits until a receiver is ready.
  • async / await event loops (Node, Python’s asyncio): cooperative single-threaded concurrency, excellent for I/O-bound work. await yields control during a wait so other tasks run.
  • Thread pools: reuse a fixed set of workers instead of spawning a new thread per task. Tasks go on a queue; idle workers pick them up. Creating threads is expensive, and unbounded threads exhaust memory.
  • Producer-consumer with backpressure: the backbone of server work dispatch.
[Producers] ---> [ bounded thread-safe queue ] ---> [Consumers]

queue FULL  => producers block  (this is "backpressure")
queue EMPTY => consumers block  (wait for work)

Backpressure means the queue’s fixed size automatically slows fast producers when consumers can’t keep up. A built-in safety valve.

One more warning that bites people constantly: never run a CPU-heavy or blocking call inside an async event loop without yielding. It freezes the entire loop and starves every other task. Keep the loop’s tasks short and I/O-focused, and offload heavy compute to a worker.

The cheapest win of all: immutability

Code is thread-safe if it behaves correctly under concurrent access without extra synchronization. The cheapest way to get there is immutability: if data never changes after it’s created, there’s nothing to race on. No locks, ever.

This is why functional and message-passing styles scale so gracefully. The race you don’t create is the bug you never debug. When in doubt, prefer immutable data and copies over shared mutable state.

Python’s GIL: a real-world wrinkle

CPython, the standard Python, has historically had a Global Interpreter Lock (GIL), a single lock that lets only one thread run Python bytecode at a time. So Python threads don’t give you CPU-bound parallelism. For that you reach for the multiprocessing module (separate processes, separate memory, no shared GIL). Threads do help I/O-bound work, though, because the GIL is released while a thread waits on I/O.

The status as of 2025–2026 is changing fast. PEP 703, the “free-threaded” no-GIL proposal, was accepted in 2023. Python 3.13 (October 2024) shipped an experimental free-threaded build, and Python 3.14 (2025) moved free-threading to officially supported status. It’s still opt-in, carries roughly 5–10% single-thread overhead, and default-on remains years away.

The practical takeaway hasn’t changed yet: don’t use Python threads for CPU-bound work expecting a speedup. Use multiprocessing or the free-threaded build.

Common misconceptions

  • “Concurrent code automatically uses all my cores.” No. Concurrency is structure; whether it runs in parallel depends on the runtime and hardware.
  • “A single core means no concurrency.” No. Time-slicing gives you concurrency on one core.
  • counter++ is one operation, so it’s safe.” It’s three operations, and two threads can lose an update.
  • “If one thread writes, others see it instantly.” Not without a lock, atomic, volatile, or barrier. Visibility costs something.
  • “More cores always means more speed.” Amdahl’s Law caps you at 1/(1−p). Past a point, extra cores buy almost nothing.

How to use this

When you sit down to write or fix concurrent code, work through this checklist:

  1. Profile first. Decide whether your bottleneck is I/O-bound (waiting) or CPU-bound (computing). Everything else follows from this.
  2. Match the model. Use async or threads to hide I/O waits. Use multiple processes or parallel threads for real computation.
  3. Minimize shared mutable state. Prefer immutable data, message passing, or channels over hand-rolled locks. The less you share, the less can go wrong.
  4. When you must lock, order your locks. Pick one global acquisition order and follow it everywhere to make deadlock impossible.
  5. Bound your concurrency. Use thread pools and bounded queues with backpressure instead of spawning unlimited threads.
  6. Prove the bug before trusting the fix. Reproduce the lost update, then confirm the lock or atomic actually removes it.
  7. Turn on the race detector in CI. Tools like Go’s -race, ThreadSanitizer, and Java’s concurrency checkers catch problems your tests won’t.

Conclusion

If you remember one thing, make it this: concurrency is how you structure a program to deal with many tasks; parallelism is the hardware actually running them at once. Nearly every concurrency decision flows from telling those two apart and asking whether your real bottleneck is waiting or computing.

The deepest bugs in this space, the lost updates and frozen deadlocks, all come from sharing mutable memory. So the most powerful move isn’t a cleverer lock. It’s designing so there’s nothing to share in the first place.

That idea scales further than you’d expect. The same instinct that makes a single program correct under threads is exactly what makes a system of thousands of servers stay consistent across a network. Which raises the next question worth chasing: when your “shared memory” lives on machines an ocean apart, what does it even mean for them to agree? That’s where distributed systems begin.

Frequently asked questions

What is the difference between concurrency and parallelism?

Concurrency is about structure: organizing a program so many tasks make progress over overlapping time. Parallelism is about execution: literally running tasks at the same instant, which needs multiple CPU cores. Concurrency enables parallelism but does not require it.

What is a race condition?

A race condition is when your program's correctness depends on the exact timing of how threads interleave, and some orderings produce wrong answers. The classic case is two threads incrementing the same counter and losing updates.

When should I use threads versus async?

Use async or an event loop for I/O-bound work where you mostly wait on the network or disk. Use multiple processes or parallel threads for CPU-bound work that needs more cores doing real computation.

What is a deadlock and how do I prevent it?

A deadlock is when threads each hold a resource the other needs and nobody lets go, so they freeze forever. The most practical fix is lock ordering: always acquire locks in one global order, which breaks the circular-wait condition.

Why don't Python threads speed up CPU-bound work?

CPython's Global Interpreter Lock (GIL) lets only one thread run Python bytecode at a time, so threads can't run computation in parallel. Use the multiprocessing module or the new free-threaded build for CPU-bound speed.

What is Amdahl's Law?

Amdahl's Law gives the maximum speedup from parallelism: 1 / (1 - p), where p is the fraction of work that can run in parallel. A program that is 90% parallel tops out at 10x speedup no matter how many cores you add.

Continue reading

Related topics