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.
- Wait at the door until he comes back with my coffee
- 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
- 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
|
|
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().
|
|
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.
|
|
A few important things.
- The code snippet above is single threaded
kid_taskanddad_taskwere all added to the event loop at the beginning- Whenever an
awaitwas 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.
asyncio.run(main()), we created the event loop scheduler and starts running themainfunctionkid_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 bymaindad_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 bymaincoffee = await kid_task, control sees theawaitkeyword, tells event loop “I’m pausing here untilkid_taskis done, take control”- Event loop sees that
kid_taskshould be the next, so it handskid_taskthe control - In
send_kid_to_get_coffeewe print “kid goes to get coffee”, then we hitawait asyncio.sleep(5) - Same as above, hitting
await asyncio.sleep(5)insidesend_kid_to_get_coffeetells the event loop: “I am not doing anything untilasyncio.sleep(5)is done, take away my control. - Event loop control and sees
dad_taskshould be next,dad_plays_gamesgets the control. dad_plays_gamesprints “dad starting to play games”dad_plays_gameshitsawait asyncio.sleep(3), tells event loop “I’m pausing here untilasyncio.sleep(3)is done”, takeaway my control.- Event loop takes back control and there isn’t anything going on
- After 3 seconds the first
asyncio.sleep(3)indad_plays_gamesis done, event loop givesdad_taskthe control, sodad_taskprints “dad played for 3 seconds”. dad_taskhitsawait asyncio.sleep(7), tells event loop “I’m pausing here untilasyncio.sleep(7)is done”, take away my control.- Event loop tries to find tasks,
await asyncio.sleep(5)inkid_taskfinished, so event loop giveskid_taskthe control. kid_taskprints “kid got coffee after 5 seconds” and returns'coffee'kid_taskwas done soawait kid_taskwas done so control was handed back tomain, prints “dad drinks coffee”mainsaysawait dad_task, so control was handed back to event loop- After 7 seconds
await asyncio.sleep(7)is done sodad_taskgets the control, prints “dad played for 7 seconds” dad_taskis done, event loop givesmainback 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.
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.
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
|
|
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
|
|
The above code would say hi and hello
Let’s try to follow the execution flow line by line again
asyncio.run(main())kicks off the event loop andmaingets controlhi_task = asyncio.create_task(say_hi_only_if_greeting_not_said())createshi_taskand added it to event loop,mainstill has controlhello_task = asyncio.create_task(say_hello_only_if_greeting_not_said())createshello_taskand added it to event loop,mainstill has controlawait hi_task,maintells event loop that it won’t need control untilhi_taskis done,maingives up control- Event loop gives control to
hi_task hi_taskgets control and seesgreeting_said == False, executesawait asyncio.sleep(1), giving up control- Event loop gives control to
hello_task hello_taskgets control and also seesgreeting_said == False, executesawait asyncio.sleep(1), gives up control to event loop- Event loop gives
hi_taskcontrol andhi_taskprintshi, also setsgreeting_saidtoTrueand markshi_taskto be done - Event loop picks
mainbecausehi_taskwas done (mainwas waiting forawait hi_task), thenmainexecutesawait hello_task, gives up control, event loop gives control tohello_task hello_taskprints"hello"even ifgreeting_saidwas marked asTrueinhi_task. This is becausehello_taskalready passed theifcheckhello_taskdone and control goes back tomainmainfinishes
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.
See Also
- Map Flatmap and Options
- MSB and LSB, MSb and LSb
- Backward and Forward Compatibility
- Rust Learning: Modules
- Rust Polars Parquet