Terra Green
Terra Green

Reputation: 61

What does asyncio.create_task actually do?

I'm trying to understand how does asyncio.create_task actually work. Suppose I have following code:

import asyncio
import time

async def delayer():
    await asyncio.sleep(1)

async def messenger():
    await asyncio.sleep(1)
    return "A Message"

async def main():
    message = await messenger()
    await delayer()

start_time = time.time()

asyncio.run(main())

end_time = time.time() - start_time

print(end_time)

The code will take about 2 seconds. But if I make some changes to the body of main like this:

import asyncio
import time

async def delayer():
    await asyncio.sleep(1)

async def messenger():
    await asyncio.sleep(1)
    return "A Message"

async def main():
    task1 = asyncio.create_task(delayer())
    task2 = asyncio.create_task(delayer())

    await task1
    await task2

start_time = time.time()

asyncio.run(main())

end_time = time.time() - start_time

print(end_time)

Now the code will take about 1 second.

My understanding from what I read is that await is a blocking process as we can see from the first code. In that code we need to wait 1 second for the messenger function to return, then another second for delayer function.

Now the real question come from the second code. We just learnt that await need us to wait for its expression to return. So even if we use async.create_task, shouldn't awaits in one of the function's body block the process and then return whenever it finishes its job, thus should give us 2 seconds for the program to end?

If that wasn't the case, can you help me understand the asyncio.create_task?

What I know:

What I am expecting: I hope I can get a simple but effective answer about how does asyncio.create_task conduct its work using my sample code.

Upvotes: 3

Views: 2008

Answers (1)

Paul Cornelius
Paul Cornelius

Reputation: 10916

Perhaps it will help to think in the following way.

You cannot understand what await does until you understand what an event loop is. This line:

asyncio.run(main())

creates and executes an event loop, which is basically an infinite loop with some methods for allowing an exit - a "semi-infinite" loop, so to speak. Until that loop exits, it will be entirely responsible for executing the program. (Here I am assuming that your program has only a single thread and a single Process. I'm not talking about concurrent program in any form.) Each unit of code that can run within an event loop is called a "Task." The idea of the loop is that it can run multiple Tasks by switching from one to another, thus giving the illusion that the CPU is doing more than one thing at a time.

The asyncio.run() call does a second thing: it creates a Task, main(). At that moment, it's the only Task. The event loop begins to run the Task at its first line. Initially it runs just like any other function:

async def main():
    task1 = asyncio.create_task(delayer())
    task2 = asyncio.create_task(delayer())

    await task1
    await task2

It creates two more tasks, task1 and task2. Now there are 3 Tasks but only one of them can be active. That's still main(). Then you come to this line:

    await task1

The await keyword is what allows this whole rigmarole to work. It is an instruction to the event loop to suspend the active task right here, at this point, and possibly allow another Task to become the active one. So to address your first bullet point, await is neither "blocking" nor is it a "process". Its purpose is to mark a point at which the event loop gets control back from the active Task.

There is another thing happening here. The object that follows the await is called, unimaginatively, an "awaitable" object. Its crucial property is whether or not it is "done." The event loop keeps track of this object; as the loop cycles through its Tasks it will keep checking this object. If it's not done, main() doesn't resume. (This isn't exactly how it's implemented because that would be inefficient, but it's conceptually what's happening.) If you want to say that the await is "blocking" main() until task1 is finished, that's sort-of true; but "blocking" has a technical meaning so it's not the best word to use. In any case, the event loop is not "blocked" at all - it can keep running other Tasks until the awaitable task1 is done. After task1 becomes "done" and main() gets its turn to be the active task, execution continues to the next line of code.

Your second bullet point, "await executes coroutine function and task object" is not correct. await doesn't execute anything. As I said, it just marks a point where the Task gets suspended and the event loop gets control back. Its awaitable determines when the Task can be resumed.

You say, "await makes [it] possible to pause coroutine process". Not quite right - it ALWAYS suspends the current Task. Whether or not there is a significant delay in the Task's execution depends on whether there are other Tasks that are ready to take over, and also the state of its awaitable.

"create_task creates task object and then schedule and execute it as soon as possible." Correct. But "as soon as possible" means the next time the current Task hits an await expression. Other Tasks may get a turn to run first, before the new Task gets a chance to start. Those details are up to the implementation of the event loop. But eventually the new Task will get a turn.

In the comments you ask, "Is it safe if I say that plain await, not being involved in any event loop or any kind of it, works in blocking manner?" It's absolutely not safe to say that. First of all, there is no such thing as a "plain await". Your task must wait FOR something, otherwise how would the event loop know when to resume? An await without an event loop is either a syntax error or a runtime error - it makes no sense, because await is a point where the Task and the event loop interact. The main point is that event loops and await expression are intimately related: an await without an event loop is an error; an event loop without any await expressions is useless.

The closest you can come to a plain await is this expression:

await asyncio.sleep(0)

which has the effect of suspending the current Task momentarily, giving the event loop a chance to run other tasks, resuming this Task as soon as possible.

One other point is that the code:

await task1

is an expression which has a value, in this case the returned value from task1. Since your task1 doesn't return anything this will be None. But if your delayer function looked like this:

async def delayer():
    await asyncio.sleep(1)
    return "Hello"

then in main() you could write:

print(await task1)

and you would see "Hello" on the console.

Upvotes: 8

Related Questions