TechWayFit
● Complete Learning Series Python & AI Engineering for .NET Developers

Mastering asyncio for High-Throughput AI Apps

Tech Buddy July 14, 2026 3 min read
Mastering asyncio for High-Throughput AI Apps

If you're running evaluation sweeps, batch-processing documents through an LLM, or building a chatbot that handles concurrent users, sequential API calls will kill your performance. A request to Claude takes 1–5 seconds. Process 100 documents sequentially and you're looking at 2–8 minutes. Process them concurrently and you're down to under 30 seconds — bounded only by your rate limits.

Python's asyncio is the answer. As a C# developer, you already understand async/await — Python's version is conceptually identical but with important runtime differences.

async/await: The Conceptual Mapping

C# async/await
													// Async method returns Task<T>
                      public async Task<string> CallClaudeAsync(
                          string prompt)
                      {
                          var response = await client
                              .Messages.CreateAsync(request);
                          return response.Content[0].Text;
                      }
                      
                      // Run concurrent tasks
                      var tasks = prompts
                          .Select(p => CallClaudeAsync(p));
                      var results = await Task.WhenAll(tasks);
                      												
Python asyncio
													# async def returns a coroutine
                      async def call_claude(prompt: str) -> str:
                          response = await client.chat.completions.create(
                              model="gpt-4o",
                              max_tokens=512,
                              messages=[{"role": "user", "content": prompt}],
                          )
                          return response.choices[0].message.content
                      
                      # Run concurrent coroutines
                      results = await asyncio.gather(
                          *[call_claude(p) for p in prompts]
                      )
                      												

The Critical Difference: The Event Loop

In C#, Task.WhenAll uses the thread pool. Python's asyncio.gather runs in a single thread on an event loop. This means:

  • asyncio helps I/O-bound work — while one coroutine waits for an API response, the event loop runs other coroutines. No thread overhead.
  • asyncio does NOT help CPU-bound work — if you need to run heavy computation (tokenization, embedding post-processing), asyncio won't parallelize it. Use multiprocessing instead (covered in Lesson 1.4).
  • LLM API calls are always I/O-bound — asyncio is exactly right for concurrent API requests.

The Async OpenAI Client

The OpenAI SDK ships both sync and async clients. Use AsyncOpenAI for concurrent workloads:

											import asyncio
                      from openai import OpenAI
                      
                      # Use AsyncOpenAI for async code
                      async_client = AsyncOpenAI(api_key="...")
                      
                      
                      async def summarize(text: str, client: AsyncOpenAI) -> str:
                          """Async version — awaitable."""
                          response = await client.chat.completions.create(
                              model="gpt-4o",
                              max_tokens=256,
                              messages=[{
                                  "role": "user",
                                  "content": f"Summarize in 2 sentences:\n{text}",
                              }],
                          )
                          return response.choices[0].message.content
                      
                      
                      async def main():
                          async with AsyncOpenAI() as client:
                              result = await summarize("Python is a programming language...", client)
                              print(result)
                      
                      
                      # Entry point: run the event loop
                      asyncio.run(main())
                      										

asyncio.gather: Concurrent Requests

asyncio.gather() is the equivalent of Task.WhenAll(). It runs multiple coroutines concurrently and collects their results in order:

											import asyncio
                      import time
                      from openai import OpenAI
                      
                      async def process_document(
                          doc_id: str,
                          text: str,
                          client: AsyncOpenAI,
                      ) -> dict:
                          """Process a single document through the LLM."""
                          try:
                              response = await client.chat.completions.create(
                                  model="gpt-4o",
                                  max_tokens=512,
                                  messages=[{
                                      "role": "user",
                                      "content": f"Extract key topics from this text:\n\n{text}",
                                  }],
                              )
                              return {
                                  "id": doc_id,
                                  "topics": response.choices[0].message.content,
                                  "tokens": response.usage.completion_tokens,
                                  "status": "success",
                              }
                          except Exception as e:
                              return {"id": doc_id, "status": "error", "error": str(e)}
                      
                      
                      async def process_batch(documents: list[dict]) -> list[dict]:
                          """Process all documents concurrently."""
                          start = time.monotonic()
                      
                          async with AsyncOpenAI() as client:
                              # Create all coroutines — they don't run yet
                              tasks = [
                                  process_document(doc["id"], doc["text"], client)
                                  for doc in documents
                              ]
                      
                              # Run all concurrently, collect results in order
                              results = await asyncio.gather(*tasks)
                      
                          elapsed = time.monotonic() - start
                          print(f"Processed {len(documents)} documents in {elapsed:.1f}s")
                          return list(results)
                      
                      
                      # Demo: 10 documents processed concurrently
                      documents = [{"id": f"doc_{i}", "text": f"Document {i} content..."} for i in range(10)]
                      results = asyncio.run(process_batch(documents))
                      										

Performance Comparison

