Conversation
…031) Covers when to use async vs sync, asyncio basics, task concurrency (create_task, gather, wait, TaskGroup), common patterns (timeouts, semaphores, queues, async context managers), error handling, testing with pytest-asyncio/AsyncMock, a worked scraper example, and eight common anti-patterns to avoid. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs/async-await.md
Outdated
|
|
||
| --- | ||
|
|
||
| ## 1. When to Use async/await |
There was a problem hiding this comment.
Please add a section about basics of concurrency -- give real life examples. Keep in mind many of the readers are not familiar with concurrency and parallelism concepts
There was a problem hiding this comment.
Done — added a new Section 1: Concurrency and Parallelism — The Big Picture before the existing content.
The section is written for readers who have never encountered these concepts before. It covers:
- Concurrency vs parallelism defined in plain English, then illustrated with two real-life analogies:
- Barista — one person juggling many coffee orders by filling idle machine-wait time with other work (concurrency)
- Restaurant kitchen — multiple chefs each making a dish at the same instant (parallelism)
- ASCII timeline diagrams showing how sequential execution wastes waiting time while concurrent execution hides that latency
- Where async/await fits: a single-threaded event loop that keeps the CPU busy during I/O waits — concurrency, not parallelism
- Decision table summarising when to reach for
asyncio,threading, ormultiprocessing
All subsequent sections have been renumbered (1→2 through 8→9) and the Table of Contents updated accordingly.
…T-1031) Inserts a new Section 1 — "Concurrency and Parallelism — The Big Picture" — that introduces the concepts accessibly for readers new to concurrent programming. Includes: - Barista analogy for concurrency (taking turns on multiple tasks) - Restaurant kitchen analogy for parallelism (simultaneous execution) - ASCII timeline diagram contrasting sequential vs concurrent execution - Event-loop diagram showing how async/await occupies idle wait time - Decision table: concurrency vs parallelism, I/O-bound vs CPU-bound Renumbers all subsequent sections (1→2 through 8→9) and updates the Table of Contents and internal cross-references accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review round 1 — changes madeAdded: Section 1 — Concurrency and Parallelism — The Big PictureAddressed the request to introduce concurrency fundamentals for readers who are new to the topic. A new Section 1 was inserted before all existing content with the following structure: Concurrency: juggling tasks by taking turns Parallelism: doing tasks simultaneously Decision table Where async/await fits All subsequent sections were renumbered (1→2 through 8→9) and the Table of Contents and internal cross-references updated. |
There was a problem hiding this comment.
Pull request overview
Adds a new documentation guide covering Python asyncio / async-await best practices, and links it from the repository’s main README so it’s discoverable alongside the other guides.
Changes:
- Added
docs/async-await.mdwith a multi-section async/await best practices guide (patterns, error handling, testing, worked example). - Updated
README.mdcontents table to link to the new guide.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| docs/async-await.md | New async/await best practices guide with code examples and a worked scraper + tests. |
| README.md | Adds the new guide to the repository contents table. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
docs/async-await.md
Outdated
| # Simpler for blocking I/O in threads (Python 3.9+) | ||
| async def read_file(path: str) -> str: | ||
| return await asyncio.to_thread(open(path).read) |
There was a problem hiding this comment.
asyncio.to_thread(open(path).read) opens the file on the event-loop thread (before scheduling the thread work) and never closes it, so the example can still block briefly and can leak file descriptors. Wrap both open+read inside the threaded callable (e.g., a small function using a with open(...) block) or use pathlib.Path(path).read_text() inside to_thread.
There was a problem hiding this comment.
Fixed — extracted a _read_file helper that uses with open(path) as f: return f.read(), then passes that helper to asyncio.to_thread. Now both open() and read() happen entirely inside the worker thread and the file handle is properly closed via the context manager.
docs/async-await.md
Outdated
| | CPU-heavy computation (image processing, ML inference) | `concurrent.futures.ProcessPoolExecutor` / `multiprocessing` | | ||
| | Parallelising a handful of blocking calls | `concurrent.futures.ThreadPoolExecutor` | | ||
|
|
||
| Use `asyncio.to_thread` (Python 3.9+) or `loop.run_in_executor()` to run blocking or CPU-bound code from an async context without blocking the event loop. |
There was a problem hiding this comment.
The text says to use asyncio.to_thread for “blocking or CPU-bound code”. to_thread runs in a thread, so it generally won’t help CPU-bound workloads due to the GIL; CPU-bound work should be sent to a ProcessPoolExecutor (or other multi-process strategy). Consider narrowing the guidance to: to_thread for blocking I/O, and run_in_executor with a process pool for CPU-bound functions.
| Use `asyncio.to_thread` (Python 3.9+) or `loop.run_in_executor()` to run blocking or CPU-bound code from an async context without blocking the event loop. | |
| Use `asyncio.to_thread` (Python 3.9+) to run blocking I/O in a thread from an async context without blocking the event loop. For CPU-bound functions, use `loop.run_in_executor()` with a `ProcessPoolExecutor` (or another multiprocessing strategy) so the work runs in separate processes and can bypass the GIL. |
There was a problem hiding this comment.
Fixed — the prose now clearly distinguishes the two: asyncio.to_thread is described as the tool for blocking I/O (runs in a thread, subject to the GIL), while loop.run_in_executor() with a ProcessPoolExecutor is presented as the right approach for CPU-bound work that needs to bypass the GIL.
docs/async-await.md
Outdated
| async def producer(queue: asyncio.Queue[str], items: list[str]) -> None: | ||
| for item in items: | ||
| await queue.put(item) | ||
| await queue.put(None) # sentinel to signal completion | ||
|
|
||
| async def consumer(queue: asyncio.Queue[str]) -> list[str]: | ||
| results = [] | ||
| while True: | ||
| item = await queue.get() | ||
| if item is None: | ||
| break | ||
| results.append(await process(item)) | ||
| queue.task_done() | ||
| return results | ||
|
|
||
| async def pipeline(items: list[str]) -> list[str]: | ||
| queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10) |
There was a problem hiding this comment.
The producer/consumer example types the queue as asyncio.Queue[str] but uses None as a sentinel (queue.put(None) and if item is None). This is inconsistent with the annotation and will be flagged by type checkers; also consider calling queue.task_done() for the sentinel if you want the example to be compatible with queue.join() patterns. Use a Queue[str | None] (or a dedicated sentinel object) and handle task_done() consistently.
| async def producer(queue: asyncio.Queue[str], items: list[str]) -> None: | |
| for item in items: | |
| await queue.put(item) | |
| await queue.put(None) # sentinel to signal completion | |
| async def consumer(queue: asyncio.Queue[str]) -> list[str]: | |
| results = [] | |
| while True: | |
| item = await queue.get() | |
| if item is None: | |
| break | |
| results.append(await process(item)) | |
| queue.task_done() | |
| return results | |
| async def pipeline(items: list[str]) -> list[str]: | |
| queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10) | |
| async def producer(queue: asyncio.Queue[str | None], items: list[str]) -> None: | |
| for item in items: | |
| await queue.put(item) | |
| await queue.put(None) # sentinel to signal completion | |
| async def consumer(queue: asyncio.Queue[str | None]) -> list[str]: | |
| results = [] | |
| while True: | |
| item = await queue.get() | |
| if item is None: | |
| queue.task_done() | |
| break | |
| results.append(await process(item)) | |
| queue.task_done() | |
| return results | |
| async def pipeline(items: list[str]) -> list[str]: | |
| queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=10) |
There was a problem hiding this comment.
Fixed — updated all three occurrences of the queue type to asyncio.Queue[str | None] and added queue.task_done() in the sentinel branch of the consumer so the example is compatible with queue.join() patterns.
docs/async-await.md
Outdated
| else: | ||
| logger.critical("Unhandled asyncio error: %s", context["message"]) | ||
|
|
||
| asyncio.get_event_loop().set_exception_handler(handle_task_exception) |
There was a problem hiding this comment.
The exception-handler example calls asyncio.get_event_loop().set_exception_handler(...) at module scope. On newer Python versions get_event_loop() is legacy and may raise if no loop is set; it can also configure the wrong loop if multiple loops are involved. Show this being set on the running loop (e.g., inside an async entry point using asyncio.get_running_loop()), or on the loop you explicitly create before running.
| asyncio.get_event_loop().set_exception_handler(handle_task_exception) | |
| async def main() -> None: | |
| loop = asyncio.get_running_loop() | |
| loop.set_exception_handler(handle_task_exception) | |
| # Application code goes here, for example: | |
| # await some_async_operation() | |
| if __name__ == "__main__": | |
| asyncio.run(main()) |
There was a problem hiding this comment.
Fixed — the example now registers the handler inside an async def main() using asyncio.get_running_loop(), with an asyncio.run(main()) entry point below it. This avoids the legacy get_event_loop() footgun and ensures the handler is set on the correct running loop.
docs/async-await.md
Outdated
| async with asyncio.timeout(timeout): | ||
| response = await client.get(url) | ||
| response.raise_for_status() | ||
| return ScrapeResult(url=url, body=response.text) |
There was a problem hiding this comment.
The worked example uses asyncio.timeout(...), which is only available in Python 3.11+. Since the guide otherwise includes compatibility notes (e.g., wait_for), either note the 3.11+ requirement here or use a wait_for-based timeout to keep the example runnable on older supported versions.
| async with asyncio.timeout(timeout): | |
| response = await client.get(url) | |
| response.raise_for_status() | |
| return ScrapeResult(url=url, body=response.text) | |
| response = await asyncio.wait_for(client.get(url), timeout=timeout) | |
| response.raise_for_status() | |
| return ScrapeResult(url=url, body=response.text) |
There was a problem hiding this comment.
Fixed — replaced async with asyncio.timeout(timeout): with await asyncio.wait_for(client.get(url), timeout=timeout), which is available from Python 3.8+. The exception type was updated to asyncio.TimeoutError accordingly. The Patterns section still shows both APIs for readers who can target 3.11+.
docs/async-await.md
Outdated
| async def good_blocking_io(path: str) -> str: | ||
| return await asyncio.to_thread(open(path).read) |
There was a problem hiding this comment.
Same issue as earlier: asyncio.to_thread(open(path).read) opens the file on the event-loop thread and doesn’t close it. For an example about avoiding blocking the loop, it’s important that the open() happens inside the worker thread and the file handle is closed (e.g., with open(...) as f: f.read()).
| async def good_blocking_io(path: str) -> str: | |
| return await asyncio.to_thread(open(path).read) | |
| def _read_file(path: str) -> str: | |
| with open(path, "r") as f: | |
| return f.read() | |
| async def good_blocking_io(path: str) -> str: | |
| return await asyncio.to_thread(_read_file, path) |
There was a problem hiding this comment.
Fixed — same approach as the Section 2 fix: extracted a _read_file helper with with open(path) as f: return f.read() and passed it to asyncio.to_thread. The comment on the example also now explicitly explains why both open and read must happen inside the worker thread.
docs/async-await.md
Outdated
| return sum(i * i for i in range(n)) | ||
|
|
||
| async def main() -> None: | ||
| loop = asyncio.get_event_loop() |
There was a problem hiding this comment.
The CPU-bound example uses asyncio.get_event_loop() from inside an async def main(). In modern asyncio this is a legacy API and can emit deprecation warnings or fail depending on context; it also contradicts the later “anti-pattern” section. Use asyncio.get_running_loop() (inside async code) or rely on higher-level helpers instead.
| loop = asyncio.get_event_loop() | |
| loop = asyncio.get_running_loop() |
There was a problem hiding this comment.
Fixed — changed to asyncio.get_running_loop(). This is the correct API to use from inside a running coroutine and avoids the deprecation/failure behaviour of get_event_loop() in Python 3.10+.
Seven correctness and style fixes: - get_event_loop() → get_running_loop() in the ProcessPoolExecutor example (avoids legacy API deprecation warnings inside async code) - Narrow to_thread/run_in_executor guidance: to_thread is for blocking I/O only; CPU-bound work needs ProcessPoolExecutor to bypass the GIL - Fix asyncio.to_thread(open(path).read) in two places: wrap open+read in a helper so both happen inside the worker thread and the file is closed - Queue type annotation Queue[str] → Queue[str | None] to match the None sentinel; add queue.task_done() for the sentinel for queue.join() safety - Exception handler: replace module-scope get_event_loop().set_exception_handler() with asyncio.get_running_loop() inside an async entry point - Worked example: replace asyncio.timeout() (3.11+) with asyncio.wait_for() for compatibility with Python 3.8+ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review round 2 — Copilot feedback addressed (7 fixes)All changes are in a single commit (6a3858d).
|
Summary
docs/async-await.md: a comprehensive async/await best practices guide covering all major asyncio patternsREADME.mdto include the new guide in the contents tableGuide contents
The guide is structured into 8 sections:
asyncio.to_thread/run_in_executorfor blocking callsasyncio.run(),await,async with/async forcreate_task,gather(incl.return_exceptions),wait,TaskGroup(3.11+), cancellation and re-raisingCancelledErrorasyncio.timeout/wait_for),Semaphorefor concurrency limiting,Queuefor producer/consumer,asynccontextmanagergatherwithreturn_exceptions,ExceptionGroup/except*(3.11+),finallycleanup,asyncio.shield, custom exception handlerspytest-asynciosetup,asyncio_mode = "auto", async fixtures,AsyncMockfor coroutine mockingget_event_loop(), forgettingawait, discarding task references, pointlessasync def,asyncio.run()in a running loopTest plan
README.mdtable entry links correctly todocs/async-await.mdCloses PLT-1031
🤖 Generated with Claude Code