Skip to content

Commit 3f5b5ae

Browse files
kovanclaude
andcommitted
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>
1 parent 61b700c commit 3f5b5ae

File tree

2 files changed

+69
-172
lines changed

2 files changed

+69
-172
lines changed

Doc/howto/asyncio-chat-server.rst

Lines changed: 66 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ managers --- and a general understanding of async/await.
1414
.. seealso::
1515

1616
:ref:`a-conceptual-overview-of-asyncio`
17-
An explanation of how asyncio works under the hood.
17+
An introduction to the fundamentals of asyncio.
1818

1919
:mod:`asyncio` reference documentation
2020
The complete API reference.
@@ -26,10 +26,16 @@ Starting with an echo server
2626
============================
2727

2828
Before building the chat server, let's start with something simpler: an echo
29-
server that sends back whatever a client sends. This introduces the
30-
:ref:`streams <asyncio-streams>` API that the chat server builds on.
29+
server that sends back whatever a client sends.
3130

32-
::
31+
The core of any asyncio network server is :func:`asyncio.start_server`. You
32+
give it a callback function, a host, and a port. When a client connects,
33+
asyncio calls your callback with two arguments: a
34+
:class:`~asyncio.StreamReader` for receiving data and a
35+
:class:`~asyncio.StreamWriter` for sending data back. Each connection runs
36+
as its own coroutine, so multiple clients are handled concurrently.
37+
38+
Here is a complete echo server::
3339

3440
import asyncio
3541

@@ -59,52 +65,15 @@ server that sends back whatever a client sends. This introduces the
5965

6066
asyncio.run(main())
6167

62-
:func:`asyncio.start_server` listens for incoming connections. Each time a
63-
client connects, it calls ``handle_client`` with a
64-
:class:`~asyncio.StreamReader` and a :class:`~asyncio.StreamWriter`. Multiple
65-
clients are handled concurrently --- each connection runs as its own coroutine.
66-
67-
Two patterns are essential when working with streams:
68-
69-
- **Write then drain:** :meth:`~asyncio.StreamWriter.write` buffers data.
70-
``await`` :meth:`~asyncio.StreamWriter.drain` ensures it is actually sent
71-
(and applies back-pressure if the client is slow to read).
72-
73-
- **Close then wait_closed:** :meth:`~asyncio.StreamWriter.close` initiates
74-
shutdown. ``await`` :meth:`~asyncio.StreamWriter.wait_closed` waits until
75-
the connection is fully closed.
76-
77-
78-
Testing with a client
79-
---------------------
80-
81-
To test the echo server, run it in one terminal and this client in another::
82-
83-
import asyncio
84-
85-
async def main():
86-
reader, writer = await asyncio.open_connection(
87-
'127.0.0.1', 8888)
88-
89-
for message in ['Hello!\n', 'How are you?\n', 'Goodbye!\n']:
90-
writer.write(message.encode())
91-
await writer.drain()
92-
93-
data = await reader.readline()
94-
print(f'Received: {data.decode().strip()!r}')
95-
96-
writer.close()
97-
await writer.wait_closed()
98-
99-
asyncio.run(main())
100-
101-
.. code-block:: none
68+
The :meth:`~asyncio.StreamWriter.write` method buffers data without sending
69+
it immediately. Awaiting :meth:`~asyncio.StreamWriter.drain` flushes the
70+
buffer and applies back-pressure if the client is slow to read. Similarly,
71+
:meth:`~asyncio.StreamWriter.close` initiates shutdown, and awaiting
72+
:meth:`~asyncio.StreamWriter.wait_closed` waits until the connection is
73+
fully closed.
10274

103-
Received: 'Hello!'
104-
Received: 'How are you?'
105-
Received: 'Goodbye!'
106-
107-
You can also test using ``telnet`` or ``nc``:
75+
To test, run the server in one terminal and connect from another using ``nc``
76+
(or ``telnet``):
10877

10978
.. code-block:: none
11079
@@ -116,24 +85,34 @@ You can also test using ``telnet`` or ``nc``:
11685
Building the chat server
11786
========================
11887

