Async Programming with asyncio

· 5 min read · Updated March 7, 2026 · intermediate
asyncio async await coroutines concurrency async programming

Asynchronous programming lets your Python code handle multiple operations without waiting for each one to finish. Instead of blocking while waiting for network requests, file reads, or database queries, your code can continue working on other tasks and resume when the results are ready. The asyncio library provides the tools for this.

This tutorial covers writing async code with async/await, running coroutines concurrently with tasks, and patterns for building responsive applications.

Your First Async Function

Regular Python functions use def and run synchronously. Async functions use async def and return coroutines — objects that represent operations waiting to execute.

import asyncio

# A regular function
def regular_function():
    return "同步"

# An async function - returns a coroutine
async def async_function():
    return "异步"

Calling an async function doesn’t run its code. It returns a coroutine object that you must await or schedule:

import asyncio

async def greet():
    print("Hello!")
    await asyncio.sleep(1)  # Non-blocking pause
    print("World!")

# This runs the async function
asyncio.run(greet())

The asyncio.run() function creates an event loop, runs your coroutine, and closes the loop when done. This is the entry point for most async Python programs.

Understanding the Event Loop

The event loop is the engine behind async Python. It manages when each coroutine runs and switches between them during await statements. Think of it as a scheduler that keeps your async tasks moving forward.

When you await something — like asyncio.sleep() or an HTTP request — the event loop can pause your coroutine and run other ready coroutines. This is cooperative multitasking: your code decides when to yield control.

import asyncio

async def task_a():
    print("Task A: starting")
    await asyncio.sleep(2)
    print("Task A: done")

async def task_b():
    print("Task B: starting")
    await asyncio.sleep(1)
    print("Task B: done")

async def main():
    # Run both tasks concurrently
    await asyncio.gather(task_a(), task_b())

asyncio.run(main())

Run this and notice the output: Task B finishes first because it sleeps for only 1 second while Task A sleeps for 2. Both run “at the same time” — the event loop switches between them.

Running Tasks Concurrently

asyncio.gather() runs multiple coroutines concurrently and waits for all to complete. This is the most common pattern for parallel async operations.

import asyncio
import urllib.request

async def fetch_url(url):
    with urllib.request.urlopen(url) as response:
        data = response.read()
        return f"{url}: {len(data)} bytes"

async def main():
    urls = [
        "https://example.com",
        "https://python.org",
        "https://github.com",
    ]
    
    # Run all fetches concurrently
    results = await asyncio.gather(*[fetch_url(url) for url in urls])
    
    for result in results:
        print(result)

asyncio.run(main())

This fetches three URLs in parallel. Each HTTP request runs without blocking the others.

Creating Tasks

Sometimes you want to start a coroutine without waiting for it. asyncio.create_task() schedules a coroutine to run in the background:

import asyncio

async def background_task():
    for i in range(5):
        await asyncio.sleep(1)
        print(f"Background: {i}")
    print("Background task done!")

async def main():
    # Start the background task
    task = asyncio.create_task(background_task())
    
    # Do other work while it runs
    print("Main: doing some work")
    await asyncio.sleep(2)
    print("Main: waiting for background task")
    
    # Wait for it to complete
    await task

asyncio.run(main())

Tasks let you run background operations while the main flow continues. You can also cancel tasks if needed:

task = asyncio.create_task(some_long_operation())
# Later...
task.cancel()

Awaiting Multiple Futures

Beyond gather, asyncio provides other primitives for managing concurrency:

import asyncio

async def long_operation(name, delay):
    await asyncio.sleep(delay)
    return f"{name} finished"

async def main():
    # Create coroutines
    coro1 = long_operation("Task 1", 3)
    coro2 = long_operation("Task 2", 1)
    coro3 = long_operation("Task 3", 2)
    
    # Run them and get results as they complete
    for coro in asyncio.as_completed([coro1, coro2, coro3]):
        result = await coro
        print(f"Completed: {result}")

asyncio.run(main())

asyncio.as_completed() returns an iterator that yields results as they finish, not in submission order. Task 2 finishes first (1 second), then Task 3, then Task 1.

Common Patterns

Running Sync Code in Async Context

Sometimes you need to call blocking synchronous code from an async function. Use asyncio.to_thread() to run it in a separate thread:

import asyncio
import time

def blocking_function():
    time.sleep(2)  # This blocks!
    return "Done"

async def main():
    # Run blocking code in a thread
    result = await asyncio.to_thread(blocking_function)
    print(result)

asyncio.run(main())

This prevents blocking the event loop while waiting for the synchronous operation.

Timeouts

Add time limits to your async operations:

import asyncio

async def slow_operation():
    await asyncio.sleep(10)
    return "Finally done!"

async def main():
    try:
        # Raise TimeoutError after 3 seconds
        result = await asyncio.wait_for(slow_operation(), timeout=3)
    except asyncio.TimeoutError:
        print("Operation timed out!")

asyncio.run(main())

asyncio.wait_for() raises TimeoutError if the operation takes too long.

Async Iterators and Generators

You can use async for to iterate over async iterables:

import asyncio

async def async_generator():
    for i in range(5):
        await asyncio.sleep(0.5)
        yield i

async def main():
    async for value in async_generator():
        print(f"Got: {value}")

asyncio.run(main())

This is useful when streaming data from async sources like WebSocket connections.

When to Use Async

Async programming shines for IO-bound operations: HTTP requests, database queries, file operations, WebSocket connections. The event loop can handle thousands of such operations efficiently.

For CPU-bound work — heavy computation, image processing, machine learning — async doesn’t help much. The work still blocks. Use multiprocessing or concurrent.futures.ProcessPoolExecutor instead.

A common pattern is mixing both: use async for network and disk IO, but offload heavy computation to a process pool.

Common Mistakes

Forgetting to await is the most frequent error. An unawaited coroutine does nothing:

async def my_func():
    await asyncio.sleep(1)

# WRONG - coroutine is created but never run
my_func()

# CORRECT - run the coroutine
asyncio.run(my_func())

Blocking calls in async code will freeze your entire program. Never use time.sleep() — use asyncio.sleep() instead. For third-party libraries that don’t support async, wrap them with asyncio.to_thread().

Not using enough concurrency defeats the purpose. If you await each operation sequentially, you get no benefit over synchronous code. Use gather() or create_task() to run operations concurrently.

Next Steps

You’ve learned the core asyncio primitives. From here, explore:

  • asyncio streams — for TCP/UDP servers and clients
  • asyncio.Queue — for producer-consumer patterns
  • asyncio.Lock and Semaphore — for synchronizing access to shared resources
  • aiohttp — an async HTTP client and server library
  • AsyncIO patterns in FastAPI — building async web applications

Asynchronous programming opens up new possibilities for building fast, responsive Python applications. Practice with these patterns and you’ll be writing production async code in no time.