DEV Community

Cover image for Asyncio Architecture in Python: Event Loops, Tasks, and Futures Explained
Sushant Gaurav
Sushant Gaurav

Posted on

Asyncio Architecture in Python: Event Loops, Tasks, and Futures Explained

How is asyncio different from multiprocessing and multithreading?

Execution model

  • Multithreading: Multiple threads share the same memory space, but only one thread runs at a time in Python (because of the GIL). It is good for I/O-bound tasks.
  • Multiprocessing: Multiple processes, each with its own memory and Python interpreter. It gives true parallelism. It is good for CPU-bound tasks.
  • Asyncio: Single thread, single process. It uses an event loop to run many tasks concurrently by switching between them when one task is waiting (for example, waiting for network or file I/O). It is best for I/O-bound workloads along with high-concurrency workloads.

Memory and overhead

  • Threads: Light, but still need stack memory.
  • Processes: Heavy, separate memory, higher overhead.
  • Coroutines (asyncio): Ultra-light. Thousands of coroutines can run inside one thread because they do not need OS threads.

Rule of thumb

  • If the need is parallelism, use multiprocessing (for example, image/video processing).
  • If the need is to handle blocking I/O with limited concurrency, use threads ****.
  • If the need is to handle huge concurrency with I/O, use asyncio (for example, chat servers, web scraping, APIs).

Difference between Threads and Coroutines

Scheduling

  • Thread: Scheduling is done by the operating system, so the context switches are expensive.
  • Coroutine: Scheduling is done by the event loop (Python-level), so the switching is cheap because it is just jumping between Python functions.

Blocking vs Non-blocking

  • Thread: A thread can block (for example, time.sleep(5) blocks it).
  • Coroutine: A coroutine must use await asyncio.sleep(5) which pauses only that coroutine, not the whole thread.

Parallelism

  • Threads: (in Python) No true parallel execution of Python code due to GIL.
  • Coroutines: No parallel execution. Just cooperative multitasking. Each coroutine yields control explicitly with await.

Coroutine

It is a special kind of function that can pause its execution and later resume from the same point. It is Python’s way of expressing cooperative concurrency, the code that can yield control so other tasks can run without using extra threads or processes.

A coroutine is created by using the async def keyword. For example:

async def my_coroutine():
    await asyncio.sleep(1)
    return "done"
Enter fullscreen mode Exit fullscreen mode
  • async def tells Python that this function does not execute like a normal function.
  • Calling it does not run it immediately.
  • Instead, it returns a coroutine object, which is a promise of work that can be scheduled.

Example:

cor = my_coroutine()
print(cor)                  # <coroutine object my_coroutine at 0x...>
Enter fullscreen mode Exit fullscreen mode

At this point, nothing has happened yet.

Running a Coroutine

A coroutine does not execute immediately when called; it must be attached to an event loop to run. There are three common ways to do that:

1. Await inside another coroutine

When operating within an asynchronous function, one can directly await another coroutine. This pauses the current coroutine until the awaited one completes.

Example:

import asyncio

async def greet():
    print("Hello...")
    await asyncio.sleep(1)
    print("...World!")
    return "Done!"

async def main():
    result = await greet()
    print(result)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

Hello...
...World!
Done!
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. The event loop starts via asyncio.run(main()).
  2. main() begins and awaits greet().
  3. Control passes to greet(), which pauses for 1 second at await asyncio.sleep(1).
  4. After finishing, greet() returns "Done!", and main() resumes to print it.

Use when: Already inside another coroutine and want sequential execution.

2. Top-level entry with asyncio.run()

When running from normal synchronous code (like a main block), use asyncio.run().
It creates a new event loop, runs the coroutine to completion, and then closes the loop.

Example:

import asyncio

async def greet():
    print("Starting task...")
    await asyncio.sleep(1)
    print("Task finished!")

asyncio.run(greet())
Enter fullscreen mode Exit fullscreen mode

Output:

Starting task...
Task finished!
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. asyncio.run() creates an event loop.
  2. The loop executes greet(), pausing for 1 second at await asyncio.sleep(1).
  3. Once done, it prints “Task finished!” and the loop closes automatically.

Use when: Need to run async code at the top level (for example, in a script).

3. Schedule concurrent execution with asyncio.create_task()

asyncio.create_task() schedules coroutines to run concurrently within an existing event loop. It returns a Task object that starts running immediately in the background.

