Python’s async model is misunderstood, especially by engineers coming from JS or C#. In Python, awaiting a coroutine doesn’t yield to the event loop. Only tasks create concurrency. This post explains why that distinction matters and how it affects locking, design, and correctness.
Every engineer has had that moment during a review where a comment sticks in their head longer than it should.
In my case, it was a simple suggestion:
“You should add more locks here: this code is async, so anything might interleave.”
The code in question touched a shared cache, and on the surface the comment made sense. Multiple asyncio tasks were hitting the same structure, and the function modifying it was async. Shouldn't that mean I need more locks?
That review pushed me down a rabbit hole. Not about the cache (it was tiny) but about the mental model many engineers (including experienced ones) bring to Python's async system. A model shaped by JavaScript or C#: all languages where await means "yield to the runtime now."
But Python isn't those languages. And misunderstanding this fundamental difference leads to unnecessary locking, accidental complexity, and subtle bugs.
This post is the explanation I wish more engineers had.
If you're coming from JavaScript, the rule is simple:
Every await always yields to the event loop.
Every async function always returns a task (a Promise).
The moment you write await, the runtime can schedule something else.
In C#, the story is nearly identical:
async functions return Task<T> or Task.
await always represents a suspension point.
The runtime decides when to resume you.
In Java's virtual-thread world (Project Loom), the principle is very similar: when you submit work to run asynchronously, typically via an ExecutorService backed by virtual threads, you're creating tasks. And when you call Future.get(), the virtual thread suspends until the result is ready. The suspension is inexpensive, but it still constitutes a full scheduling boundary.
So developers internalize one big rule:
“Any async boundary is a suspension point.“
And then they bring that rule to Python.
Python splits things into:
Defined with async def, but not scheduled. A coroutine object is just a state machine with potential suspension points.
When you run:
Python immediately steps into the coroutine and executes it inside the current task, synchronously, until it either finishes or hits a suspension point (await something_not_ready).
No event-loop scheduling happens here.
Created with asyncio.create_task(coro). Tasks are the unit of concurrency in Python. The event loop interleaves tasks, not coroutines.
This distinction is not cosmetic: it’s the reason many developers misunderstand Python's async semantics.
This sentence is the entire post:
Awaiting a coroutine does not give control back to the event loop. Awaiting a task does.
A coroutine is more like a nested function call that can pause, but it doesn't pause by default. It only yields if and when it reaches an awaitable that isn't ready.
In contrast:
JavaScript
Java
C#
Do not expose this difference. In those languages, an "async function" is always a task. You never await a "bare coroutine." Every await is a potential context switch.
Python breaks that assumption.
Let's make the behavior painfully explicit.
Output:
Notice what didn't happen:
No other task ran between "child start" and "child end".
await child() did not give the event loop a chance to schedule anything else until child() itself awaited asyncio.sleep.
await child() simply inlined the coroutine's body.
This is not how JavaScript behaves. This is not how C# behaves. This is not how Java behaves.
Change one line:
Now the output interleaves depending on the scheduler:
Because now we have a task, and awaiting a task does yield to the event loop.
Tasks are where concurrency comes from, not coroutines.
This single difference is where most incorrect locking recommendations arise.
Now let's extract the general rule:
An async def function is not automatically concurrent.
await is not a scheduling point unless the inner awaitable suspends.
Concurrency exists only across tasks and only at actual suspension points.
This is why the code review suggestion I received, "add more locks, it’s async!", was based on the wrong mental model.
My mutation block contained no awaits. The only awaits happened before acquiring the lock. Therefore:
The critical section was atomic relative to the event loop.
No other task could interleave inside the mutation.
More locks would not increase safety.
The cache wasn't the story. My reviewer's misconception was.
Python's async model evolved from generators (yield, yield from), rather than green threads or promises. Coroutines are an evolution of these primitives.
This legacy leads to:
A more explicit boundary between structured control flow and scheduled concurrency.
The ability to write async code that behaves synchronously until a real suspension occurs.
Fine-grained control over when interleaving can happen.
It also leads to confusion among developers coming from JavaScript, Java, or C#, languages where async automatically means "this is a task."
Python leaves "is this a task?" up to you.
Here is the model I now advocate whenever reviewing asyncio code:
Coroutines are callables with potential suspension points: they do not run concurrently.
Only tasks introduce concurrency: if you never call asyncio.create_task, you may not have any concurrency at all.
Concurrency occurs only at suspension points: no await inside a block → no interleave → no need for locks there.
Locks should protect data across tasks, not coroutines: lock where suspension is possible, not where the keyword async appears.
Audit where tasks are created: every asyncio.create_task() is a concurrency boundary.
Scan critical sections for suspension points: if there's no await inside the lock, the block is atomic relative to the event loop.
Prefer "compute outside, mutate inside": compute values before acquiring the lock, then mutate quickly inside it.
Teach the difference explicitly: a surprising number of experienced engineers haven't internalized coroutine vs task separation.
Once you internalize that:
JavaScript: async function → always a task
C#: async → always a task
Java (Loom's VirtualThread)): async → always a task
Python: async def → only a coroutine; task creation is explicit
Then the whole model makes sense.
Python's await isn't a context switch. It's a structured control flow that might suspend.
That difference is why I didn't add more locks to my cache code. And it's why I now review Python async code by asking a much better question:
"Where can this code actually interleave?"
That single question catches more bugs and eliminates more unnecessary complexity than any blanket rule about locking in async systems.