Subjects Questions Quizzes Pricing Search

Concurrency vs Parallelism

Threads, processes, and the event loop — what actually runs at the same time

Concurrency vs Parallelism

"Concurrent", "parallel", "thread", and "process" are four of the most misused words in software engineering. They are related, but they are not synonyms — and confusing them leads directly to real bugs: race conditions, corrupted shared state, and mysterious "this worked on my machine" failures under load.

This guide builds a precise mental model, then applies it to a real production bug from an async web server.


The One-Sentence Definitions

  • Concurrency is dealing with many things at once — tasks make progress by interleaving, taking turns on a shared resource.
  • Parallelism is doing many things at once — tasks execute literally simultaneously on separate hardware (CPU cores).

Rob Pike's famous framing: concurrency is about structure, parallelism is about execution. You can have concurrency on a single core (tasks take turns). You can only have parallelism with multiple cores.

CONCURRENT (1 core, interleaved):
  Task A: ██░░██░░██░░        ← A and B take turns
  Task B: ░░██░░██░░██          on the SAME core

PARALLEL (2 cores, simultaneous):
  Core 1 → Task A: ████████    ← A and B run at the
  Core 2 → Task B: ████████       exact same instant

Key insight: All parallelism is concurrency, but not all concurrency is parallelism. Two tasks interleaving on one core are concurrent but not parallel.


Why the Distinction Matters

The confusion is harmless until you have shared mutable state. Then it decides whether your code is correct:

  • If two tasks are truly parallel, they can touch the same memory at the same nanosecond → you need locks, atomics, or immutable data.
  • If two tasks are merely concurrent (interleaved), they never run at the same instant, but one can be paused mid-operation while the other runs → you still need to reason about which points are "safe to interrupt".

Both models can corrupt shared state. The difference is how.


Processes vs Threads

Concurrency and parallelism are concepts. Threads and processes are the operating-system mechanisms that implement them.

Process

A process is an independent program in execution with its own private memory space. Two processes cannot accidentally read each other's variables — the OS isolates them.

  • Isolation: a crash in one process does not corrupt another.
  • Communication is explicit and costly: pipes, sockets, shared-memory segments, or a message queue.
  • Heavyweight: creating a process copies a lot of OS bookkeeping.

Thread

A thread is a unit of execution within a process. All threads in a process share the same memory (the same heap, the same global variables).

  • Cheap: creating a thread is far lighter than a process.
  • Shared memory = fast communication — but also the source of race conditions, because two threads can touch the same variable.
  • A crash (segfault) can take down the whole process, all threads included.
Property Process Thread
Memory Private, isolated Shared within the process
Creation cost High Low
Communication IPC (pipes, sockets, queues) Shared variables (needs locks)
Crash blast radius Contained Whole process
Parallel on multiple cores? Yes Yes (OS-level threads)
Process A                         Process B
┌───────────────────────┐        ┌───────────────────────┐
  Shared heap / globals           Shared heap / globals 
   ┌────────┐ ┌───────┐                 ┌────────┐      
   Thread 1 Thread2                 Thread 1      
   └────────┘ └───────┘                 └────────┘      
└───────────────────────┘        └───────────────────────┘
   isolated address space           isolated address space

The Python Twist: the GIL

Python (specifically CPython) has a Global Interpreter Lock (GIL): only one thread executes Python bytecode at a time, even on a 16-core machine.

This means Python threads give you concurrency but not CPU parallelism for pure-Python code:

  • CPU-bound work (number crunching in Python) does not speed up with threads — the GIL serializes it. Use multiprocessing (separate processes, separate GILs) or a native extension that releases the GIL.
  • I/O-bound work (network calls, disk, database) does benefit from threads, because a thread releases the GIL while waiting on I/O, letting another thread run.
Workload Best tool in Python Why
CPU-bound (hashing, math) multiprocessing Separate processes bypass the GIL → true parallelism
I/O-bound (HTTP, DB, files) asyncio or threads Waiting releases control; one worker handles many waits
Mixed Processes for CPU + async for I/O Match the tool to the bottleneck

Rule of thumb: In Python, reach for processes for CPU work and async/threads for I/O work.


Async: Concurrency Without Threads