119-
The chat server extends the echo server with two key additions: tracking
120-
connected clients and broadcasting messages to all of them.
88+
The chat server extends the echo server with two additions: tracking connected
89+
clients and broadcasting messages to everyone.
90+
91+
We store each client's name and :class:`~asyncio.StreamWriter` in a dictionary.
92+
When a message arrives, we broadcast it to all other connected clients.
93+
:class:`asyncio.TaskGroup` sends to all recipients concurrently, and
94+
:func:`contextlib.suppress` silently handles any :exc:`ConnectionError` from
95+
clients that have already disconnected.
12196

12297
::
12398

12499
import asyncio
100+
import contextlib
125101

126102
connected_clients: dict[str, asyncio.StreamWriter] = {}
127103

128104
async def broadcast(message, *, sender=None):
129105
"""Send a message to all connected clients except the sender."""
130-
for name, writer in list(connected_clients.items()):
131-
if name != sender:
132-
try:
133-
writer.write(message.encode())
134-
await writer.drain()
135-
except ConnectionError:
136-
pass # Client disconnected; cleaned up elsewhere.
106+
async def send(writer):
107+
with contextlib.suppress(ConnectionError):
108+
writer.write(message.encode())
109+
await writer.drain()
110+
111+
async with asyncio.TaskGroup() as tg:
112+
# Iterate over a copy: clients may leave during the broadcast.
113+
for name, writer in list(connected_clients.items()):
114+
if name != sender:
115+
tg.create_task(send(writer))
137116

138117
async def handle_client(reader, writer):
139118
addr = writer.get_extra_info('peername')
@@ -163,6 +142,7 @@ connected clients and broadcasting messages to all of them.
163142
except ConnectionError:
164143
pass
165144
finally:
145+
# Ensure cleanup even if the client disconnects unexpectedly.
166146
del connected_clients[name]
167147
print(f'{name} ({addr}) has left')
168148
await broadcast(f'*** {name} has left the chat ***\n')
@@ -180,22 +160,8 @@ connected clients and broadcasting messages to all of them.
180160

181161
asyncio.run(main())
182162

183-
Some things to note about this design:
184-
185-
- **No locks needed.** ``connected_clients`` is a plain :class:`dict`.
186-
Because asyncio runs in a single thread, no other task can modify it between
187-
``await`` points.
188-
189-
- **Iterating a copy.** ``broadcast()`` iterates over ``list(...)`` because a
190-
client might disconnect (and be removed from the dict) while we are
191-
broadcasting.
192-
193-
- **Cleanup in** ``finally``. The ``try``/``finally`` block ensures the
194-
client is removed from ``connected_clients`` and the connection is closed
195-
even if the client disconnects unexpectedly.
196-
197-
To test, start the server in one terminal and connect from two or more others
198-
using ``telnet`` or ``nc``:
163+
To test, start the server and connect from two or more terminals using ``nc``
164+
(or ``telnet``):
199165

200166
.. code-block:: none
201167
@@ -208,103 +174,34 @@ using ``telnet`` or ``nc``:
208174
Each message you type is broadcast to all other connected users.
209175

210176

211-
.. _asyncio-chat-server-extending:
212-
213-
Extending the chat server
214-
=========================
215-
216-
The chat server is a good foundation to build on. Here are some ideas to
217-
try.
177+
.. _asyncio-chat-server-timeout:
218178

219179
Adding an idle timeout
220-
----------------------
180+
======================
221181

222-
Disconnect users who have been idle for too long using
223-
:func:`asyncio.timeout`::
182+
To disconnect clients who have been idle for too long, wrap the read call in
183+
:func:`asyncio.timeout`. This async context manager takes a duration in
184+
seconds. If the enclosed ``await`` does not complete within that time, the
185+
operation is cancelled and :exc:`TimeoutError` is raised. This frees server
186+
resources when clients connect but stop sending data.
224187

