Yun Sheng's Site
A Little Bit of This, A Little Bit of That

async await in asyncio

tldr

  • An await may suspend the current task, allowing the event loop to run other ready work on the same thread.
  • async/await within a single thread can still have race conditions
  • In general, async/await is cooperative scheduling, this means tasks/jobs needs to voluntarily “give up” control
  • async/await depends on the “execution environment”, the execution environment could be single threaded or multi threaded.
  • python’s asyncio tasks could be garbage collected, so hold a reference to it

Overview

Since the JavaScript days I don’t think I ever understood async/await to the point where I’d feel comfortable.

We recently got a bug in our code and it was related to async/await usage in python’s asyncio.

I’m also trying to learn tokio to be able to try out Framed

All the above resulted into me vibing with Gemini and Claude again to go over this topic.

What is async/await about?

Instead of thinking async/await as language keywords, I think it’s best to understand them as a programming pattern.

Say I want to do two things this weekend, drink coffee and play games. However I don’t have coffee at home, so I want my kid to go to Starbucks and get me a cup of coffee. I could do one of the following.

  1. Wait at the door until he comes back with my coffee
  2. Before he goes to get coffee, tell him that I’m going to play games and he could shove coffee in my face when he gets it back
  3. Me and my kid work with my wife, she would help coordinate

It’s not hard to tell that (1) above is blocking. (2) is the good old callback function and (3) is async/await where the scheduler is my wife.

Let’s write some code for the above.

The case where I wait for my coffee

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import time
from datetime import datetime


def print_with_time(x):
    now = datetime.now()
    print(now, x)


def send_kid_to_get_coffee():
    print_with_time("kid goes to get coffee")
    time.sleep(5)
    print_with_time("kid got coffee after 5 seconds")
    return "coffee"


def dad_plays_games():
    print_with_time("dad starting to play games")
    time.sleep(3)
    print_with_time("dad played for 3 seconds")
    time.sleep(7)
    print_with_time("dad played for 7 seconds")

    print_with_time("dad done playing games")


coffee = send_kid_to_get_coffee()
print_with_time('dad drinks ' + coffee)
dad_plays_games()

The above is an abstraction of scenario (1), I send kid to get coffee but I wait at the door for 5 seconds. I don’t get to play video games until my kid gets back with my coffee.

Running the above would result the following output

shengy@Mac ~/tmp/yo [22:30:09]
> python3 block.py
2026-08-02 22:30:13.506923 kid goes to get coffee
2026-08-02 22:30:18.511882 kid got coffee after 5 seconds
2026-08-02 22:30:18.511983 dad drinks coffee
2026-08-02 22:30:18.511993 dad starting to play games
2026-08-02 22:30:21.515760 dad played for 3 seconds
2026-08-02 22:30:28.520653 dad played for 7 seconds
2026-08-02 22:30:28.520798 dad done playing games

This obviously is a waste of my time, so how about we try out (2). The main idea of (2) is that I do not need to wait for my kid to get my coffee back. Before I sent him to get my coffee, I gave him a “callback” to pass the coffee into when he gets it.

The case where I use a worker thread and callback

Since time.sleep() blocks the thread that calls it, I run send_kid_to_get_coffee() on a separate thread so it can proceed concurrently with dad_plays_games().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import time
import threading
from datetime import datetime


def print_with_time(x):
    now = datetime.now()
    print(now, x)


def send_kid_to_get_coffee(cb):
    print_with_time("kid goes to get coffee")
    time.sleep(5)
    print_with_time("kid got coffee after 5 seconds")
    cb("coffee")


def dad_plays_games():
    print_with_time("dad starting to play games")
    time.sleep(3)
    print_with_time("dad played for 3 seconds")
    time.sleep(7)
    print_with_time("dad played for 7 seconds")

    print_with_time("dad done playing games")


def hand_dad_his_coffee(coffee):
    print_with_time('dad drinks ' + coffee)


kid_thread = threading.Thread(
    target=send_kid_to_get_coffee,
    args=(hand_dad_his_coffee,)
)

kid_thread.start()
dad_plays_games()

kid_thread.join()

This is multi threading in python, I created a new thread to send the kid to get coffee, and provided it a callback function to hand dad the coffee.

shengy@Mac ~/tmp/yo [22:31:50]
> python3 callback.py
2026-08-02 22:32:11.885150 kid goes to get coffee
2026-08-02 22:32:11.885185 dad starting to play games
2026-08-02 22:32:14.890236 dad played for 3 seconds
2026-08-02 22:32:16.890134 kid got coffee after 5 seconds
2026-08-02 22:32:16.890338 dad drinks coffee
2026-08-02 22:32:21.895275 dad played for 7 seconds
2026-08-02 22:32:21.895467 dad done playing games

The case where I rely on a scheduler

Now we are going to look at the scenario where we work with my wife (the scheduler). For the purpose of demonstrating async/await, we would be limiting to one thread. This is what people usually mean when they say JavaScript is single-threaded.

