What Is an asyncio.Task and How Is It Scheduled?
An asyncio.Task is a core building block in Python's
asynchronous programming model that wraps a coroutine to run
concurrently in an event loop. This article explains the nature of a
Task, how it differs from a raw coroutine, and the internal
step-by-step mechanism the event loop uses to register, schedule, and
drive its execution to completion.
What Is an asyncio.Task?
In Python, a standard coroutine defined with async def
does not run automatically when called; it merely returns a coroutine
object. To execute concurrently alongside other operations, that
coroutine must be wrapped inside an asyncio.Task.
A Task is a concrete subclass of
asyncio.Future. It serves two primary responsibilities:
- State Tracking: It tracks the execution state of the wrapped coroutine (e.g., pending, running, finished, or cancelled) and stores the eventual return value or raised exception.
- Execution Driver: It repeatedly steps through the
coroutine's execution frames using
coro.send(None)whenever the coroutine is ready to resume.
Creating a task is commonly done via
asyncio.create_task(coro) (introduced in Python 3.7) or the
lower-level loop.create_task(coro).
How a Task Is Scheduled onto the Event Loop
The transition from a raw coroutine to a running background task follows a precise internal sequence:
1. Task Initialization and Immediate Registration
When asyncio.create_task(coro) is called, the
Task object is instantiated immediately. During its
initialization (__init__), the task automatically schedules
its own execution onto the current event loop:
loop.call_soon(self.__step, context=self._context)The task does not wait for an explicit await to register
itself. By passing its internal driving method (conventionally named
__step) to loop.call_soon, the task requests
that the event loop run it at the earliest opportunity.
2. Placement in the Event Loop Ready Queue
The event loop maintains an internal FIFO queue of callbacks that are
ready to run (often an internal collections.deque named
_ready). Calling loop.call_soon() places a
Handle pointing to task.__step directly into
this ready queue.
3. Driving the Coroutine
(__step)
When the event loop reaches its next iteration, it pulls handles from
the _ready queue and executes them:
- The loop calls
task.__step(). - Inside
__step, the task advances the coroutine by invokingresult = coro.send(None). - The coroutine executes synchronously until it either returns a
value, raises an exception, or yields control back to the loop via an
awaitexpression.
4. Handling Suspension
(await)
When the coroutine hits an await other_awaitable
expression, it yields control back to __step:
- If
other_awaitableis an I/O operation (such as a network socket read), the event loop registers the underlying file descriptor with an OS-level selector (likeepoll,kqueue, or IOCP). - The task attaches a callback to that low-level future using
add_done_callback(). - Once the requested I/O event completes or the awaited future
resolves, the callback executes and calls
loop.call_soon(task.__step)again, moving the task back into the_readyqueue.
5. Completion and Cleanup
When the coroutine finishes execution without awaiting further operations:
- If it returns a value, the
Taskcatches the resultingStopIterationexception, marks itself asdone, and sets its internal result to the returned value. - If it raises an unhandled exception, the
Taskcatches it, sets it as its internal exception state, and marks itself asdone. - Finally, the task triggers any callbacks registered via
task.add_done_callback()and removes itself from the event loop's active tasks set.