Skip to content

docs(async-await): add Python async/await best practices guide#8

Merged
eywalker merged 3 commits intodevfrom
eywalker/plt-1031-add-python-asyncawait-best-practices-guide
Mar 19, 2026
Merged

docs(async-await): add Python async/await best practices guide#8
eywalker merged 3 commits intodevfrom
eywalker/plt-1031-add-python-asyncawait-best-practices-guide

Conversation

@agent-kurouto
Copy link
Contributor

@agent-kurouto agent-kurouto bot commented Mar 19, 2026

Summary

  • Adds docs/async-await.md: a comprehensive async/await best practices guide covering all major asyncio patterns
  • Updates README.md to include the new guide in the contents table

Guide contents

The guide is structured into 8 sections:

  1. When to Use async/await — I/O-bound vs CPU-bound workload guidance, asyncio.to_thread / run_in_executor for blocking calls
  2. asyncio Basics — coroutines, asyncio.run(), await, async with / async for
  3. Tasks and Concurrencycreate_task, gather (incl. return_exceptions), wait, TaskGroup (3.11+), cancellation and re-raising CancelledError
  4. Common Patterns — timeouts (asyncio.timeout / wait_for), Semaphore for concurrency limiting, Queue for producer/consumer, asynccontextmanager
  5. Error Handling — exception propagation, gather with return_exceptions, ExceptionGroup / except* (3.11+), finally cleanup, asyncio.shield, custom exception handlers
  6. Testing Async Codepytest-asyncio setup, asyncio_mode = "auto", async fixtures, AsyncMock for coroutine mocking
  7. Worked Example — a complete async HTTP scraper with semaphore-based concurrency, per-request timeouts, structured error results, and matching tests
  8. Common Anti-Patterns — blocking the event loop, deprecated get_event_loop(), forgetting await, discarding task references, pointless async def, asyncio.run() in a running loop

Test plan

  • Verify the Markdown renders correctly (headings, tables, code blocks)
  • Check all internal anchor links resolve
  • Confirm README.md table entry links correctly to docs/async-await.md
  • Review code examples for correctness and consistency with repo style

Closes PLT-1031

🤖 Generated with Claude Code

…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>

---

## 1. When to Use async/await

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, or multiprocessing

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>
@agent-kurouto
Copy link
Contributor Author

agent-kurouto bot commented Mar 19, 2026

Review round 1 — changes made

Added: Section 1 — Concurrency and Parallelism — The Big Picture

Addressed 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
Explained using a barista analogy — a single barista manages multiple coffee orders concurrently by filling machine-wait time with other tasks, rather than standing idle or serving one customer fully before the next. An ASCII timeline compares sequential vs concurrent execution to make the latency benefit concrete.

Parallelism: doing tasks simultaneously
Explained using a restaurant kitchen analogy — multiple chefs each preparing a different dish at the same instant. Contrasted clearly with concurrency to prevent the common conflation of the two terms.

Decision table
A quick-reference table covering: whether tasks run simultaneously, what the benefit is, what the workload type is, and which Python tools to reach for (asyncio, threading, multiprocessing, ProcessPoolExecutor).

Where async/await fits
Closes the section by connecting the concepts back to the guide's subject: the single-threaded event loop is a concurrency mechanism that hides I/O latency, not a parallelism mechanism. Includes an event-loop diagram showing multiple coroutines progressing during each other's idle wait time.

All subsequent sections were renumbered (1→2 through 8→9) and the Table of Contents and internal cross-references updated.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md with a multi-section async/await best practices guide (patterns, error handling, testing, worked example).
  • Updated README.md contents 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.

Comment on lines +145 to +147
# 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)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

| 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.
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +404 to +420
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)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

else:
logger.critical("Unhandled asyncio error: %s", context["message"])

asyncio.get_event_loop().set_exception_handler(handle_task_exception)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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())

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +673 to +676
async with asyncio.timeout(timeout):
response = await client.get(url)
response.raise_for_status()
return ScrapeResult(url=url, body=response.text)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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+.

Comment on lines +787 to +788
async def good_blocking_io(path: str) -> str:
return await asyncio.to_thread(open(path).read)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()).

Suggested change
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)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

return sum(i * i for i in range(n))

async def main() -> None:
loop = asyncio.get_event_loop()
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@agent-kurouto
Copy link
Contributor Author

agent-kurouto bot commented Mar 19, 2026

Review round 2 — Copilot feedback addressed (7 fixes)

All changes are in a single commit (6a3858d).

# Issue Fix
1 asyncio.get_event_loop() inside async def main() (CPU-bound example) Changed to asyncio.get_running_loop() — the correct, non-deprecated API inside a running coroutine
2 asyncio.to_thread(open(path).read) opens file on event-loop thread and leaks the handle (Section 2) Extracted _read_file helper using with open(path) as f: return f.read(); passes the callable to to_thread so open+read both happen in the worker thread
3 Guidance text said to_thread works for "blocking or CPU-bound code" Corrected: to_thread is for blocking I/O only; CPU-bound work needs ProcessPoolExecutor to bypass the GIL
4 asyncio.Queue[str] type annotation inconsistent with None sentinel Changed to `asyncio.Queue[str
5 asyncio.get_event_loop().set_exception_handler() at module scope Moved inside async def main() using asyncio.get_running_loop(), with an asyncio.run(main()) entry point
6 Worked example used asyncio.timeout() (Python 3.11+ only) Replaced with asyncio.wait_for() (Python 3.8+) to match the rest of the guide's compatibility baseline; exception type updated to asyncio.TimeoutError
7 asyncio.to_thread(open(path).read) file descriptor leak in anti-patterns section Same fix as #2: _read_file helper with proper with block passed to to_thread

Copy link

@eywalker eywalker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content looks great!

@eywalker eywalker merged commit 5d59b67 into dev Mar 19, 2026
@eywalker eywalker deleted the eywalker/plt-1031-add-python-asyncawait-best-practices-guide branch March 19, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants