DEV Community

Chandrashekhar Kachawa
Chandrashekhar Kachawa

Posted on • Originally published at ctrix.pro

Python Asyncio: The Real Difference Between `await` and `create_task`

Python's asyncio framework is incredibly powerful for building high-performance I/O-bound applications, but it comes with a few concepts that can be tricky for newcomers. Perhaps the most important one to master is the distinction between directly awaiting a coroutine and creating a task to run it.

Getting this right is the key to unlocking true concurrency and moving from sequential code to parallel execution. Let's break it down.

The Basics: Coroutines and Sequential await

First, let's set the stage. An async def function defines a coroutine. You can think of it as a function that can be paused and resumed. The await keyword is what pauses it, and asyncio.run() is the top-level entry point that starts the whole process.

Consider this simple program:

import asyncio
import time

async def say_after(delay: int, what: str) -> None:
    print(f"  - starting '{what}'")
    await asyncio.sleep(delay)
    print(f"  - finished '{what}'")

async def main():
    start_time = time.time()
    print(f"Started at {time.strftime('%X')}")

    await say_after(1, "hello")
    await say_after(2, "world")

    end_time = time.time()
    print(f"Finished at {time.strftime('%X')} (took {end_time - start_time:.2f} seconds)")

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

When you run this, pay close attention to the output and the total time:

Started at 14:30:00
  - starting 'hello'
  - finished 'hello'
  - starting 'world'
  - finished 'world'
Finished at 14:30:03 (took 3.01 seconds)
Enter fullscreen mode Exit fullscreen mode

This took 3 seconds. The program waited for "hello" to finish completely (1 second) and only then did it start waiting for "world" (another 2 seconds). This is sequential, not concurrent.

The Problem: await is a Blocking Operation

The await say_after(...) expression does two things: it ensures the coroutine runs, but it also blocks the main function from continuing until that specific coroutine is complete. It's like a one-lane road; you must wait for the car in front of you to finish its journey before you can start yours.

To achieve concurrency, we need a way to start both "journeys" at the same time and then wait for them both to finish.

The Solution: asyncio.create_task() for Concurrency

This is where asyncio.create_task() comes in.

When you call asyncio.create_task(my_coroutine), you are telling the asyncio event loop: "Schedule this coroutine to run as soon as possible. Don't wait for it. Give me a Task object right away so I can check on it later."

The Task object is a wrapper that lets you manage the execution. Your code can continue doing other things, and later, you can await the Task object to get the result (or just to ensure it's finished).

Let's rewrite our main function using create_task:

async def main():
    start_time = time.time()
    print(f"Started at {time.strftime('%X')}")

    # Schedule both coroutines to run on the event loop immediately.
    # This is non-blocking.
    task1 = asyncio.create_task(say_after(1, "hello"))
    task2 = asyncio.create_task(say_after(2, "world"))

    print("Tasks created. Now waiting for them to complete.")

    # Now we block and wait for the tasks to be done.
    await task1
    await task2

    end_time = time.time()
    print(f"Finished at {time.strftime('%X')} (took {end_time - start_time:.2f} seconds)")

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

Look at the output now:

Started at 14:35:00
Tasks created. Now waiting for them to complete.
  - starting 'hello'
  - starting 'world'
  - finished 'hello'
  - finished 'world'
Finished at 14:35:02 (took 2.01 seconds)
Enter fullscreen mode Exit fullscreen mode

The total time is now 2 seconds (the duration of the longest task). Both say_after calls started at roughly the same time. This is true concurrency. We performed useful work (scheduling the second task) while the first one was already in its "waiting" phase.

A More Convenient Way: asyncio.gather()

Manually creating and awaiting tasks works, but for waiting on a group of tasks to complete, asyncio.gather() is more convenient. It wraps coroutines in tasks for you and waits for them all to finish.

Here is the most common and Pythonic way to write our main function:

async def main():
    start_time = time.time()
    print(f"Started at {time.strftime('%X')}")

    await asyncio.gather(
        say_after(1, "hello"),
        say_after(2, "world")
    )

    end_time = time.time()
    print(f"Finished at {time.strftime('%X')} (took {end_time - start_time:.2f} seconds)")

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

This produces the same concurrent 2-second result but with less boilerplate code.

Conclusion

Understanding this distinction is fundamental to using asyncio effectively.

  • await my_coro(): Runs and waits. Use this when you need the result of one step before starting the next. It's for sequential asynchronous code.
  • asyncio.create_task(my_coro): Schedules and continues. Use this when you want to start a background operation and do other things at the same time. It's the key to concurrent asynchronous code.

When you want to run multiple I/O-bound operations and not have them wait on each other, reach for asyncio.create_task() or the more convenient asyncio.gather() to unlock the true power of asyncio.

Top comments (0)