140

What does asyncio.create_task() do? A bit of code that confuses me is this:

import asyncio

async def counter_loop(x, n):
    for i in range(1, n + 1):
        print(f"Counter {x}: {i}")
        await asyncio.sleep(0.5)
    return f"Finished {x} in {n}"

async def main():
    slow_task = asyncio.create_task(counter_loop("Slow", 4))
    fast_coro = counter_loop("Fast", 2)

    print("Awaiting Fast")
    fast_val = await fast_coro
    print("Finished Fast")

    print("Awaiting Slow")
    slow_val = await slow_task
    print("Finished Slow")

    print(f"{fast_val}, {slow_val}")

asyncio.run(main())

This outputs:

001 | Awaiting Fast
002 | Counter Fast: 1
003 | Counter Slow: 1
004 | Counter Fast: 2
005 | Counter Slow: 2
006 | Finished Fast
007 | Awaiting Slow
008 | Counter Slow: 3
009 | Counter Slow: 4
010 | Finished Slow
011 | Finished Fast in 2, Finished Slow in 4

I don't understand quite how this is working.

  1. Shouldn't the slow_task not be able to run until the completion of the fast_coro because it was never used in an asyncio.gather() method?
  2. Why do we have to await slow_task?
  3. Why is "Awaiting Slow" printed after the coroutine appears to have started?
  4. What really is a task? I know that what gather is doing is scheduling a task. And create_task supposedly creates a task.
1
  • 1
    As the log shows, a task is executed immediately Commented Jun 23, 2020 at 6:28

3 Answers 3

174

What does asyncio.create_task() do?

It submits the coroutine to run "in the background", i.e. concurrently with the current task and all other tasks, switching between them at await points. It returns an awaitable handle called a "task" which you can also use to cancel the execution of the coroutine.

It's one of the central primitives of asyncio, the asyncio equivalent of starting a thread. (In the same analogy, awaiting the task with await is the equivalent of joining a thread.)

Shouldn't the slow_task not be able to run until the completion of the fast_coro

No, because you explicitly used create_task to start slow_task in the background. Had you written something like:

    slow_coro = counter_loop("Slow", 4)
    fast_coro = counter_loop("Fast", 2)
    fast_val = await fast_coro

...indeed slow_coro would not run because no one would have yet submitted it to the event loop. But create_task does exactly that: submit it to the event loop for execution concurrently with other tasks, the point of switching being any await.

because it was never used in an asyncio.gather method?

asyncio.gather is not the only way to achieve concurrency in asyncio. It's just a utility function that makes it easier to wait for a number of coroutines to all complete, and submit them to the event loop at the same time. create_task does just the submitting, it should have probably been called start_coroutine or something like that.

Why do we have to await slow_task?

We don't have to, it just serves to wait for both coroutines to finish cleanly. The code could have also awaited asyncio.sleep() or something like that. Returning from main() (and the event loop) immediately with some tasks still pending would have worked as well, but it would have printed a warning message indicating a possible bug. Awaiting (or canceling) the task before stopping the event loop is just cleaner.

What really is a task?

It's an asyncio construct that tracks execution of a coroutine in a concrete event loop. When you call create_task, you submit a coroutine for execution and receive back a handle. You can await this handle when you actually need the result, or you can never await it, if you don't care about the result. This handle is the task, and it inherits from Future, which makes it awaitable and also provides the lower-level callback-based interface, such as add_done_callback.

Sign up to request clarification or add additional context in comments.

15 Comments

@BeastCoder asyncio.gather(*list_of_tasks) would wait for the tasks to finish and return the result of their respective coroutines. create_task returns a task, which you can await to get the result of whatever is (was) running. If it had already finished, don't worry, it'll keep the result anyway. Yes, gather calls create_task if needed for your convenience - this is because it supports awaitables other than coroutines.
@BeastCoder Pretty much, yes. Something like tasks = [asyncio.create_task(c) for c in list_of_coros]; results = [await t for t in tasks] would be a good first approximation of what gather does. The implementation is much more complex, however, because gather is very careful about how it propagates exceptions, how it responds to cancelation, as well as various other concerns.
@ArnabMukherjee There is no real "background", they run when you await something, and they are ready to run. These comments should be posted as a separate question; see also this one.
@aleks224 If you are referring to the code exactly as in the question, there is. In general asyncio doesn't guarantee the order in which runnable tasks are executed, but here the code awaits a coroutine, not a task. The code in a directly awaited coroutine is immediately executed (without yielding to the event loop) up to the first suspension. Since there is no suspension between await fast_coro and the first print, it will always before anything that comes from another task.
@aleks224 Immediately executing awaited coroutine up to a suspension is guaranteed, although you might find it hard to find chapter and verse in the docs. Basically it follows from await being both specified and implemented in terms of yield from, and this is how yield from must behave to satisfy the refactoring principle of PEP 380.
|
9

If you are working with coroutines (i.e. async functions) and you understand how asyncio.gather works, then asyncio.create_task is pretty much the same as using gather with one single argument.

In fact, these two lines of code are equivalent:

res = await asyncio.create_task(coro)
res, = await asyncio.gather(coro) 

create_task schedules the execution of the given coroutine and returns an awaitable object (a Task object specifically). When awaited, it will return the result of the coroutine.

gather schedules the execution of all the given coroutines and returns an awaitable object (a _GatheringFuture object specifically). When awaited, it will return a list with the results of all the coroutines, in the same order that they were passed.

Note that create_task only accepts a coroutine as an argument whereas gather can accept any awaitable object (i.e. a future, a coroutine, a task, the result of another gather, etc). The actual implementation of gather is a bit complex, but you can think of it as a method that just calls create_task for each of the passed coroutines.

1 Comment

thanks for the simple explanation of the difference, helps me understand asyncio
4

This is the event loop:

[EVENT LOOP]
[ task1    ] < current task
[ task2    ]
[ task3    ]
[          ]

It steps through tasks in round-robin (FIFO) order. The topmost task runs until it hits the next await. Then, that task is moved to the bottom so that the other tasks can take their turn:

[EVENT LOOP]
[ task2    ] < current task
[ task3    ]
[ task1    ]
[          ]

Say task2 runs task4 = asyncio.create_task(coro). This schedules a new task on the event loop:

[EVENT LOOP]
[ task2    ] < current task
[ task3    ]
[ task1    ]
[ task4    ] <- asyncio.create_task(coro)

The event loop now continues cycling through task2, task3, task1, task4 in that order.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.