225-
async def handle_client(reader, writer):
226-
# ... (name registration as before) ...
227-
try:
228-
while True:
229-
try:
230-
async with asyncio.timeout(300): # 5-minute timeout
231-
data = await reader.readline()
232-
except TimeoutError:
233-
writer.write(b'Disconnected: idle timeout.\n')
234-
await writer.drain()
235-
break
236-
if not data:
237-
break
238-
message = data.decode().strip()
239-
if message:
240-
await broadcast(f'{name}: {message}\n', sender=name)
241-
except ConnectionError:
242-
pass
243-
finally:
244-
# ... (cleanup as before) ...
245-
246-
Exercises
247-
---------
248-
249-
These exercises build on the chat server:
250-
251-
- **Add a** ``/quit`` **command** that lets a user disconnect gracefully by
252-
typing ``/quit``.
253-
254-
- **Add private messaging.** If a user types ``/msg Alice hello``, only
255-
Alice should receive the message.
256-
257-
- **Log messages to a file** using :func:`asyncio.to_thread` to avoid
258-
blocking the event loop during file writes.
259-
260-
- **Limit concurrent connections** using :class:`asyncio.Semaphore` to
261-
restrict the server to a maximum number of users.
262-
263-
264-
.. _asyncio-chat-server-pitfalls:
265-
266-
Common pitfalls
267-
===============
268-
269-
Forgetting to await
270-
-------------------
188+
Replace the message loop in ``handle_client`` with::
271189

272-
Calling a coroutine function without ``await`` creates a coroutine object but
273-
does not run it::
274-
275-
async def main():
276-
asyncio.sleep(1) # Wrong: creates a coroutine but never runs it.
277-
await asyncio.sleep(1) # Correct.
278-
279-
Python will emit a :exc:`RuntimeWarning` if a coroutine is never awaited.
280-
If you see ``RuntimeWarning: coroutine '...' was never awaited``, check for a
281-
missing ``await``.
282-
283-
Blocking the event loop
284-
-----------------------
285-
286-
Calling blocking functions like :func:`time.sleep` or performing synchronous
287-
I/O inside a coroutine freezes the entire event loop::
288-
289-
async def bad():
290-
time.sleep(5) # Wrong: blocks the event loop for 5 seconds.
291-
292-
async def good():
293-
await asyncio.sleep(5) # Correct: suspends without blocking.
294-
await asyncio.to_thread(time.sleep, 5) # Also correct: runs in a thread.
295-
296-
You can use :ref:`debug mode <asyncio-debug-mode>` to detect blocking calls:
297-
pass ``debug=True`` to :func:`asyncio.run`.
298-
299-
Fire-and-forget tasks disappearing
300-
-----------------------------------
301-
302-
If you create a task without keeping a reference to it, the task may be
303-
garbage collected before it finishes::
304-
305-
async def main():
306-
asyncio.create_task(some_coroutine()) # No reference kept!
307-
await asyncio.sleep(10)
308-
309-
Use :class:`asyncio.TaskGroup` to manage task lifetimes, or store task
310-
references in a collection.
190+
try:
191+
while True:
192+
try:
193+
async with asyncio.timeout(300): # 5-minute timeout
194+
data = await reader.readline()
195+
except TimeoutError:
196+
writer.write(b'Disconnected: idle timeout.\n')
197+
await writer.drain()
198+
break
199+
if not data:
200+
break
201+
message = data.decode().strip()
202+
if message:
203+
await broadcast(f'{name}: {message}\n', sender=name)
204+
except ConnectionError:
205+
pass
206+
finally:
207+
# ... (cleanup as before) ...

Doc/library/asyncio.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ asyncio is often a perfect fit for IO-bound and high-level
3131

3232
.. seealso::
3333

34-
:ref:`asyncio-chat-server-howto`
35-
Build a TCP chat server with asyncio streams.
36-
3734
:ref:`a-conceptual-overview-of-asyncio`
3835
Explanation of the fundamentals of asyncio.
3936

37+
:ref:`asyncio-chat-server-howto`
38+
Build a TCP chat server with asyncio streams.
39+
4040
asyncio provides a set of **high-level** APIs to:
4141

4242
* :ref:`run Python coroutines <coroutine>` concurrently and

0 commit comments

Comments
 (0)