skip to content

Tasks, Futures, and gather

Concurrency starts when you wrap coroutines in Tasks and run them with gather, wait or as_completed. Interviewers ask the difference between awaiting in a loop and gathering, and where errors surface.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What does asyncio.create_task() return, and when does its coroutine actually start running?

level: juniorimportance: must knowfreq 78%

answer

  1. Scheduling, not running
  2. You get an object back, not a value
  3. A Future subclass is what you hold
  4. The loop only gets control at an await

basics

~20 s

asyncio.create_task(coro) returns an asyncio.Task, a Future subclass that wraps the coroutine and schedules it on the running event loop. The coroutine does not run inside that call; it starts when the loop next gets control, at your following await.

solid answer

~50 s

`asyncio.create_task(coro)` hands the coroutine object to the **running** event loop and immediately returns an `asyncio.Task` — a handle, not a result. The call itself executes none of the coroutine body: the loop schedules the task's first step as a callback, so the code inside runs only once your own coroutine suspends at an `await` (or finishes). That is why `create_task` is what turns a coroutine into something that makes progress alongside you, while simply calling `sync_warehouse()` produces an inert coroutine object. Awaiting the returned Task later gives you the coroutine's return value, or re-raises whatever it raised. `create_task` requires a running loop — calling it at import time or from plain synchronous code raises `RuntimeError`. `asyncio.ensure_future` is the older, more permissive cousin: it accepts any awaitable and passes an existing Task or Future straight through.

code

python · 18 lines
python
import asyncio


async def sync_warehouse(name):
    print("started", name)
    await asyncio.sleep(0.01)
    return name.upper()


async def main():
    task = asyncio.create_task(sync_warehouse("depot-7"))
    print("create_task returned:", type(task).__name__, "- nothing has run yet")
    await asyncio.sleep(0)
    print("after one yield to the loop, the coroutine has started")
    print("result:", await task)


asyncio.run(main())

go deeper

for a junior

Recall the shape: create_task returns an asyncio.Task handle, awaiting that handle later gives you the value. Be able to say out loud that the coroutine body has not run yet when create_task returns.

for a middle

Explain the mechanics: create_task requires a running loop, queues the task's first step as a callback, and the body advances only when your coroutine suspends. Know that Task subclasses Future and carries done/result/exception.

for a senior

Show you reason about scheduling points in real code — why a task created before a synchronous CPU block does not progress, why awaiting in creation order is still concurrent, and when awaiting a stored Task twice is the right design.

for a principal

Own the guidance: which layers may create tasks at all, whether the codebase standardises on create_task over ensure_future, and whether an eager task factory is worth the changed timing assumptions for your workload.

