All Courses
Advanced Python

Python Asynchronous Programming (asyncio)

Synchronous vs Asynchronous

Synchronous Asynchronous
Execution Sequential, line by line Can pause and resume; event-driven
Blocking Yes — must wait for each operation No — can do other work while waiting
Threading needed No No — runs in one thread
Complexity Simple Simpler than threading

Key Terminology

Term Definition
Coroutine An async function — can pause execution and give control to other coroutines
await Pauses the current coroutine and waits for another coroutine/task to finish
Task A scheduled coroutine — runs concurrently without blocking
Event loop Manages scheduling and switching between coroutines/tasks
Future An object that will have a value at some point — tasks are a type of future
asyncio.gather() Schedules multiple coroutines as tasks and runs them concurrently

Setup

import asyncio

Defining a Coroutine

Add async before def:

async def main():
    print("main")

print(type(main))    # <class 'function'>
print(type(main()))  # <class 'coroutine'>  ← calling it returns a coroutine, not a result

Running Async Code — Entry Point

You must always start your async program with asyncio.run():

asyncio.run(main())   # sets up the event loop and runs the main coroutine

This is always required — you cannot await outside of a coroutine.


await — Calling a Coroutine

Use await inside a coroutine to call another coroutine and wait for it to finish:

async def print_something():
    await asyncio.sleep(1)    # use asyncio.sleep, NOT time.sleep
    print("something")
    return "finished"

async def main():
    result = await print_something()   # blocks until print_something() is done
    print(result)                      # "finished"

asyncio.run(main())

⚠️ Always use asyncio.sleep() in async code — time.sleep() blocks the entire event loop.


Tasks — Running Concurrently

Wrapping a coroutine in a task schedules it to run concurrently — it doesn't block:

async def main():
    task = asyncio.create_task(print_something())   # scheduled, starts soon
    print("main continues immediately")
    await task                                       # wait for task to finish
    result = await task                             # get return value
  • If the program ends before a task finishes → the task is cancelled
  • Always await a task if you need it to complete

await vs create_task — The Core Difference

# Blocking — waits for print_something to finish before continuing
await print_something()
print("this runs after")

# Non-blocking — schedules it; continues immediately
task = asyncio.create_task(print_something())
print("this runs right away")
await task   # wait later when needed

Practical Example — Concurrent Tasks

async def print_values(values, delay):
    for item in values:
        print(item)
        await asyncio.sleep(delay)
    return delay

async def main():
    task1 = asyncio.create_task(print_values([1, 3, 5], 0.2))
    task2 = asyncio.create_task(print_values([2, 4], 0.3))
    await task1
    await task2

asyncio.run(main())
# Output: 1 2 3 4 5  (in order — reliable, no locks needed!)

asyncio.gather() — Shortcut for Running Multiple Tasks

Schedules all coroutines as tasks, runs them concurrently, and returns a list of all return values:

async def main():
    results = await asyncio.gather(
        print_values([1, 3, 5], 0.2),
        print_values([2, 4], 0.3)
    )
    print(results)   # [0.2, 0.3] — return values in order

This is equivalent to manually creating tasks and awaiting them — just more concise.


Real-World Use Case — Fetch Data + Run Algorithm

async def fetch_data():
    print("Start fetching")
    await asyncio.sleep(2)   # simulates network delay
    print("Done fetching")
    return [1, 2, 3]

async def run_algorithm():
    for i in range(10):
        print(i)
        await asyncio.sleep(0.5)

async def main():
    # Run both concurrently — don't wait for fetch before starting algorithm
    data, _ = await asyncio.gather(fetch_data(), run_algorithm())
    print(data)

Async Generators

A generator function with async and yield:

async def gen(n):
    for i in range(n):
        yield i
        await asyncio.sleep(0.5)

Iterating over an async generator

Must use async for — regular for will raise an error:

async def main():
    async for i in gen(10):   # ✅ async for
        print(i)

    for i in gen(10):         # ❌ TypeError — not iterable
        print(i)

Async Methods in Classes

class MyClass:
    @staticmethod
    async def test():
        print("hi")

async def main():
    await MyClass.test()

Async vs Threading — Why Prefer Async?

Threading Asyncio
Complexity High (locks, deadlocks, race conditions) Low (cleaner syntax)
Runs in Multiple threads One thread
True parallelism ❌ (blocked by GIL) ❌ (but same effect for I/O)
Concurrency
Preferred for I/O tasks Sometimes ✅ Yes

Since the GIL prevents true parallelism in Python anyway, asyncio achieves the same concurrency benefits as threading but with far less complexity.


Key Takeaways & Recap

Concept Summary
async def Defines a coroutine — returns a coroutine object when called
await Pauses current coroutine; waits for another coroutine/task
asyncio.run() Entry point — always required to start async code
asyncio.create_task() Schedules a coroutine to run concurrently (non-blocking)
asyncio.gather() Runs multiple coroutines concurrently; returns list of results
asyncio.sleep() Use instead of time.sleep() in async code
async for Required to iterate over async generators/iterables
Task/Future An object representing a value that will exist in the future
Prefer async over threads Simpler, cleaner, avoids GIL-related threading headaches