Example:

import asyncio

async def task_one():
    await asyncio.sleep(2)
    print("Task One Complete")

async def task_two():
    await asyncio.sleep(1)
    print("Task Two Complete")

async def main():
    t1 = asyncio.create_task(task_one())
    t2 = asyncio.create_task(task_two())

    print("Both tasks started...")
    await t1
    await t2
    print("All tasks done!")

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

Both tasks started...
Task Two Complete
Task One Complete
All tasks done!
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. Both coroutines are scheduled and start running in parallel.
  2. task_two completes first (after 1s), followed by task_one (after 2s).
  3. Once both are done, the main coroutine prints “All tasks done!”.

Use when: Want to run multiple coroutines concurrently on the same event loop.

Note: The event loop drives these coroutines forward, suspending them whenever an await is reached and resuming when the awaited operation is complete.

Coroutine vs normal function

Normal Function (def) Coroutine (async def)
Runs top to bottom at once Can pause at await and resume later
Returns a value directly Returns a coroutine object (must be awaited)
Blocking Non-blocking (gives control back to the event loop)

Event Loop, Tasks, and Futures

Event loop

The event loop is the central scheduler for asyncio. It runs in a single thread and repeatedly does three jobs:

  1. Monitor OS I/O events (sockets, pipes, etc.).
  2. Run ready callbacks.
  3. Resume coroutines whose awaited operations have completed.

Starting a loop the modern way

The easiest entry point is asyncio.run():

import asyncio

async def main():
    print("Hello from asyncio!")

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from asyncio!
Enter fullscreen mode Exit fullscreen mode

Note: asyncio.run() creates an event loop, runs the coroutine until it finishes, and closes the loop.

Important properties

  • An event loop is like a scheduler that keeps track of all asyncio tasks. Only one loop can run inside a given thread. Normally, the main thread hosts one loop. If another thread is created, it can have its own loop if explicitly started. Most asyncio programs just use a single loop in the main thread.
  • asyncio.run() is the recommended top-level entry point; it creates a new loop, runs the coroutine until completion, and then closes the loop. So, there is no need for manual loop handling.
  • Sometimes, more control is required (for example, libraries or advanced servers), then the event loop object can be accessed and managed directly. Its key methods are:

    • new_event_loop(): It creates a new event loop.
    • set_event_loop(loop): It assigns a specific event loop object as the current loop for the running thread.
    • create_task(): It schedules a coroutine to run concurrently as a Task on the current event loop and returns a Task object immediately (schedule + run and returns Task object).
    • loop.run_forever(): It starts the loop and keeps it alive until loop.stop() is called.
    • loop.run_until_complete(future): It runs the loop until a specific coroutine/future is complete.
    • loop.stop(): It requests the loop to exit.
    • Example:
    import asyncio
    
    async def say_hi():
        await asyncio.sleep(1)
        print("Hi!")
        loop.stop()               # stop the loop after task completes
    
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    loop.create_task(say_hi())    # schedule a coroutine
    loop.run_forever()            # manually keep running
    loop.close()
    
    # Output:
    # Hi!
    

    Note: The loop.run_forever() call blocks the main thread while the loop keeps spinning. It does not return until loop.stop() is triggered from inside the loop. When loop.stop() is finally called by say_hi():

    • The loop finishes the current iteration.
    • run_forever() returns control to the main code.
    • The next Python statement (loop.close()) executes.
  • Inside a coroutine, the active loop can be retrieved with asyncio.get_running_loop(). This is safer than the older asyncio.get_event_loop(), because it ensures a loop is already running.

  • The loop can also schedule normal functions to run later. Some common helpers for this are:

    • loop.call_soon(callback, *args): It runs callback as soon as possible on the next loop iteration.
    • loop.call_later(delay, callback, *args): It runs callback after delay seconds.
    • loop.time(): It returns the loop’s internal monotonic clock (useful for scheduling).
    • loop.create_task(coroutine) or asyncio.create_task(coroutine): It converts a coroutine into a running Task.
    • Example:
    import asyncio
    
    def plain_callback(msg):
        print("Callback says:", msg)
    
    async def main():
        loop = asyncio.get_running_loop()
        loop.call_soon(plain_callback, "soon")
        loop.call_later(1, plain_callback, "after 1 second")
    
        await asyncio.sleep(1.5)   # keep loop alive long enough
    
    asyncio.run(main())
    

    Output:

    Callback says: soon
    Callback says: after 1 second
    

