asyncio & Coroutines
Topic 4 of 7, with 3 concept checks. async/await, the event loop, and structuring concurrent I/O
Coordinate cooperative tasks on one event loop
Cooperative scheduling
Track where a coroutine yields control, which task can run next, and how cancellation and structured cleanup keep asynchronous work responsive and predictable.
Core lesson 01
Calling an async def function immediately returns a coroutine object without running any code inside it — it only executes once awaited or scheduled on the event loop.
This mirrors exactly how calling a generator function returns a generator without running its body. async def f(): ... defines a coroutine function; f() creates a coroutine object, inert until something drives it — either await f() inside another coroutine, or handing it to the event loop directly with asyncio.run(f()) or wrapping it in a Task.
import asyncio
async def greet():
print("hello from coroutine")
return 42
c = greet()
print(type(c)) # <class 'coroutine'> -- nothing printed yet, not run
result = asyncio.run(c) # NOW it actually runs
print(result) # 42What to remember
What's the difference between a coroutine function and calling it?
Common footguns
- Calling an async function and forgetting to await/run it — Python emits a 'coroutine was never awaited' warning, and none of the code inside ever executes.
Core lesson 02
await pauses the current coroutine until the awaited one finishes, effectively running things sequentially. create_task() schedules a coroutine to start immediately in the background, letting you await its result later — enabling true concurrency.
await coro() runs coro to completion before moving to the next line — awaiting three things one after another runs them sequentially even though each individually yields during I/O waits. asyncio.create_task(coro()) wraps the coroutine in a Task and schedules it on the event loop right away; the current coroutine can keep doing other things and await the task later, so multiple tasks' waiting periods overlap.
import asyncio, time
async def work(name, delay):
await asyncio.sleep(delay)
return name
async def sequential():
start = time.time()
a = await work("A", 1)
b = await work("B", 1)
print("sequential:", time.time() - start) # ~2s
async def concurrent():
start = time.time()
task_a = asyncio.create_task(work("A", 1))
task_b = asyncio.create_task(work("B", 1))
a, b = await task_a, await task_b
print("concurrent:", time.time() - start) # ~1s
asyncio.run(sequential())
asyncio.run(concurrent())What to remember
What's the difference between await something and asyncio.create_task(something)?
Common footguns
- Awaiting each coroutine immediately in a loop instead of creating tasks first — this accidentally serializes work that could have run concurrently.
Core lesson 03
An unhandled exception inside a Task is stored on the Task; if you never retrieve it, Python logs a warning when the task is garbage collected, rather than raising immediately.
Because a Task runs independently of whatever created it, an exception inside it can't just propagate up the normal call stack. asyncio catches it and stores it on the Task; calling await task (or task.result()) later re-raises it there. If nothing ever retrieves the result, the exception is effectively swallowed until Python's garbage collector notices and logs a warning — making bugs in fire-and-forget tasks easy to miss.
import asyncio
async def boom():
raise ValueError("kaboom")
async def main():
task = asyncio.create_task(boom())
await asyncio.sleep(0.1)
# if we never `await task`, the exception is only reported later
# via a logged warning, not raised here
asyncio.run(main())What to remember
What happens if an exception is raised inside a task created with create_task and never awaited?
Common footguns
- Firing off create_task() calls for background work and never awaiting or checking them — errors can go unnoticed for a long time.
Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Always await or otherwise retrieve the result of every Task you create, to surface exceptions promptly.
- Use asyncio.gather() or TaskGroup (3.11+) to run and await multiple coroutines concurrently instead of one by one.
- Never call blocking synchronous code directly inside a coroutine — offload it with loop.run_in_executor() if unavoidable.
- Use asyncio.timeout() (3.11+) or asyncio.wait_for() to bound how long you'll wait on a coroutine.
Apply the concept in Interview practice
Building H2OmediumLeetCode #1117 · O(1) synchronization overhead
Use semaphores capped so only 2 hydrogens and 1 oxygen proceed per molecule, plus a barrier ensuring threads release in H-H-O groups without interleaving between molecules.
Open problemPrint Zero Even OddmediumLeetCode #1116 · O(n) synchronized steps
Use three semaphores to hand off strictly between the zero, even, and odd printing threads/coroutines in the required sequence.
Open problemConcept checks
What's the difference between a coroutine function and calling it?
Hint
Same pattern as generators — defining it doesn't run it.
Calling an async def function returns a coroutine object, not a result.
Answer
Calling an async def function immediately returns a coroutine object without running any code inside it — it only executes once awaited or scheduled on the event loop.
This mirrors exactly how calling a generator function returns a generator without running its body. async def f(): ... defines a coroutine function; f() creates a coroutine object, inert until something drives it — either await f() inside another coroutine, or handing it to the event loop directly with asyncio.run(f()) or wrapping it in a Task.
import asyncio
async def greet():
print("hello from coroutine")
return 42
c = greet()
print(type(c)) # <class 'coroutine'> -- nothing printed yet, not run
result = asyncio.run(c) # NOW it actually runs
print(result) # 42Watch out
- Calling an async function and forgetting to await/run it — Python emits a 'coroutine was never awaited' warning, and none of the code inside ever executes.
What's the difference between await something and asyncio.create_task(something)?
Hint
One waits immediately; the other starts something running in the background.
create_task lets you run multiple coroutines concurrently instead of one after another.
Answer
await pauses the current coroutine until the awaited one finishes, effectively running things sequentially. create_task() schedules a coroutine to start immediately in the background, letting you await its result later — enabling true concurrency.
await coro() runs coro to completion before moving to the next line — awaiting three things one after another runs them sequentially even though each individually yields during I/O waits. asyncio.create_task(coro()) wraps the coroutine in a Task and schedules it on the event loop right away; the current coroutine can keep doing other things and await the task later, so multiple tasks' waiting periods overlap.
import asyncio, time
async def work(name, delay):
await asyncio.sleep(delay)
return name
async def sequential():
start = time.time()
a = await work("A", 1)
b = await work("B", 1)
print("sequential:", time.time() - start) # ~2s
async def concurrent():
start = time.time()
task_a = asyncio.create_task(work("A", 1))
task_b = asyncio.create_task(work("B", 1))
a, b = await task_a, await task_b
print("concurrent:", time.time() - start) # ~1s
asyncio.run(sequential())
asyncio.run(concurrent())Watch out
- Awaiting each coroutine immediately in a loop instead of creating tasks first — this accidentally serializes work that could have run concurrently.
What happens if an exception is raised inside a task created with create_task and never awaited?
Hint
It doesn't crash your program immediately — but it's not silent forever either.
Python surfaces it later, often as a 'Task exception was never retrieved' warning.
Answer
An unhandled exception inside a Task is stored on the Task; if you never retrieve it, Python logs a warning when the task is garbage collected, rather than raising immediately.
Because a Task runs independently of whatever created it, an exception inside it can't just propagate up the normal call stack. asyncio catches it and stores it on the Task; calling await task (or task.result()) later re-raises it there. If nothing ever retrieves the result, the exception is effectively swallowed until Python's garbage collector notices and logs a warning — making bugs in fire-and-forget tasks easy to miss.
import asyncio
async def boom():
raise ValueError("kaboom")
async def main():
task = asyncio.create_task(boom())
await asyncio.sleep(0.1)
# if we never `await task`, the exception is only reported later
# via a logged warning, not raised here
asyncio.run(main())Watch out
- Firing off create_task() calls for background work and never awaiting or checking them — errors can go unnoticed for a long time.