async/await does not need to limit itself to single threads, for instance in Python you could kick off threads inside async functions and Rust’s tokio execution environment can schedule jobs across threads.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import time
import asyncio
from datetime import datetime


def print_with_time(x):
    now = datetime.now()
    print(now, x)


async def send_kid_to_get_coffee():
    print_with_time("kid goes to get coffee")
    await asyncio.sleep(5)
    print_with_time("kid got coffee after 5 seconds")
    return 'coffee'


async def dad_plays_games():
    print_with_time("dad starting to play games")
    await asyncio.sleep(3)
    print_with_time("dad played for 3 seconds")
    await asyncio.sleep(7)
    print_with_time("dad played for 7 seconds")

    print_with_time("dad done playing games")


async def main():
    # kicks off both tasks
    kid_task = asyncio.create_task(send_kid_to_get_coffee())
    dad_task = asyncio.create_task(dad_plays_games())

    # await here yields control so dad task can progress
    coffee = await kid_task

    # here kid_task finished so dad drinks coffee
    print_with_time('dad drinks ' + coffee)

    # wait for dad to finish his gaming session
    await dad_task

asyncio.run(main())

A few important things.

  1. The code snippet above is single threaded
  2. kid_task and dad_task were all added to the event loop at the beginning
  3. Whenever an await was executed the current execution flow can “give up” the control to the scheduler, then the scheduler picks the next place to start.

In python’s asyncio an await does not give up control when the awaited object completes without suspending the current task. For example, directly awaiting a coroutine that returns without reaching a suspension point does not give control back to the event loop. However in JavaScript, await pauses the current async function and schedules its continuation as a microtask.

Let’s try to go through how things happened line by line.

  1. asyncio.run(main()), we created the event loop scheduler and starts running the main function
  2. kid_task = asyncio.create_task(send_kid_to_get_coffee()), creates the task and adds it to the event loop we just created, this task itself is not running yet because the control is still owned by main
  3. dad_task = asyncio.create_task(dad_plays_games()), creates the task and adds it to the event loop we just created, this task itself is not running yet because the control is still owned by main
  4. coffee = await kid_task, control sees the await keyword, tells event loop “I’m pausing here until kid_task is done, take control”
  5. Event loop sees that kid_task should be the next, so it hands kid_task the control
  6. In send_kid_to_get_coffee we print “kid goes to get coffee”, then we hit await asyncio.sleep(5)
  7. Same as above, hitting await asyncio.sleep(5) inside send_kid_to_get_coffee tells the event loop: “I am not doing anything until asyncio.sleep(5) is done, take away my control.
  8. Event loop control and sees dad_task should be next, dad_plays_games gets the control.
  9. dad_plays_games prints “dad starting to play games”
  10. dad_plays_games hits await asyncio.sleep(3), tells event loop “I’m pausing here until asyncio.sleep(3) is done”, takeaway my control.
  11. Event loop takes back control and there isn’t anything going on
  12. After 3 seconds the first asyncio.sleep(3) in dad_plays_games is done, event loop gives dad_task the control, so dad_task prints “dad played for 3 seconds”.
  13. dad_task hits await asyncio.sleep(7), tells event loop “I’m pausing here until asyncio.sleep(7) is done”, take away my control.
  14. Event loop tries to find tasks, await asyncio.sleep(5) in kid_task finished, so event loop gives kid_task the control.
  15. kid_task prints “kid got coffee after 5 seconds” and returns 'coffee'
  16. kid_task was done so await kid_task was done so control was handed back to main, prints “dad drinks coffee”
  17. main says await dad_task, so control was handed back to event loop
  18. After 7 seconds await asyncio.sleep(7) is done so dad_task gets the control, prints “dad played for 7 seconds”
  19. dad_task is done, event loop gives main back control and the whole program is done
shengy@Mac ~/tmp/yo [22:32:21]
> python3 asyncawait.py
2026-08-02 22:32:58.441104 kid goes to get coffee
2026-08-02 22:32:58.441137 dad starting to play games
2026-08-02 22:33:01.442403 dad played for 3 seconds
2026-08-02 22:33:03.442122 kid got coffee after 5 seconds
2026-08-02 22:33:03.442229 dad drinks coffee
2026-08-02 22:33:08.445115 dad played for 7 seconds
2026-08-02 22:33:08.445230 dad done playing games

If there is only one thing to take away, it is that await may suspend the current task and return control to the event loop, allowing other tasks to run.

A bit more about python asyncio

Coroutine and Tasks

Application-level asyncio code mainly works with Coroutines and Tasks, although Futures are the third main awaitable abstraction.

When we call a function that was marked async in python, instead of executing it, it returns a coroutine.

