n4nAI

Python asyncio vs threading for LLM API concurrency

A practitioner's head-to-head comparison of Python asyncio vs threading for LLM API concurrency, covering latency, ergonomics, limits, and which to use per use case.

n4n Team4 min read946 words

Audio narration

Coming soon — every post will get a voice note here.

When you need to fire dozens of concurrent requests at an LLM endpoint, the choice between python asyncio vs threading llm clients shapes your entire service architecture. Both can saturate a network-bound workload, but they impose different constraints on error handling, streaming, and resource footprint.

Capabilities

Threading in Python uses OS-level threads. The CPython GIL releases during blocking I/O, so a pool of 20–50 threads will happily wait on HTTP responses from an LLM API. You get straightforward synchronous code: a function that calls requests or httpx.sync and returns. The mental model is “one call per thread.”

Asyncio uses a single-threaded event loop with cooperative scheduling. You write async/await and the loop switches contexts when a coroutine awaits I/O. This scales to thousands of in-flight requests on one thread because you are not paying per-thread stack or kernel scheduling overhead.

The practical difference appears when you need cancellation, timeouts, and fan-out control. Asyncio gives you asyncio.wait_for, Task.cancel(), and native backpressure via semaphores. With threads you rely on per-request timeouts and concurrent.futures shutdown gymnastics.

If you route through a gateway such as n4n.ai that provides one OpenAI-compatible endpoint with automatic fallback and per-token metering, the concurrency model you pick doesn’t change provider-specific quirks, but it does change how you handle that fallback’s latency.

Streaming

LLM responses are increasingly streamed token-by-token. With asyncio, streaming is a natural fit:

async def stream_llm(client, prompt):
    async with client.stream("POST", "/v1/chat/completions",
                             json={"model": "gpt-4o-mini", "stream": True,
                                   "messages": [{"role": "user", "content": prompt}]}) as r:
        async for line in r.aiter_lines():
            if line.startswith("data:"):
                yield line[5:]

Threading can stream too, but you must block the thread per chunk and manage the iterator across threads if you want concurrency. It gets ugly fast.

Latency and throughput

Neither approach speeds up a single LLM call; that is bounded by the provider. For batches, threading adds per-thread memory (~8MB stack) and context-switch cost. At 100+ concurrent calls, asyncio typically sustains higher throughput on the same hardware because the event loop avoids kernel scheduling.

Latency tail matters. Thread pools can saturate: if all 10 workers are blocked on slow providers, new calls queue. Asyncio with a bounded semaphore lets you shed load or prioritize. Both suffer if you accidentally run CPU-bound parsing on the event loop or in threads—keep payload processing minimal.

Cost and resource model

There is no direct monetary cost difference; token pricing is identical regardless of client. The hidden cost is infrastructure: threads force higher memory headroom. A service handling 500 concurrent LLM requests with threads may need 4GB just for stacks; asyncio handles that on 200MB.

Connection pooling also differs. httpx.AsyncClient uses a single connection pool shared across coroutines; ThreadPoolExecutor with sync clients often creates per-thread pools, multiplying idle sockets. Against a gateway that meters per token, inefficient connection use doesn’t change billing but can hit file-descriptor limits.

Ergonomics

Threading wins for engineers who already think in procedural code. A typical batch:

from concurrent.futures import ThreadPoolExecutor
import httpx

def complete(prompt):
    with httpx.Client() as c:
        r = c.post("https://api.example.com/v1/chat/completions",
                   json={"model": "gpt-4o-mini", "messages":[{"role":"user","content":prompt}]})
        return r.json()["choices"][0]["message"]["content"]

with ThreadPoolExecutor(max_workers=8) as ex:
    out = list(ex.map(complete, prompts))

That is readable and debuggable with standard stack traces.

Asyncio demands async everywhere up the call stack. You cannot call await from a sync Flask route without wrapping. Frameworks like FastAPI embrace it; Django historically fought it. The payoff is explicit control:

async def main(prompts):
    async with httpx.AsyncClient() as c:
        sem = asyncio.Semaphore(20)
        async def bounded(p):
            async with sem:
                return await complete_async(c, p)
        return await asyncio.gather(*(bounded(p) for p in prompts))

If your team is small and the task is a one-off script, threading reduces cognitive load. For a long-lived API server, asyncio aligns with the runtime.

Ecosystem

Most LLM SDKs now ship async clients: OpenAI’s official SDK, Anthropic’s, and Haystack. httpx is the de facto transport. Threading relies on requests or sync httpx, both mature. You will find more examples of threading in legacy internal tools; new FastAPI services default to asyncio.

Testing asyncio requires pytest-asyncio or anyio. Threading tests run as plain functions. Profiling async needs async-profiler or loop instrumentation; threads show in standard py-spy.

Hard limits

The CPython GIL is not a bottleneck for I/O-bound LLM calls—threads release it on socket read. But if you attach heavy post-processing (JSON schema validation, embedding cosine) inside the worker, threads serialize on CPU and asyncio won’t help either unless you offload to loop.run_in_executor or multiprocessing.

Asyncio fails badly if you call a blocking function (e.g., time.sleep, requests.get) inside a coroutine: it stalls the entire loop. Threading fails badly if you share mutable state without locks. Both are survivable; the failure modes differ.

Comparison table

Dimension Threading Asyncio
Concurrency model OS threads, GIL released on I/O Single-threaded event loop, cooperative
Max practical in-flight ~100–200 (memory bound) Thousands (fd bound)
Streaming ergonomics Awkward, per-thread blocks Native async iterators
Cancellation/timeout Per-request timeout, manual wait_for, Task.cancel
Memory per request ~MB stack + client pool KB coroutine + shared pool
Code complexity Low, sync style Higher, async propagation
Best fit Scripts, mixed CPU/I/O, legacy API servers, high fan-out

Which to choose

Batch offline jobs and scripts

Use threading. If you are processing 5,000 support tickets nightly with a simple loop, ThreadPoolExecutor with 16 workers is ten lines and done. You avoid async bootstrap and can use pandas alongside without fear.

Low-latency serving behind an API

Use asyncio. A FastAPI endpoint that fans out to 30 LLM calls per user request must not block worker threads. Asyncio with a shared AsyncClient and semaphore keeps p99 stable. The python asyncio vs threading llm decision here is settled by the runtime model.

Mixed workloads with CPU parsing

If you must validate responses with heavy pydantic models, threads give you parallel CPU under the GIL only if you use multiprocessing; asyncio needs run_in_executor. In that case, threading with a process pool or asyncio with a separate executor both work—pick based on existing codebase.

Gateway-heavy routing

When calling a single endpoint that already handles fallback across 240+ models, the client-side concurrency is about managing your own saturation. Asyncio’s backpressure primitives make rate-limit handling cleaner than thread pools.

The python asyncio vs threading llm trade-off is not ideological. It is about whether your deployment is a script or a service, and how much streaming and cancellation you need. Choose threading for simplicity and asyncio for scale.

Tagspythonasynciothreadingcomparison

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All python async/await streaming (asyncio) posts →