### Three different objects, easily confused An `async def` function is a **coroutine function**. Calling it produces a **coroutine object** — a suspended, inert frame that has executed none of its body. A coroutine object does nothing on its own; something must drive it. `asyncio.create_task()` is the normal way to ask the event loop to be that driver, and what it gives you back is a third thing: an **`asyncio.Task`**, a handle onto work the loop has agreed to push forward. ```python coro = sync_warehouse("depot-7") # nothing has run task = asyncio.create_task(coro) # scheduled; still nothing has run ``` Keeping those three straight — coroutine function, coroutine object, Task — is most of what this question is testing. ### What create_task actually does `create_task` fetches the loop that is running *right now* (it fails with `RuntimeError: no running event loop` if there is none), constructs a `Task` around the coroutine, and asks the loop to call the task's first step **soon** — that is, it appends a callback to the loop's ready queue. Then it returns. Control is still inside your coroutine; the loop has not run anything. The very earliest the task body can execute is the next time your coroutine gives the loop control, which happens when you hit an `await` that actually suspends, or when your coroutine returns. This is why `await asyncio.sleep(0)` is the classic "let everything scheduled so far take a step" idiom. It is also why a task created immediately before a long block of purely synchronous CPU work does not run during that work: the loop is single-threaded and you are holding it. ### The Task is a Future subclass `asyncio.Task` inherits from `asyncio.Future`, so it carries the whole result protocol: * `task.done()` — has it finished, one way or another? * `task.result()` — the coroutine's return value, or a re-raise of its exception. Calling it before the task is done raises `asyncio.InvalidStateError`. * `task.exception()` — the exception object instead of a re-raise, or `None`. * `task.add_done_callback(fn)` — schedule `fn` when it completes. Awaiting the task is the ergonomic path: `value = await task` suspends until it is done and then gives you the result or re-raises. Awaiting an already-finished task returns immediately, and awaiting the same task twice is fine — a Task holds its outcome, so the second `await` simply reads the stored value. That is very different from a coroutine object, which can only be driven once; awaiting the same coroutine object twice raises `RuntimeError`. ### ensure_future, and why create_task is the one to use `asyncio.ensure_future(obj)` predates `create_task` and is deliberately permissive: given a coroutine it wraps it in a Task, given an existing Future or Task it returns it unchanged, and given any other awaitable it wraps it appropriately. That flexibility is useful inside libraries that accept "whatever the caller had", but in application code it hides which of those three happened. `asyncio.create_task` takes a coroutine and always produces a new Task, which is why the documentation points application code at it. ### When the coroutine starts, revisited The default task factory is *lazy* in the sense described above — the body waits for the loop. Since 3.12, CPython also ships `asyncio.eager_task_factory`, an opt-in factory you install on the loop: with it, `create_task` runs the coroutine synchronously up to its first real suspension point, and a coroutine that never actually suspends (an all-cache-hit path, say) completes without ever touching the loop's queue. This is a throughput optimisation for workloads dominated by coroutines that usually finish without I/O; it is not the default, and code should not depend on either timing. ### The everyday consequence Because the task is scheduled but not yet running when `create_task` returns, code like this is concurrent: ```python a = asyncio.create_task(fetch_a()) b = asyncio.create_task(fetch_b()) results = [await a, await b] ``` Both tasks were handed to the loop before the first `await`, so both make progress while you wait. The `await` order only determines the order you *collect* results, not the order they run. Understanding that `create_task` is a scheduling call and `await` is a collection point is the whole mental model. A final practical note: a Task you never keep a reference to and never await is a live hazard, because the loop only holds a weak reference to running tasks — that failure mode has its own dedicated discussion.

  • What does asyncio.ensure_future() accept that asyncio.create_task() does not?
    `ensure_future` takes any awaitable: a coroutine (wrapped in a new Task), an existing Task or Future (returned unchanged), or an arbitrary object with `__await__` (wrapped appropriately). `create_task` takes a coroutine and always returns a new Task. `ensure_future` is the library-facing normaliser for "whatever the caller passed"; application code should say `create_task` so the reader can see a task is genuinely being created.
  • What happens if you call asyncio.create_task() outside a running event loop?
    It raises `RuntimeError: no running event loop`. `create_task` resolves the loop via `asyncio.get_running_loop()`, so it only works from inside a coroutine or a callback the loop is executing. Creating tasks at import time or from ordinary synchronous code is therefore impossible — you need to be inside something `asyncio.run()` is driving.
  • Can you await the same Task twice, or the same coroutine object twice?
    The Task, yes: it stores its outcome, so a second `await` returns the same result (or re-raises the same exception) immediately. A coroutine object, no — it can only be driven once, and awaiting it again raises `RuntimeError: cannot reuse already awaited coroutine`. That asymmetry is one practical reason to wrap work in a Task when more than one place needs the answer.

Calling the coroutine function writes the work order; create_task drops it in the dispatcher's inbox and hands you the tracking number. The dispatcher only looks at the inbox once you stop talking to them.

saying these in an interview costs you the question

  • Says create_task runs the coroutine immediately, inline
  • Thinks create_task returns the coroutine's result value
  • Uses "coroutine" and "Task" as if they were one thing
  • Believes the task only starts when you await it
  • Calls create_task from synchronous module-level code

context

open as a page

How does asyncio.gather report an exception raised by one of its awaitables?

level: middleimportance: must knowfreq 68%

basics

~20 s

By default asyncio.gather propagates the first exception out of the await immediately, while the other awaitables keep running untouched. With return_exceptions=True it instead waits for everything and returns exception objects in the results list, positionally.

open as a page

What distinguishes an asyncio.Task from a plain asyncio.Future?

level: middleimportance: should knowfreq 44%

basics

~20 s

asyncio.Task is a subclass of asyncio.Future. A Future is an empty result slot someone else fills with set_result or set_exception; a Task additionally owns a coroutine and drives it step by step on the event loop until it completes.

open as a page

When would you use asyncio.wait with FIRST_COMPLETED instead of asyncio.gather, and what must you clean up afterwards?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Use asyncio.wait with return_when=asyncio.FIRST_COMPLETED when you only need the earliest answer, such as racing a cache against an authoritative source. It returns done and pending sets, raises nothing itself, and leaves the pending tasks running for you to cancel.

open as a page