Python Runtime Models
Sync workers, threads, async and processes are four different services with the same source code — and the GIL explains which is which.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Which Python concurrency model does my service actually use, and what does the GIL stop it from doing?
A Python service must handle concurrent requests. The framework and the server are chosen; the concurrency behaviour that follows from that choice is not yet understood.
Run the app with Gunicorn and a few workers. Python handles concurrency; if it is slow, add workers.
With synchronous workers, each worker serves exactly one request at a time, so N workers means N concurrent requests and request N+1 waits in the queue with no error and no signal (Worker Processes).
- With synchronous workers, each worker serves exactly one request at a time, so N workers means N concurrent requests and request N+1 waits in the queue with no error and no signal (Worker Processes).
- Adding workers multiplies memory and database connections: 16 workers with a pool of 10 each is 160 connections to a database configured for 100 (Connection Pools).
- Threads are added to increase parallelism for a CPU-bound endpoint and nothing improves, because CPython's global interpreter lock means one thread executes bytecode at a time.
- An async framework is adopted and a synchronous database driver stays, so every query blocks the event loop and the service is slower than the synchronous version it replaced.
- A single slow external call ties up a worker for its whole duration, so a dependency's latency becomes your concurrency limit (Timeouts).
What is actually happening
- CPython has a global interpreter lock: one thread executes Python bytecode at a time within a process. It is a property of the CPython implementation, not of the language — and an experimental free-threaded build exists (PEP 703), which is not the default and should not be assumed in production planning.
- The GIL is released around blocking I/O and inside many C extensions. So threads do give you real concurrency for I/O-bound work — waiting on a socket does not hold the lock — and give you no parallelism for pure-Python CPU work.
- Sync workers (WSGI) — Gunicorn's default
syncworker handles one request at a time per process. Concurrency equals worker count. Simple, isolated, and easy to reason about; a slow request costs exactly one worker. - Threaded workers —
gthreadin Gunicorn, or a threaded WSGI server. Each worker runs a pool of threads, so an I/O-bound service serves many more concurrent requests per process. CPU-bound work still serialises on the GIL. - Greenlet-based workers — gevent and eventlet monkey-patch the standard library so blocking calls yield cooperatively. Very high I/O concurrency with unchanged-looking code; the patching is invasive and interacts badly with some C extensions.
- Async (ASGI) —
asynciowith an ASGI server such as Uvicorn. One event loop per worker process, with the same rules as any single-loop runtime: one blocking call stops everything on that loop (Blocking the Event Loop). - Processes —
multiprocessingor separate worker processes are the way to get real CPU parallelism in CPython, because each process has its own interpreter and its own lock. - A running service is usually a combination: N processes, each with either a thread pool or an event loop. Both numbers matter, and neither is visible from application code.
What the GIL does and does not prevent
The GIL is stated more often than it is understood. The precise claim is narrow: within one CPython process, one thread executes Python bytecode at a time. It says nothing about waiting.
That distinction is the whole practical picture. While a thread waits on a socket, a file or a time.sleep, the lock is released and other threads run. So a threaded Python server genuinely serves many concurrent I/O-bound requests, and genuinely cannot use two cores for pure-Python computation in one process.
| Work | Threads in one process | Why |
|---|---|---|
| Waiting on a database query | Concurrent — scales well | The driver releases the GIL while blocked on the socket |
| Waiting on an HTTP call | Concurrent — scales well | Same: the lock is released around the blocking read |
| Reading a file | Concurrent | Blocking I/O releases the lock |
| Pure-Python loop over a large list | Serialised — no gain | Executing bytecode requires holding the lock |
| NumPy / compiled extension computation | Often parallel | Many C extensions release the lock around long computations |
Hashing a password (hashlib) | Often parallel | Implemented in C and releases the lock for the work |
| JSON parsing of a huge document | Mostly serialised | Interpreter work dominates and holds the lock |
Four deployments of one application
The command line is where the concurrency model is actually chosen. These four commands run the same code and produce services with different ceilings, different failure modes and different debugging stories.
Note the last line of each: the number that decides how many requests can be in flight at once, and what happens to the rest.
1# 1. Sync workers (WSGI). One request per worker at a time.2gunicorn app:wsgi --workers 83# concurrency = 8. A slow request costs exactly one worker.4# Easiest to debug; lowest concurrency per GB of memory.5 6# 2. Threaded workers (WSGI). Threads per worker for I/O overlap.7gunicorn app:wsgi --workers 4 --threads 8 --worker-class gthread8# concurrency = 4 x 8 = 32 for I/O-bound work.9# CPU-bound work still serialises inside each process (GIL).10# Module-level state is now shared between threads: thread safety matters.11 12# 3. Greenlets. Cooperative scheduling via monkey-patching.13gunicorn app:wsgi --workers 4 --worker-class gevent --worker-connections 100014# concurrency = thousands, for I/O-bound work, with sync-looking code.15# Cost: the standard library is patched underneath you.16 17# 4. Async (ASGI). One event loop per worker.18uvicorn app:asgi --workers 419# concurrency = very high, IF the whole path is async.20# One blocking call collapses that worker to one request at a time.21 22# In every case, the number that reaches the database is:23# workers x pool_size_per_worker x instances24# Check it against the database max_connections BEFORE deploying.Four different services. Nothing in the application source distinguishes them, which is why "how many concurrent requests can one instance handle" is a question about your process manager configuration and not about your code.
Mixing sync and async is the expensive mistake
The failure that costs the most is adopting an async framework while keeping a synchronous driver or client on the request path. The result performs worse than the synchronous deployment it replaced, because it has one loop per worker instead of one request per worker and the loop is blocked anyway.
The fix is either a fully async path or an explicit hand-off to a thread. Making the hand-off explicit is also what keeps it bounded — an unbounded executor just relocates the problem.
import requests # synchronous HTTP client
@app.get("/profile/{user_id}")
async def profile(user_id: str):
# This blocks the ENTIRE event loop for this worker.
# Every other in-flight request on this worker waits.
r = requests.get(f"https://crm.example.com/users/{user_id}", timeout=2)
return r.json()import httpx # async-capable client
client = httpx.AsyncClient(
timeout=2.0,
limits=httpx.Limits(max_connections=50), # bounded concurrency
)
@app.get("/profile/{user_id}")
async def profile(user_id: str):
r = await client.get(f"https://crm.example.com/users/{user_id}")
return r.json()
# When a library has no async version, hand it to a thread
# explicitly rather than pretending:
# result = await asyncio.to_thread(legacy_sdk.fetch, user_id)
# and bound the executor, or you have swapped one unbounded
# resource for another.An async def handler is a coroutine on a shared loop, not a thread. A synchronous call inside it holds the loop for its full duration, so a worker that could have had hundreds of requests in flight has one. The shared AsyncClient also gets you connection reuse and an explicit connection cap, which the per-call requests.get form has neither of (Keep-Alive and Connection Reuse).
How to build it
Most important first.
- Know your exact deployment shape: server, worker class, worker count, threads per worker. Concurrency per instance is the product, and almost nobody can state it from memory (Configuration: Separating Code From Environment).
- Classify the workload. Predominantly I/O-bound work benefits from threads, greenlets or async; CPU-bound work needs processes, or the work moved out of Python entirely into a library that releases the GIL (Computing or Waiting? in Performance).
- If you choose async, make the whole request path async. One synchronous database call, one
requests.get, onetime.sleepinside an async handler blocks that worker's entire loop. - Where a blocking call is unavoidable in async code, push it to a thread explicitly with
asyncio.to_threadorrun_in_executor, and bound that executor. - Size the connection pool as pool per worker times workers times instances and check it against the database's limit. This is the single most common Python capacity mistake (Connection Pool Exhaustion).
- Recycle workers periodically (Gunicorn's
max_requestswithmax_requests_jitter) to bound the effect of slow memory growth, and treat it as a mitigation rather than a fix (Memory Leaks in Backend Services). - Set a worker timeout so a stuck worker is replaced instead of silently reducing capacity, and make sure it is longer than your legitimate slowest request.
What can go wrong
- Worker starvation: all N workers busy on slow requests, so everything queues. Latency climbs with no errors and CPU looks low, because the workers are waiting rather than computing.
- Connection exhaustion at the database from the workers-times-pool multiplication, which usually appears first as a completely unrelated service failing to connect.
- One blocking call in an async handler collapsing an ASGI worker's throughput to something worse than the sync version.
- Copy-on-write memory savings from pre-fork eroding as the interpreter touches reference counts on shared objects, so per-worker memory is closer to full than expected.
- Gevent monkey-patching applied too late in startup, or interacting with a C extension that does not cooperate — producing hangs that are extremely hard to attribute.
- The mitigation failing:
max_requestsrecycling masking a leak until the leak grows faster than the recycle interval.
- In a threaded worker, module-level mutable state is shared across threads and needs real synchronisation; the GIL makes individual bytecodes atomic and does not make a read-modify-write sequence atomic (Backend Races).
- In an async worker, every
awaitis an interleaving point, exactly as in any single-loop runtime (The Node Event Loop). - Across pre-fork workers, anything in-process is per worker: an in-memory cache, a counter or a lock exists N times and coordinates nothing (Stateless Services).
- Forking after opening a database connection shares a socket across processes and corrupts it; connections must be created after the fork, which is why pool libraries expose post-fork hooks (Connection Pools).
- Process isolation is a real boundary between workers; threads within a worker share one address space and one set of module-level globals (Worker Processes).
- Module-level mutable state is shared by every request handled by that worker — a request-scoped value cached in a global is a cross-request leak, and in a threaded worker it is also a data race (Stateless Services).
- On an async worker, an expensive synchronous operation reachable by an unauthenticated request is a denial-of-service surface for the whole worker (Transport Validation).
- Pickle-based deserialization of untrusted data executes arbitrary code. This is a Python-specific and severe version of a general problem (Deserialization: Bytes to Objects).
- "The GIL makes Python slow." It prevents multiple threads from executing Python bytecode simultaneously. I/O-bound services are usually unaffected, because the lock is released while waiting.
- "Threads are useless in Python." They are useless for CPU-bound parallelism and genuinely effective for I/O-bound concurrency, which is what most backends are.
- "Async is faster." It raises concurrency for I/O-bound work. For CPU-bound work it changes nothing, and a mixed sync/async stack is often slower than the sync one.
- "Gunicorn workers are threads." The default worker is a process. Which worker class you configured decides whether there are threads at all.
- "We have 16 workers, so we can handle 16 requests per second." Workers bound *concurrency*, not rate. Throughput is workers divided by average request duration (Little's Law as Working Intuition from Performance).
Operating it
- Busy workers versus total workers is the primary saturation signal for a WSGI deployment; without it, saturation looks like "the database got slower".
- Request queue time — how long a request waited before a worker picked it up — separates "we are slow" from "we are full". Some servers expose it; a proxy-versus-app latency comparison approximates it (Why Is My API Slow?).
- For async workers, measure loop lag the same way you would in Node: a task scheduled at a known interval, measured for lateness.
- Database connections in use versus the server's limit, remembering the multiplication across workers and instances.
- Per-worker memory over time, especially after a deploy; pre-fork memory growth is per worker and multiplies.
- At 10x, sync workers become the constraint quickly: concurrency equals worker count, and worker count is bounded by memory per worker. Threads or async raise the ceiling per instance considerably for I/O-bound work.
- At 100x, the connection multiplication forces a connection proxy such as PgBouncer in front of the database, because the number of application processes now exceeds what the database can hold (Connection Pools).
- CPU-bound work does not scale with workers beyond the core count. Past that point the answer is a different execution location — a library that releases the GIL, a separate service, or a job queue (Background Jobs).
- Sync workers are the easiest thing in the world to debug — one request, one process, a readable stack trace — and have the lowest concurrency per unit of memory.
- Threads raise I/O concurrency cheaply and introduce shared mutable state and the need for thread safety in code that never needed it.
- Async raises I/O concurrency furthest and requires an async-native stack end to end; the mixed-mode version is often worse than either pure choice.
- Greenlets give async-like concurrency with synchronous-looking code, at the cost of monkey-patching the standard library — powerful, and a source of failures that are very hard to attribute.
- More processes give real parallelism and isolation, and multiply memory, connections and warm-up cost.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- RUNTIME-SPECIFICCPython specifically. The GIL is an implementation property: PyPy has one too, Jython historically did not, and CPython 3.13 ships an experimental free-threaded build (PEP 703) that removes it — not the default, and not something to plan capacity around today.
- LANGUAGE-SPECIFICPython's split between WSGI (synchronous) and ASGI (asynchronous) interfaces has no direct equivalent in Node, where everything is already async, or in Go, where blocking style and cheap concurrency coexist. It is why "which model" is a live question in Python and mostly settled elsewhere.
- FRAMEWORK-SPECIFICDjango and Flask are WSGI-first with async support layered on; FastAPI and Starlette are ASGI-native but run a synchronous
defendpoint in a thread pool automatically — so the same framework gives you two different concurrency models depending on whether you wrotedeforasync def.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.