asyncio is a single-threaded, single-process concurrency model. There is exactly one thread running an event loop that juggles many coroutines.

A coroutine runs until it hits an await on something that isn't ready (a network read, a DB response). At that point it voluntarily suspends and hands control back to the event loop, which runs another ready coroutine. When the awaited I/O completes, the loop resumes the first coroutine.

Single thread, single event loop:

Coroutine A: ──run──[await I/O]···········[resume]──run──▶
                         loop is free here      
Coroutine B:            └──run──[await I/O]·······┘
                                     loop runs A again when its I/O is ready

This is cooperative concurrency: a coroutine only yields at an explicit await. Between two awaits, a coroutine runs to completion with no interruption — nothing else can touch shared state in that window. That property is what makes the next section's bug both possible and avoidable.

  • await a(); await b()sequential: b starts only after a fully completes.
  • asyncio.gather(a(), b())concurrent: both are scheduled before either finishes; they interleave.

Neither is parallel. Both run on the one event-loop thread.


Case Study: One Database Session, Two Coroutines

Here is a real bug that this model predicts exactly.

An async web handler used a single SQLAlchemy AsyncSession (call it db) and tried to speed up three independent queries with asyncio.gather:

# BROKEN — three coroutines share ONE session
top_subjects, top_questions, categories = await asyncio.gather(
    catalog_service.get_top_subjects(db),   # coroutine 1
    catalog_service.get_top_questions(db),  # coroutine 2  ← same db!
    catalog_service.get_categories(db),     # coroutine 3  ← same db!
)

It crashed with:

sqlalchemy.exc.InvalidRequestError: This session is provisioning a
new connection; concurrent operations are not permitted

Why? An AsyncSession wraps one logical database connection and is not safe for concurrent use. asyncio.gather schedules all three coroutines before any completes. While coroutine 1 is suspended mid-await (acquiring the connection), the event loop runs coroutine 2, which calls .execute() on the same half-initialised session → the session's guard fires.

Note this is not a parallelism bug — nothing ran on two cores. It is a concurrency bug: interleaving exposed a moment when the shared session was in an inconsistent in-between state.

Two correct fixes

1. Run them sequentially (simplest; the queries are fast):

top_subjects  = await catalog_service.get_top_subjects(db)
top_questions = await catalog_service.get_top_questions(db)
categories    = await catalog_service.get_categories(db)

While each await waits on the database socket, the event loop still serves other users' requests — each of which has its own session. You keep concurrency across requests while using each session sequentially.

2. Give each coroutine its own session (only if you truly need overlap):

async with async_session_factory() as db1, async_session_factory() as db2:
    a, b = await asyncio.gather(query_one(db1), query_two(db2))

Two sessions = two connections from the pool = genuinely independent work.

The rule: one AsyncSession = one connection = strictly sequential use. Never hand the same session to gather, create_task, or any two coroutines that can run at the same time.


Many Users, One Server

A natural follow-up: if the server is single-threaded, how does it serve thousands of simultaneous users?

Each incoming request gets its own session and connection, and the event loop interleaves them. While one request waits on the database, the loop advances others.

User 1  session_A 
User 2  session_B ├─▶ each borrows one connection from a shared pool
User 3  session_C 

Connection pool (e.g. size 5, overflow 10  up to 15 connections):
  [c1][c2][c3]  [c15]    borrowed per active request, returned on response
  request #16 waits for a connection to free up (automatic back-pressure)

The "one session is sequential" rule applies within a single session. Different sessions run fully concurrently against the database over separate connections. That is how one thread comfortably handles high concurrency for I/O-bound work.


Mental Model Summary

  1. Concurrency = interleaving (taking turns). Parallelism = simultaneous (multiple cores). All parallelism is concurrency; the reverse is false.
  2. Processes are isolated (separate memory); threads share memory within a process.
  3. CPython's GIL means Python threads don't give CPU parallelism — use processes for CPU-bound work, async/threads for I/O-bound work.
  4. asyncio is single-threaded cooperative concurrency: one event loop, coroutines yield at await.
  5. Shared mutable state is the enemy in both models. A single DB session, like a single connection, must be used sequentially — never across concurrently scheduled coroutines.

Ready to test your knowledge?

Practice questions