shengy@Mac ~/tmp/yo [21:14:01]
> python3
Python 3.14.6 (main, Jun 10 2026, 10:03:53) [Clang 21.0.0 (clang-2100.0.123.102)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> async def yo():
...     print("yo")
...
>>> yo()
<coroutine object yo at 0x10a45f100>
>>>

A coroutine doesn’t run on its own, calling it just builds the coroutine object, it doesn’t start executing. To actually drive it, you need to either:

  • await it directly, in which case it runs as part of whoever is currently awaiting it (no new task involved), or
  • Wrap it in asyncio.create_task(), which registers it with the event loop as its own independent task, so it can run concurrently with other tasks.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import asyncio

async def yo():
    print("yo")

async def main():
    # await a coroutine directly, it runs as part of the main task
    await yo()

asyncio.run(main())
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import asyncio

async def yo():
    print("yo")

async def main():
    # creates a new task and runs as an independent task
    task = asyncio.create_task(yo())
    await task

asyncio.run(main())

Note that calling main() also creates a coroutine, and instead of creating a task with it we passed it straight to asyncio.run(), this is how you run the top-level entry point. This is where everything starts.

Garbage Collector Gotcha

In the code snippet above we held a reference to the created task, if we do not do that the code still runs but there is a huge gotcha here.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import asyncio

async def yo():
    print("yo")

async def main():
    # task created here might be garbage collected
    asyncio.create_task(yo())

asyncio.run(main())

Note that we did not hold a reference to the task created (no variable holding the result of asyncio.create_task(yo()))

This means the task might be garbage collected by python’s runtime and give undesired behaviors.

create_task explicitly said that the reference asyncio has to the task is a weak reference, so in order to avoid the created task to be garbage collected, we need to hold references to them.

You might ask why did we create the task and not hold the reference? It’s mainly used for the “fire and forget” case, in the case above the intent was to fire and forget yo() as a task. The right way is to hold them somewhere and once done remove them (to let the garbage collector do its job)

I’ll pull the documentation’s code snippet here

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
background_tasks = set()

for i in range(10):
    task = asyncio.create_task(some_coro(param=i))

    # Add task to the set. This creates a strong reference.
    background_tasks.add(task)

    # To prevent keeping references to finished tasks forever,
    # make each task remove its own reference from the set after
    # completion:
    task.add_done_callback(background_tasks.discard)

The Bug

OK so I spent a lot of time going through async/await in python’s asyncio. But what was the bug we had?

The bug originated from the following misunderstanding

Event Loop is single threaded, and in single thread programs there are no race conditions.

The above claim is WRONG.

Let’s recap what does await do, await means the current control tells the event loop: “I’m waiting for this operation to finish, so I’ll pause and give the event loop a chance to run other tasks.”

Assume that we want to only say hi or hello if no greeting was said, and tried to come up with the following code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

import asyncio

greeting_said = False

async def say_hi_only_if_greeting_not_said():
    global greeting_said
    if not greeting_said:
        await asyncio.sleep(1)
        print("hi")
        greeting_said = True

async def say_hello_only_if_greeting_not_said():
    global greeting_said
    if not greeting_said:
        await asyncio.sleep(1)
        print("hello")
        greeting_said = True

async def main():
    hi_task = asyncio.create_task(say_hi_only_if_greeting_not_said())
    hello_task = asyncio.create_task(say_hello_only_if_greeting_not_said())

    await hi_task
    await hello_task

asyncio.run(main())

The above code would say hi and hello

Let’s try to follow the execution flow line by line again

  1. asyncio.run(main()) kicks off the event loop and main gets control
  2. hi_task = asyncio.create_task(say_hi_only_if_greeting_not_said()) creates hi_task and added it to event loop, main still has control
  3. hello_task = asyncio.create_task(say_hello_only_if_greeting_not_said()) creates hello_task and added it to event loop, main still has control
  4. await hi_task, main tells event loop that it won’t need control until hi_task is done, main gives up control
  5. Event loop gives control to hi_task
  6. hi_task gets control and sees greeting_said == False, executes await asyncio.sleep(1), giving up control
  7. Event loop gives control to hello_task
  8. hello_task gets control and also sees greeting_said == False, executes await asyncio.sleep(1), gives up control to event loop
  9. Event loop gives hi_task control and hi_task prints hi, also sets greeting_said to True and marks hi_task to be done
  10. Event loop picks main because hi_task was done (main was waiting for await hi_task), then main executes await hello_task, gives up control, event loop gives control to hello_task
  11. hello_task prints "hello" even if greeting_said was marked as True in hi_task. This is because hello_task already passed the if check
  12. hello_task done and control goes back to main
  13. main finishes

The problem happened because even if the event loop runs in a single thread, whenever we call await you might give up control and other tasks could modify shared states. In this particular example the tasks are giving up control because asyncio.sleep() was called.

Further things to think about

  • Rust’s async/await allows you to pick the execution environment, meaning you can choose different implementations.
  • Rust’s famous tokio schedules jobs across threads and allows work-stealing. This means jobs may be executed in parallel.
Update: 2026-08-02

See Also