Tasks

A Task is a wrapper that schedules a coroutine to run on the event loop. Creating a Task immediately schedules execution (it does not need an explicit call to "start"). Tasks are awaitable (awaiting a task yields its return value).

Coroutine vs Task

A coroutine is just the recipe (the object returned by calling an async def function). But a task is a wrapper that tells the event loop to start running that coroutine in the background.

Example:

coroutine = my_coroutine()            # just a coroutine object
task = asyncio.create_task(coroutine) # now scheduled to run
Enter fullscreen mode Exit fullscreen mode

task starts immediately on the event loop.
await task waits for the result.

Creating Tasks

Coroutines by themselves are not scheduled. Coroutines are wrapped in Tasks so that they can run. For example:

import asyncio

async def say_after(delay, msg):
    await asyncio.sleep(delay)
    print(msg)

async def main():
    t1 = asyncio.create_task(say_after(1, "One"))
    t2 = asyncio.create_task(say_after(2, "Two"))
    await t1
    await t2

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

One
Two
Enter fullscreen mode Exit fullscreen mode

Important properties

  • Tasks are instances of asyncio.Task, which is a subclass of asyncio.Future.
  • Tasks can be created with asyncio.create_task(coroutine) (preferred inside a running loop) or loop.create_task(coroutine). It can also be created using asyncio.ensure_future(coroutine_or_future), which accepts either a coroutine or a Future and ensures a Task if needed.
  • The Task runs until the coroutine yields (via await) or completes.

Important functions

  • task.cancel(): It requests cancellation. Awaiting a cancelled task raises asyncio.CancelledError.
  • task.done(): It returns True if the task is complete.
  • task.cancelled(): It returns True if the task is cancelled.
  • task.result(): It returns the value or raises the original exception (if the task finished with an exception). It should be called only if task.done() is True.
  • task.exception(): It returns the exception instance (if any).
  • task.add_done_callback(cb): It registers a callback called with the task as a single argument when completed.

Example:

import asyncio

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

async def main():
    t1 = asyncio.create_task(worker("A", 1))
    t2 = asyncio.create_task(worker("B", 2))

    def on_done(t):
        try:
            print("Callback result:", t.result())
        except Exception as e:
            print("Callback exception:", e)

    t1.add_done_callback(on_done)

    # Cancel t2 before it finishes
    await asyncio.sleep(0.5)
    t2.cancel()

    try:
        await t2
    except asyncio.CancelledError:
        print("t2 was cancelled")

    res1 = await t1
    print("res1:", res1)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

t2 was cancelled
Callback result: A done
res1: A done
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. Program starts: asyncio.run(main()) creates an event loop and starts running the main() coroutine.
  2. Inside main(): Two coroutines (worker("A", 1) and worker("B", 2)) are created and immediately scheduled using asyncio.create_task().
    • t1 starts working on worker("A", 1)
    • t2 starts working on worker("B", 2)
    • Both are now running concurrently inside the event loop.
  3. Callback added: The on_done() callback is attached to t1. This means, when t1 finishes, the callback will automatically be called and print the result.
  4. Pause for 0.5 seconds: The line await asyncio.sleep(0.5) pauses the main() coroutine for half a second.
    • During this time, both t1 and t2 get to run.
    • They both enter their own await asyncio.sleep(delay) calls (1s and 2s respectively).
    • So, both are currently “sleeping” asynchronously (waiting).
  5. Cancel task t2: After 0.5 seconds, main() wakes up and calls t2.cancel().
    • Task t2 is cancelled while it’s still sleeping for 2 seconds.
    • Its coroutine (worker("B", 2)) receives a CancelledError.
  6. Handle cancellation: await t2 tries to wait for t2 to finish, but since it was cancelled, it raises a CancelledError.
    • The try-except block catches this and prints: t2 was cancelled.
  7. Wait for t1 to finish: By now, around 0.5 seconds have passed.
    • t1 is still sleeping but will complete soon (after a total of 1 second from start).
    • When t1 finishes:
      • The on_done() callback is triggered first, it prints: Callback result: A done
      • Then, res1 = await t1 receives the result "A done" and prints: res1: A done
  8. End of program: After both tasks are done (or cancelled), main() finishes. asyncio.run() closes the event loop.

