-
-
Notifications
You must be signed in to change notification settings - Fork 34.5k
gh-79012: Add asyncio chat server HOWTO #144604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+269
−0
Closed
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
61b700c
gh-79012: Add asyncio chat server HOWTO
kovan 3f5b5ae
gh-79012: Address review feedback on asyncio chat server HOWTO
kovan 3b2356d
gh-79012: Restructure HOWTO to explain concepts before code
kovan 5582825
gh-79012: Restructure HOWTO based on review feedback
kovan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
gh-79012: Address review feedback on asyncio chat server HOWTO
- Explain concepts (start_server, StreamReader/StreamWriter) before code - Use asyncio.TaskGroup for concurrent broadcasting - Use contextlib.suppress instead of bare except/pass - Remove Python test client, keep only nc/telnet - Properly explain asyncio.timeout before showing usage - Move implementation notes to code comments - Remove Exercises and Common pitfalls sections - Reorder seealso links in asyncio.rst Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Loading branch information
commit 3f5b5aeab52ffeb36818ba62c9705e1297fb3d0a
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -14,7 +14,7 @@ managers --- and a general understanding of async/await. | |||||
| .. seealso:: | ||||||
|
|
||||||
| :ref:`a-conceptual-overview-of-asyncio` | ||||||
| An explanation of how asyncio works under the hood. | ||||||
| An introduction to the fundamentals of asyncio. | ||||||
|
|
||||||
| :mod:`asyncio` reference documentation | ||||||
| The complete API reference. | ||||||
|
|
@@ -26,10 +26,16 @@ Starting with an echo server | |||||
| ============================ | ||||||
|
|
||||||
| Before building the chat server, let's start with something simpler: an echo | ||||||
| server that sends back whatever a client sends. This introduces the | ||||||
| :ref:`streams <asyncio-streams>` API that the chat server builds on. | ||||||
| server that sends back whatever a client sends. | ||||||
|
|
||||||
| :: | ||||||
| The core of any asyncio network server is :func:`asyncio.start_server`. You | ||||||
| give it a callback function, a host, and a port. When a client connects, | ||||||
| asyncio calls your callback with two arguments: a | ||||||
| :class:`~asyncio.StreamReader` for receiving data and a | ||||||
| :class:`~asyncio.StreamWriter` for sending data back. Each connection runs | ||||||
| as its own coroutine, so multiple clients are handled concurrently. | ||||||
|
|
||||||
| Here is a complete echo server:: | ||||||
|
|
||||||
| import asyncio | ||||||
|
|
||||||
|
|
@@ -59,52 +65,15 @@ server that sends back whatever a client sends. This introduces the | |||||
|
|
||||||
| asyncio.run(main()) | ||||||
|
|
||||||
| :func:`asyncio.start_server` listens for incoming connections. Each time a | ||||||
| client connects, it calls ``handle_client`` with a | ||||||
| :class:`~asyncio.StreamReader` and a :class:`~asyncio.StreamWriter`. Multiple | ||||||
| clients are handled concurrently --- each connection runs as its own coroutine. | ||||||
|
|
||||||
| Two patterns are essential when working with streams: | ||||||
|
|
||||||
| - **Write then drain:** :meth:`~asyncio.StreamWriter.write` buffers data. | ||||||
| ``await`` :meth:`~asyncio.StreamWriter.drain` ensures it is actually sent | ||||||
| (and applies back-pressure if the client is slow to read). | ||||||
|
|
||||||
| - **Close then wait_closed:** :meth:`~asyncio.StreamWriter.close` initiates | ||||||
| shutdown. ``await`` :meth:`~asyncio.StreamWriter.wait_closed` waits until | ||||||
| the connection is fully closed. | ||||||
|
|
||||||
|
|
||||||
| Testing with a client | ||||||
| --------------------- | ||||||
|
|
||||||
| To test the echo server, run it in one terminal and this client in another:: | ||||||
|
|
||||||
| import asyncio | ||||||
|
|
||||||
| async def main(): | ||||||
| reader, writer = await asyncio.open_connection( | ||||||
| '127.0.0.1', 8888) | ||||||
|
|
||||||
| for message in ['Hello!\n', 'How are you?\n', 'Goodbye!\n']: | ||||||
| writer.write(message.encode()) | ||||||
| await writer.drain() | ||||||
|
|
||||||
| data = await reader.readline() | ||||||
| print(f'Received: {data.decode().strip()!r}') | ||||||
|
|
||||||
| writer.close() | ||||||
| await writer.wait_closed() | ||||||
|
|
||||||
| asyncio.run(main()) | ||||||
|
|
||||||
| .. code-block:: none | ||||||
| The :meth:`~asyncio.StreamWriter.write` method buffers data without sending | ||||||
| it immediately. Awaiting :meth:`~asyncio.StreamWriter.drain` flushes the | ||||||
| buffer and applies back-pressure if the client is slow to read. Similarly, | ||||||
| :meth:`~asyncio.StreamWriter.close` initiates shutdown, and awaiting | ||||||
| :meth:`~asyncio.StreamWriter.wait_closed` waits until the connection is | ||||||
| fully closed. | ||||||
|
|
||||||
| Received: 'Hello!' | ||||||
| Received: 'How are you?' | ||||||
| Received: 'Goodbye!' | ||||||
|
|
||||||
| You can also test using ``telnet`` or ``nc``: | ||||||
| To test, run the server in one terminal and connect from another using ``nc`` | ||||||
| (or ``telnet``): | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this in parentheses?
Suggested change
|
||||||
|
|
||||||
| .. code-block:: none | ||||||
|
|
||||||
|
|
@@ -116,24 +85,34 @@ You can also test using ``telnet`` or ``nc``: | |||||
| Building the chat server | ||||||
| ======================== | ||||||
|
|
||||||
| The chat server extends the echo server with two key additions: tracking | ||||||
| connected clients and broadcasting messages to all of them. | ||||||
| The chat server extends the echo server with two additions: tracking connected | ||||||
| clients and broadcasting messages to everyone. | ||||||
|
|
||||||
| We store each client's name and :class:`~asyncio.StreamWriter` in a dictionary. | ||||||
| When a message arrives, we broadcast it to all other connected clients. | ||||||
| :class:`asyncio.TaskGroup` sends to all recipients concurrently, and | ||||||
| :func:`contextlib.suppress` silently handles any :exc:`ConnectionError` from | ||||||
| clients that have already disconnected. | ||||||
|
|
||||||
| :: | ||||||
|
|
||||||
| import asyncio | ||||||
| import contextlib | ||||||
|
|
||||||
| connected_clients: dict[str, asyncio.StreamWriter] = {} | ||||||
|
|
||||||
| async def broadcast(message, *, sender=None): | ||||||
| """Send a message to all connected clients except the sender.""" | ||||||
| for name, writer in list(connected_clients.items()): | ||||||
| if name != sender: | ||||||
| try: | ||||||
| writer.write(message.encode()) | ||||||
| await writer.drain() | ||||||
| except ConnectionError: | ||||||
| pass # Client disconnected; cleaned up elsewhere. | ||||||
| async def send(writer): | ||||||
| with contextlib.suppress(ConnectionError): | ||||||
| writer.write(message.encode()) | ||||||
| await writer.drain() | ||||||
|
|
||||||
| async with asyncio.TaskGroup() as tg: | ||||||
| # Iterate over a copy: clients may leave during the broadcast. | ||||||
| for name, writer in list(connected_clients.items()): | ||||||
| if name != sender: | ||||||
| tg.create_task(send(writer)) | ||||||
|
|
||||||
| async def handle_client(reader, writer): | ||||||
| addr = writer.get_extra_info('peername') | ||||||
|
|
@@ -163,6 +142,7 @@ connected clients and broadcasting messages to all of them. | |||||
| except ConnectionError: | ||||||
| pass | ||||||
| finally: | ||||||
| # Ensure cleanup even if the client disconnects unexpectedly. | ||||||
| del connected_clients[name] | ||||||
| print(f'{name} ({addr}) has left') | ||||||
| await broadcast(f'*** {name} has left the chat ***\n') | ||||||
|
|
@@ -180,22 +160,8 @@ connected clients and broadcasting messages to all of them. | |||||
|
|
||||||
| asyncio.run(main()) | ||||||
|
|
||||||
| Some things to note about this design: | ||||||
|
|
||||||
| - **No locks needed.** ``connected_clients`` is a plain :class:`dict`. | ||||||
| Because asyncio runs in a single thread, no other task can modify it between | ||||||
| ``await`` points. | ||||||
|
|
||||||
| - **Iterating a copy.** ``broadcast()`` iterates over ``list(...)`` because a | ||||||
| client might disconnect (and be removed from the dict) while we are | ||||||
| broadcasting. | ||||||
|
|
||||||
| - **Cleanup in** ``finally``. The ``try``/``finally`` block ensures the | ||||||
| client is removed from ``connected_clients`` and the connection is closed | ||||||
| even if the client disconnects unexpectedly. | ||||||
|
|
||||||
| To test, start the server in one terminal and connect from two or more others | ||||||
| using ``telnet`` or ``nc``: | ||||||
| To test, start the server and connect from two or more terminals using ``nc`` | ||||||
| (or ``telnet``): | ||||||
|
|
||||||
| .. code-block:: none | ||||||
|
|
||||||
|
|
@@ -208,103 +174,34 @@ using ``telnet`` or ``nc``: | |||||
| Each message you type is broadcast to all other connected users. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
|
|
||||||
| .. _asyncio-chat-server-extending: | ||||||
|
|
||||||
| Extending the chat server | ||||||
| ========================= | ||||||
|
|
||||||
| The chat server is a good foundation to build on. Here are some ideas to | ||||||
| try. | ||||||
| .. _asyncio-chat-server-timeout: | ||||||
|
|
||||||
| Adding an idle timeout | ||||||
| ---------------------- | ||||||
| ====================== | ||||||
|
|
||||||
| Disconnect users who have been idle for too long using | ||||||
| :func:`asyncio.timeout`:: | ||||||
| To disconnect clients who have been idle for too long, wrap the read call in | ||||||
| :func:`asyncio.timeout`. This async context manager takes a duration in | ||||||
| seconds. If the enclosed ``await`` does not complete within that time, the | ||||||
| operation is cancelled and :exc:`TimeoutError` is raised. This frees server | ||||||
| resources when clients connect but stop sending data. | ||||||
|
|
||||||
| async def handle_client(reader, writer): | ||||||
| # ... (name registration as before) ... | ||||||
| try: | ||||||
| while True: | ||||||
| try: | ||||||
| async with asyncio.timeout(300): # 5-minute timeout | ||||||
| data = await reader.readline() | ||||||
| except TimeoutError: | ||||||
| writer.write(b'Disconnected: idle timeout.\n') | ||||||
| await writer.drain() | ||||||
| break | ||||||
| if not data: | ||||||
| break | ||||||
| message = data.decode().strip() | ||||||
| if message: | ||||||
| await broadcast(f'{name}: {message}\n', sender=name) | ||||||
| except ConnectionError: | ||||||
| pass | ||||||
| finally: | ||||||
| # ... (cleanup as before) ... | ||||||
|
|
||||||
| Exercises | ||||||
| --------- | ||||||
|
|
||||||
| These exercises build on the chat server: | ||||||
|
|
||||||
| - **Add a** ``/quit`` **command** that lets a user disconnect gracefully by | ||||||
| typing ``/quit``. | ||||||
|
|
||||||
| - **Add private messaging.** If a user types ``/msg Alice hello``, only | ||||||
| Alice should receive the message. | ||||||
|
|
||||||
| - **Log messages to a file** using :func:`asyncio.to_thread` to avoid | ||||||
| blocking the event loop during file writes. | ||||||
|
|
||||||
| - **Limit concurrent connections** using :class:`asyncio.Semaphore` to | ||||||
| restrict the server to a maximum number of users. | ||||||
|
|
||||||
|
|
||||||
| .. _asyncio-chat-server-pitfalls: | ||||||
|
|
||||||
| Common pitfalls | ||||||
| =============== | ||||||
|
|
||||||
| Forgetting to await | ||||||
| ------------------- | ||||||
| Replace the message loop in ``handle_client`` with:: | ||||||
|
|
||||||
| Calling a coroutine function without ``await`` creates a coroutine object but | ||||||
| does not run it:: | ||||||
|
|
||||||
| async def main(): | ||||||
| asyncio.sleep(1) # Wrong: creates a coroutine but never runs it. | ||||||
| await asyncio.sleep(1) # Correct. | ||||||
|
|
||||||
| Python will emit a :exc:`RuntimeWarning` if a coroutine is never awaited. | ||||||
| If you see ``RuntimeWarning: coroutine '...' was never awaited``, check for a | ||||||
| missing ``await``. | ||||||
|
|
||||||
| Blocking the event loop | ||||||
| ----------------------- | ||||||
|
|
||||||
| Calling blocking functions like :func:`time.sleep` or performing synchronous | ||||||
| I/O inside a coroutine freezes the entire event loop:: | ||||||
|
|
||||||
| async def bad(): | ||||||
| time.sleep(5) # Wrong: blocks the event loop for 5 seconds. | ||||||
|
|
||||||
| async def good(): | ||||||
| await asyncio.sleep(5) # Correct: suspends without blocking. | ||||||
| await asyncio.to_thread(time.sleep, 5) # Also correct: runs in a thread. | ||||||
|
|
||||||
| You can use :ref:`debug mode <asyncio-debug-mode>` to detect blocking calls: | ||||||
| pass ``debug=True`` to :func:`asyncio.run`. | ||||||
|
|
||||||
| Fire-and-forget tasks disappearing | ||||||
| ----------------------------------- | ||||||
|
|
||||||
| If you create a task without keeping a reference to it, the task may be | ||||||
| garbage collected before it finishes:: | ||||||
|
|
||||||
| async def main(): | ||||||
| asyncio.create_task(some_coroutine()) # No reference kept! | ||||||
| await asyncio.sleep(10) | ||||||
|
|
||||||
| Use :class:`asyncio.TaskGroup` to manage task lifetimes, or store task | ||||||
| references in a collection. | ||||||
| try: | ||||||
| while True: | ||||||
| try: | ||||||
| async with asyncio.timeout(300): # 5-minute timeout | ||||||
| data = await reader.readline() | ||||||
| except TimeoutError: | ||||||
| writer.write(b'Disconnected: idle timeout.\n') | ||||||
| await writer.drain() | ||||||
| break | ||||||
| if not data: | ||||||
| break | ||||||
| message = data.decode().strip() | ||||||
| if message: | ||||||
| await broadcast(f'{name}: {message}\n', sender=name) | ||||||
| except ConnectionError: | ||||||
| pass | ||||||
| finally: | ||||||
| # ... (cleanup as before) ... | ||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.