DEV Community

syncore
syncore

Posted on

Asyncio Explained Simply: A Beginner's Guide to Python Async

4 min read · 756 words

Imagine you are cooking breakfast. You put bread in the toaster, crack two eggs into a frying pan, and brew coffee.

If you cooked synchronously, you would stand motionless in front of the toaster for two minutes. Only after the toast popped up would you turn to crack the eggs. You'd wait for the eggs to finish before grinding coffee beans. That is a terrible way to make breakfast, and it is an equally terrible way to handle I/O-bound code.

Python's asyncio solves this problem. It allows a single thread to pause an operation while waiting for an external system (like a database, an API, or a file read) and do other work in the meantime.

Here is what you need to understand asyncio without getting lost in academic jargon.


The Three Pillars of Asyncio

Before writing code, lock in these three concepts:

  1. The Event Loop: The central engine. It tracks tasks and delegates execution. If Task A is waiting for a network response, the event loop parks Task A and resumes Task B.
  2. Coroutines (async def): Functions declared with async def. When you call one, Python doesn't execute it immediately; it returns a coroutine object ready to be scheduled on the event loop.
  3. Pausing Points (await): The await keyword yields execution back to the event loop. It tells Python: "Go do other work until this operation produces a result."

1. Your First Asyncio Script

Let's look at how sequential code compares to concurrent asynchronous execution using asyncio.sleep, which simulates network latency.

import asyncio
import time

async def fetch_data(source_id: int, delay: float):
    print(f"Fetching from source {source_id}...")
    await asyncio.sleep(delay)  # Yields control back to the loop
    print(f"Received data from source {source_id}!")
    return {"source": source_id, "status": "ok"}

async def main():
    start = time.perf_counter()

    # Schedule three tasks to run concurrently
    results = await asyncio.gather(
        fetch_data(1, 2.0),
        fetch_data(2, 1.0),
        fetch_data(3, 1.5),
    )

    elapsed = time.perf_counter() - start
    print(f"Results: {results}")
    print(f"Total time taken: {elapsed:.2f} seconds")

if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

If these tasks ran sequentially, this script would take 2.0 + 1.0 + 1.5 = 4.5 seconds. Run this code, and it finishes in roughly 2.0 seconds—the duration of the longest individual task.

asyncio.gather wraps all three coroutines, submits them to the event loop, and waits until all of them finish.


2. Real-World Async: Concurrent API Requests

The real power of asyncio shines when interacting with third-party web services. When calling remote APIs, your CPU spends 99% of its time idling, waiting on network packets.

Here is an example making multiple LLM requests concurrently using the modern Anthropic Python SDK:

import asyncio
import anthropic

# Initialize the asynchronous client
client = anthropic.AsyncAnthropic()

async def generate_summary(topic: str) -> str:
    prompt = f"Provide a one-sentence summary of the core benefit of {topic}."

    response = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=150,
        messages=[{"role": "user", "content": prompt}],
    )
    return response.content[0].text

async def main():
    topics = ["Docker", "Asyncio", "Redis"]

    # Launch all requests concurrently
    tasks = [generate_summary(topic) for topic in topics]
    summaries = await asyncio.gather(*tasks)

    for topic, summary in zip(topics, summaries):
        print(f"\n--- {topic} ---\n{summary}")

if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Instead of waiting for Docker to finish before dispatching Asyncio, the event loop fires all three requests nearly simultaneously. All responses stream back as they resolve.


Three Common Traps to Avoid

1. Calling Blocking Code Inside Coroutines

Async code only yields control when you explicitly await an asynchronous call. If you drop time.sleep(5) or standard requests.get() inside an async def function, you freeze the entire event loop. No other task can run. Always use non-blocking equivalents like asyncio.sleep() or httpx.AsyncClient().

2. Forgetting the await Keyword

If you call an async function without await:

data = fetch_data(1, 2.0)
print(data)
# Output: <coroutine object fetch_data at 0x104...>
Enter fullscreen mode Exit fullscreen mode

Python will print a coroutine object instead of your expected payload, and issue a RuntimeWarning: coroutine 'fetch_data' was never awaited.

3. Thinking Async Speeds Up Heavy Math

Asyncio does not offer parallel CPU processing. It is designed purely for I/O-bound operations (databases, microservices, file systems). If you are processing images, training machine learning models, or computing primes, reach for Python's multiprocessing module instead.


Wrapping Up

Asyncio doesn't make individual network requests faster—it stops your program from sitting idle while waiting for them. By organizing I/O around async def, await, and asyncio.gather, you can dramatically increase your application's throughput with minimal overhead.

What part of async Python trips you up the most—cancellation, task groups, or debugging? Let me know in the comments below!

Top comments (0)