Coroutines, ownership, and bounded concurrency
How to study this lesson
Read one section, trace the worked example, and try the knowledge check. Bookmark saves a shortcut; notes appear in My notebook. Mark lesson complete is your own assessment and does not mark its lab passed.
By the end, you can
- Trace when a coroutine runs and yields.
- Choose a concurrency limit from resource constraints.
- Separate per-item failure from batch cancellation.
A coroutine is suspended work
An ordinary Python function executes when called. Calling an async function instead constructs a coroutine object. The event loop drives that coroutine until it reaches an awaitable that cannot finish immediately; the loop can then run other ready tasks. This is cooperative scheduling. A long Python loop with no suspension point can delay every network request sharing the loop, even if its function begins with async.
Imagine three independent catalog reads that each spend 80 milliseconds waiting for a remote service and 2 milliseconds decoding a response. Sequential execution takes approximately 246 milliseconds. Concurrent execution can overlap the waiting, but decoding still needs processor time. This estimate is a model for reasoning, not a measured promise: connection setup, queues, service limits, and scheduling overhead can dominate a real workload.
Ownership is the first concurrency decision
Creating a task creates an obligation to observe its result, exception, or cancellation. In Python 3.12, an asyncio TaskGroup provides a useful lexical owner: tasks created inside the group are awaited before the context finishes. An unexpected child failure can cancel siblings, which is appropriate when all results belong to one indivisible operation. It is less appropriate when a failed customer lookup should not discard unrelated lookups.
Represent expected item failures as data when partial success is part of the product contract. A missing catalog record might become a result with status missing. A programmer error should still propagate. Catching every exception and returning an empty dictionary destroys this distinction and can turn widespread outages into apparently successful empty answers.
Backpressure means saying how much work fits
A semaphore limits how many tasks enter a constrained section at once. If your downstream permits four concurrent reads, acquire the semaphore before starting each read and release it after completion. Creating ten thousand semaphore-waiting tasks still creates ten thousand task objects; a bounded worker queue is the next step when the input itself is large or arrives continuously.
Consider six reads and a limit of two. At most two reads should own connections; the remaining four wait in your program. The limit is a policy for resource use, not evidence that two is the fastest setting. Measure throughput and tail latency while increasing the limit. If errors or latency rise sharply, the bottleneck may have moved into the service or connection pool.
Timeouts and cancellation change the contract
A timeout limits how long the caller waits. It does not prove a remote operation never happened. That matters even for an agent that appears to perform one simple action: retrying a timed-out purchase or write can duplicate the action. A read-only lookup is a better first exercise because repeating it usually has smaller consequences.
Cleanup belongs in finally blocks or context managers. Cancellation must normally propagate after cleanup so that the task owner can finish shutting down. Do not suppress cancellation merely to keep an attractive progress indicator moving. Distinguish a deadline for the whole user request from a timeout for each attempt; three sequential ten-second attempts can otherwise exceed the user's intended ten-second budget.
Read traces before trusting speed
The example uses simulated reads and an explicit maximum-active counter. It checks the property we care about without relying on wall-clock timing. Results are returned in input order because gather associates each output with the matching input position; completion order is a separate concept. A real client should attach a request identifier so a response cannot silently be joined to the wrong item.
When adapting the example, replace the simulated await with an async HTTP client call, keep the semaphore around the scarce operation, and validate the returned data before making it available to later steps. Use monotonic timing for durations and redact credentials from traces. The useful debugging question is which task owned which resource at the point progress stopped.
Work through the code
Four integer IDs enter simulated I/O behind a semaphore of two. The counters prove bounded ownership, while gather preserves input order. The short sleep only yields control; this is a scheduling demonstration, not an HTTP benchmark. Change the gate to one and compare the peak.
import asyncio
async def main():
gate = asyncio.Semaphore(2)
active = 0
peak = 0
async def read(record_id):
nonlocal active, peak
async with gate:
active += 1
peak = max(peak, active)
try:
await asyncio.sleep(0.001)
return {"id": record_id, "value": record_id * 10}
finally:
active -= 1
results = await asyncio.gather(*(read(i) for i in range(4)))
print("values:", [row["value"] for row in results])
print("peak active:", peak)
print("remaining active:", active)
if __name__ == "__main__":
asyncio.run(main())
values: [0, 10, 20, 30] peak active: 2 remaining active: 0
Run Python snippets locally with the prerequisites named above. The module coding lab runs directly in your browser.
Pause and reason
A request has eight independent read-only items, a maximum of two concurrent reads, and each read waits 100 ms. Ignoring overhead, estimate minimum batch latency. What changes if one prerequisite must finish before any of the other seven items can start?
Check your understanding
You call fetch_record() where fetch_record is async, but no network work happens. What is the most direct explanation?
Your notes
Explain the mechanism in your own words. Add a failure you want to test.