Futures

A Future is a promise of a value that will be available later. The asyncio.Future is a low-level building block used by asyncio. Normally, high-level code uses await on coroutines or tasks. But sometimes, custom code needs a raw Future to signal that some event or computation finished.

A Future:

  • Starts as pending (no result yet).
  • Later becomes done with either:
    • a result (success), or
    • an exception (failure).

Creating a Future

Inside a running event loop:

loop = asyncio.get_running_loop()
future = loop.create_future()
Enter fullscreen mode Exit fullscreen mode

(or asyncio.Future(), but using the loop is safer).

At this point, the future is empty. If a coroutine awaits it, it will pause until someone sets its result.

Completing a Future

The result can be supplied later:

future.set_result(value)      # Finish successfully
future.set_exception(error)   # Finish with an error
Enter fullscreen mode Exit fullscreen mode

Once a result or exception is set:

  • Any coroutine waiting with await future resumes immediately.
  • The result can be retrieved with future.result().
  • If an exception was set, future.result() raises that exception.

Important functions

  • future.set_result(value): Marks the future as finished successfully and stores value as the result.
  • future.set_exception(exc): Marks the future as finished with an error and stores exc as the exception.
  • future.result(): Retrieves the stored value if the future has completed successfully.
    • Raises an exception if the future finished with an error.
    • Raises InvalidStateError if the future is not done yet.
  • future.exception(): Returns the exception object if the future finished with an error, or None if it succeeded.
  • future.add_done_callback(cb): Registers a function cb(future) that is automatically called once the future completes, whether it succeeds or fails.
  • Thread-safety: set_result and set_exception must be called from the event loop’s own thread.
    • If the result must be set from another thread, use loop.call_soon_threadsafe(future.set_result, value) to safely schedule the update inside the loop.

Example (creating and awaiting a future):

import asyncio

async def waiter(future):
    print("Waiting for future...")
    result = await future              # Pause until fut gets a result
    print("Future done, result:", result)

async def main():
    loop = asyncio.get_running_loop()
    # Creating future
    future = loop.create_future()

    # After 1 second, set the result to "done"
    loop.call_later(1.0, future.set_result, "done")
    await waiter(future)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

Waiting for future...
Future done, result: done
Enter fullscreen mode Exit fullscreen mode

Flow:

Time →
│
│ 0.0s  ─► main() starts
│         ├── future created (empty)
│         ├── call_later(1s, set_result)
│         └── waiter(future) started → "Waiting for future..."
│
│ 0.0–1.0s → event loop idle (waiting)
│
│ 1.0s  ─► future.set_result("done")
│         ├── waiter resumes
│         └── prints "Future done, result: done"
│
│ 1.1s  ─► main() done → event loop stops
Enter fullscreen mode Exit fullscreen mode

Example (setting future result from a different thread):

import asyncio
from threading import Thread
import time

async def main():
    loop = asyncio.get_running_loop()
    future = loop.create_future()

    def other_thread():
        time.sleep(0.5)
        loop.call_soon_threadsafe(future.set_result, "from thread")

    Thread(target=other_thread).start()
    print(await future)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Output:

from thread
Enter fullscreen mode Exit fullscreen mode

Relationship between task, future, and awaitable

  • A coroutine function returns a coroutine object (an awaitable). A coroutine object itself does nothing until scheduled.
  • A Task wraps and schedules a coroutine. It is a Future (so isinstance(task, asyncio.Future) is True).
  • A Future is a placeholder for a result. Tasks are Futures with an attached coroutine and scheduling behaviour.
  • asyncio.create_task(coroutine) is the usual way to turn a coroutine into a running Task.
  • asyncio.ensure_future accepts either a coroutine or a Future and returns a Future or Task accordingly.

We have covered a lot in this article - from coroutines and tasks to futures and event loops. In the upcoming articles, we will go even deeper into asyncio, exploring:

  • How cooperative multitasking compares with threads and processes
  • Awaitable objects and running concurrent I/O in a single thread
  • Running multiple tasks concurrently with asyncio.gather and create_task
  • Async context managers (async with) and async iterators (async for)
  • The difference between blocking and non-blocking calls
  • Integrating asyncio with libraries like aiohttp and aiomysql
  • Using async context managers effectively
  • When to use await versus asyncio.run()

Stay tuned to unlock the full power of Python’s asynchronous programming!

Top comments (0)