This is the payoff that makes asyncio worth the mental overhead. When you fire ten LLM calls sequentially, total time is the sum of all ten; when you run them concurrently with asyncio.gather, total time is roughly the slowest single call, because the waiting overlaps. Since AI apps spend almost all their time waiting on network I/O, this is often a 5–10x throughput win for the same hardware.

											import asyncio
                      import time
                      from openai import OpenAI
                      
                      PROMPTS = [f"What is {topic}?" for topic in [
                          "machine learning", "neural networks", "transformers", "embeddings",
                          "RAG", "fine-tuning", "RLHF", "attention mechanism",
                      ]]
                      
                      # Sequential — naive approach
                      def run_sequential(client: OpenAI) -> list[str]:
                          return [
                              client.chat.completions.create(
                                  model="gpt-4o-mini-20240307",  # fast model for demo
                                  max_tokens=64,
                                  messages=[{"role": "user", "content": p}],
                              ).choices[0].message.content
                              for p in PROMPTS
                          ]
                      
                      # Concurrent with asyncio
                      async def run_concurrent(client: AsyncOpenAI) -> list[str]:
                          async def one(prompt: str) -> str:
                              r = await client.chat.completions.create(
                                  model="gpt-4o-mini-20240307",
                                  max_tokens=64,
                                  messages=[{"role": "user", "content": prompt}],
                              )
                              return r.choices[0].message.content
                      
                          return await asyncio.gather(*[one(p) for p in PROMPTS])
                      
                      
                      # Sequential: ~16s (8 × ~2s per request)
                      # Concurrent: ~3s (all 8 in parallel, limited by slowest)
                      # Speedup: ~5x — limited by rate limits and the slowest single request
                      										

Controlling Concurrency with Semaphores

Launching 1000 concurrent requests will hit rate limits instantly. A asyncio.Semaphore limits how many coroutines run at once — like a thread pool size in .NET:

											import asyncio
                      from openai import OpenAI
                      
                      async def process_with_semaphore(
                          documents: list[dict],
                          max_concurrent: int = 10,  # match your API rate limit tier
                      ) -> list[dict]:
                          semaphore = asyncio.Semaphore(max_concurrent)
                      
                          async def process_one(doc: dict, client: AsyncOpenAI) -> dict:
                              async with semaphore:  # blocks if max_concurrent already running
                                  return await process_document(doc["id"], doc["text"], client)
                      
                          async with AsyncOpenAI() as client:
                              tasks = [process_one(doc, client) for doc in documents]
                              results = await asyncio.gather(*tasks, return_exceptions=True)
                      
                          # Filter out exceptions from results
                          successes = [r for r in results if not isinstance(r, Exception)]
                          errors = [r for r in results if isinstance(r, Exception)]
                          print(f"Completed: {len(successes)}, Errors: {len(errors)}")
                          return successes
                      										

Async Context Managers and Generators

Two async variants of familiar constructs matter for AI work. An async context manager (async with) cleanly opens and closes resources like an HTTP client or connection pool — the await using of the Python world. An async generator (async for over yield) is how you consume a streaming response token-by-token without buffering the whole thing, the equivalent of an IAsyncEnumerable.

											import asyncio
                      from openai import OpenAI
                      
                      # Async context manager — same as with statement, but for async resources
                      async def stream_response(prompt: str) -> None:
                          """Stream a response token by token."""
                          async with AsyncOpenAI() as client:
                              async with client.chat.completions.stream(
                                  model="gpt-4o",
                                  max_tokens=512,
                                  messages=[{"role": "user", "content": prompt}],
                              ) as stream:
                                  async for text in stream.text_stream:
                                      print(text, end="", flush=True)
                              print()  # newline at end
                      
                      
                      # Async generator for processing a stream
                      async def process_stream_chunks(prompt: str):
                          """Yield chunks as they arrive."""
                          async with AsyncOpenAI() as client:
                              async with client.chat.completions.stream(
                                  model="gpt-4o",
                                  max_tokens=512,
                                  messages=[{"role": "user", "content": prompt}],
                              ) as stream:
                                  async for text in stream.text_stream:
                                      yield text  # async generator
                      
                      
                      async def main():
                          await stream_response("Explain Python asyncio in one paragraph.")
                      
                      asyncio.run(main())
                      										

asyncio Error Handling

Concurrency complicates error handling: when you run many calls together, one failure shouldn't necessarily sink the rest. The key tool is asyncio.gather(..., return_exceptions=True), which collects exceptions alongside successful results instead of aborting the whole batch — essential when you're processing a thousand documents and a handful hit rate limits. The examples show handling per-task failures and enforcing timeouts.

											import asyncio
                      
                      async def safe_process(doc: dict, client) -> dict:
                          """Process one document, handling errors gracefully."""
                          try:
                              return await process_document(doc["id"], doc["text"], client)
                          except openai.RateLimitError:
                              # Simple per-task backoff
                              await asyncio.sleep(5)
                              return await process_document(doc["id"], doc["text"], client)
                          except Exception as e:
                              return {"id": doc["id"], "status": "error", "error": str(e)}
                      
                      
                      async def gather_with_errors(documents: list[dict]) -> tuple[list, list]:
                          """Gather results, separating successes from failures."""
                          async with AsyncOpenAI() as client:
                              # return_exceptions=True prevents one failure from canceling all
                              results = await asyncio.gather(
                                  *[safe_process(doc, client) for doc in documents],
                                  return_exceptions=True,
                              )
                      
                          successes = [r for r in results if isinstance(r, dict)]
                          failures = [r for r in results if isinstance(r, Exception)]
                          return successes, failures
                      										

Key Takeaways

  • asyncio is single-threaded cooperative concurrency — it helps I/O-bound work (API calls, network, disk) but NOT CPU-bound computation
  • Use AsyncOpenAI() for async LLM calls; await client.chat.completions.create() yields control to the event loop while waiting
  • asyncio.gather(*coroutines) runs multiple coroutines concurrently — equivalent to C#'s Task.WhenAll()
  • asyncio.Semaphore(n) limits concurrency to n simultaneous operations — essential for staying within API rate limits
  • Use return_exceptions=True in gather() so a single failure doesn't cancel all other concurrent tasks
  • Entry point: asyncio.run(main()) starts the event loop; only call it once at the top level

Comments

Loading comments…

Leave a comment