From 61b700ceb74e9a9a877e174283ee9c3ceedb17c4 Mon Sep 17 00:00:00 2001 From: kovan Date: Sun, 8 Feb 2026 21:21:07 +0100 Subject: [PATCH 1/3] gh-79012: Add asyncio chat server HOWTO Co-Authored-By: Claude Opus 4.6 --- Doc/howto/asyncio-chat-server.rst | 310 ++++++++++++++++++++++++++++++ Doc/howto/index.rst | 2 + Doc/library/asyncio.rst | 3 + 3 files changed, 315 insertions(+) create mode 100644 Doc/howto/asyncio-chat-server.rst diff --git a/Doc/howto/asyncio-chat-server.rst b/Doc/howto/asyncio-chat-server.rst new file mode 100644 index 00000000000000..7e34aee7a1fc9c --- /dev/null +++ b/Doc/howto/asyncio-chat-server.rst @@ -0,0 +1,310 @@ +.. _asyncio-chat-server-howto: + +*********************************************** +Building a TCP chat server with :mod:`!asyncio` +*********************************************** + +This guide walks you through building a TCP chat server where multiple users +can connect and exchange messages in real time. Along the way, you will learn +how to use :ref:`asyncio streams ` for network programming. + +The guide assumes basic Python knowledge --- functions, classes, and context +managers --- and a general understanding of async/await. + +.. seealso:: + + :ref:`a-conceptual-overview-of-asyncio` + An explanation of how asyncio works under the hood. + + :mod:`asyncio` reference documentation + The complete API reference. + + +.. _asyncio-chat-server-echo: + +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 ` API that the chat server builds on. + +:: + + import asyncio + + async def handle_client(reader, writer): + addr = writer.get_extra_info('peername') + print(f'New connection from {addr}') + + while True: + data = await reader.readline() + if not data: + break + writer.write(data) + await writer.drain() + + print(f'Connection from {addr} closed') + writer.close() + await writer.wait_closed() + + async def main(): + server = await asyncio.start_server( + handle_client, '127.0.0.1', 8888) + addr = server.sockets[0].getsockname() + print(f'Serving on {addr}') + + async with server: + await server.serve_forever() + + 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 + + Received: 'Hello!' + Received: 'How are you?' + Received: 'Goodbye!' + +You can also test using ``telnet`` or ``nc``: + +.. code-block:: none + + $ nc 127.0.0.1 8888 + + +.. _asyncio-chat-server-building: + +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. + +:: + + import asyncio + + 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 handle_client(reader, writer): + addr = writer.get_extra_info('peername') + + writer.write(b'Enter your name: ') + await writer.drain() + data = await reader.readline() + if not data: + writer.close() + await writer.wait_closed() + return + + name = data.decode().strip() + connected_clients[name] = writer + print(f'{name} ({addr}) has joined') + await broadcast(f'*** {name} has joined the chat ***\n', sender=name) + + try: + while True: + data = await reader.readline() + if not data: + break + message = data.decode().strip() + if message: + print(f'{name}: {message}') + await broadcast(f'{name}: {message}\n', sender=name) + except ConnectionError: + pass + finally: + del connected_clients[name] + print(f'{name} ({addr}) has left') + await broadcast(f'*** {name} has left the chat ***\n') + writer.close() + await writer.wait_closed() + + async def main(): + server = await asyncio.start_server( + handle_client, '127.0.0.1', 8888) + addr = server.sockets[0].getsockname() + print(f'Chat server running on {addr}') + + async with server: + await server.serve_forever() + + 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``: + +.. code-block:: none + + $ nc 127.0.0.1 8888 + Enter your name: Alice + *** Bob has joined the chat *** + Bob: Hi Alice! + Hello Bob! + +Each message you type is broadcast to all other connected users. + + +.. _asyncio-chat-server-extending: + +Extending the chat server +========================= + +The chat server is a good foundation to build on. Here are some ideas to +try. + +Adding an idle timeout +---------------------- + +Disconnect users who have been idle for too long using +:func:`asyncio.timeout`:: + + 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 +------------------- + +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 ` 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. diff --git a/Doc/howto/index.rst b/Doc/howto/index.rst index 81fc7e63f35bd7..bd2c87dfb2b4ab 100644 --- a/Doc/howto/index.rst +++ b/Doc/howto/index.rst @@ -14,6 +14,7 @@ Python Library Reference. :hidden: a-conceptual-overview-of-asyncio.rst + asyncio-chat-server.rst cporting.rst curses.rst descriptor.rst @@ -42,6 +43,7 @@ Python Library Reference. General: * :ref:`a-conceptual-overview-of-asyncio` +* :ref:`asyncio-chat-server-howto` * :ref:`annotations-howto` * :ref:`argparse-tutorial` * :ref:`descriptorhowto` diff --git a/Doc/library/asyncio.rst b/Doc/library/asyncio.rst index 0f72e31dee5f1d..5f3dfb738ad225 100644 --- a/Doc/library/asyncio.rst +++ b/Doc/library/asyncio.rst @@ -31,6 +31,9 @@ asyncio is often a perfect fit for IO-bound and high-level .. seealso:: + :ref:`asyncio-chat-server-howto` + Build a TCP chat server with asyncio streams. + :ref:`a-conceptual-overview-of-asyncio` Explanation of the fundamentals of asyncio. From 3f5b5aeab52ffeb36818ba62c9705e1297fb3d0a Mon Sep 17 00:00:00 2001 From: kovan Date: Tue, 10 Feb 2026 16:43:42 +0100 Subject: [PATCH 2/3] 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 --- Doc/howto/asyncio-chat-server.rst | 235 +++++++++--------------------- Doc/library/asyncio.rst | 6 +- 2 files changed, 69 insertions(+), 172 deletions(-) diff --git a/Doc/howto/asyncio-chat-server.rst b/Doc/howto/asyncio-chat-server.rst index 7e34aee7a1fc9c..54ad6675cf3164 100644 --- a/Doc/howto/asyncio-chat-server.rst +++ b/Doc/howto/asyncio-chat-server.rst @@ -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 ` 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``): .. 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. -.. _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 ` 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) ... diff --git a/Doc/library/asyncio.rst b/Doc/library/asyncio.rst index 5f3dfb738ad225..6940e4871580dd 100644 --- a/Doc/library/asyncio.rst +++ b/Doc/library/asyncio.rst @@ -31,12 +31,12 @@ asyncio is often a perfect fit for IO-bound and high-level .. seealso:: - :ref:`asyncio-chat-server-howto` - Build a TCP chat server with asyncio streams. - :ref:`a-conceptual-overview-of-asyncio` Explanation of the fundamentals of asyncio. + :ref:`asyncio-chat-server-howto` + Build a TCP chat server with asyncio streams. + asyncio provides a set of **high-level** APIs to: * :ref:`run Python coroutines ` concurrently and From 3b2356d96c1ffbdcc83dc518dda48a3646057a37 Mon Sep 17 00:00:00 2001 From: kovan Date: Tue, 10 Feb 2026 17:40:55 +0100 Subject: [PATCH 3/3] gh-79012: Restructure HOWTO to explain concepts before code - Move write/drain and close/wait_closed explanations above the echo server example - Explain async with server, serve_forever, and asyncio.run - Break chat server into subsections: client tracking, broadcasting, then the complete example - Show broadcast function separately before the full listing Co-Authored-By: Claude Opus 4.6 --- Doc/howto/asyncio-chat-server.rst | 59 ++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/Doc/howto/asyncio-chat-server.rst b/Doc/howto/asyncio-chat-server.rst index 54ad6675cf3164..11d84c594656f0 100644 --- a/Doc/howto/asyncio-chat-server.rst +++ b/Doc/howto/asyncio-chat-server.rst @@ -35,6 +35,19 @@ asyncio calls your callback with two arguments: a :class:`~asyncio.StreamWriter` for sending data back. Each connection runs as its own coroutine, so multiple clients are handled concurrently. +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. + +Using the server as an async context manager (``async with server``) ensures +it is properly cleaned up when done. Calling +:meth:`~asyncio.Server.serve_forever` keeps the server running until the +program is interrupted. Finally, :func:`asyncio.run` starts the event loop +and runs the top-level coroutine. + Here is a complete echo server:: import asyncio @@ -65,13 +78,6 @@ Here is a complete echo server:: asyncio.run(main()) -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. - To test, run the server in one terminal and connect from another using ``nc`` (or ``telnet``): @@ -88,13 +94,40 @@ Building the chat server 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. +Client tracking +--------------- + +We store each connected client's name and :class:`~asyncio.StreamWriter` in a +module-level dictionary. When a client connects, ``handle_client`` prompts for +a name and adds the writer to the dictionary. A ``finally`` block ensures the +client is always removed on disconnect, even if the connection drops +unexpectedly. + +Broadcasting messages +--------------------- + +To send a message to all clients, we define a ``broadcast`` function. +:class:`asyncio.TaskGroup` sends to all recipients concurrently rather than +one at a time. :func:`contextlib.suppress` silently handles any +:exc:`ConnectionError` from clients that have already disconnected:: + + async def broadcast(message, *, sender=None): + """Send a message to all connected clients except the sender.""" + 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)) + +The complete chat server +------------------------ -:: +Putting it all together:: import asyncio import contextlib