Skip to content

Commit 61b700c

Browse files
kovanclaude
andcommitted
gh-79012: Add asyncio chat server HOWTO
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cfeede8 commit 61b700c

File tree

3 files changed

+315
-0
lines changed

3 files changed

+315
-0
lines changed

Doc/howto/asyncio-chat-server.rst

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
.. _asyncio-chat-server-howto:
2+
3+
***********************************************
4+
Building a TCP chat server with :mod:`!asyncio`
5+
***********************************************
6+
7+
This guide walks you through building a TCP chat server where multiple users
8+
can connect and exchange messages in real time. Along the way, you will learn
9+
how to use :ref:`asyncio streams <asyncio-streams>` for network programming.
10+
11+
The guide assumes basic Python knowledge --- functions, classes, and context
12+
managers --- and a general understanding of async/await.
13+
14+
.. seealso::
15+
16+
:ref:`a-conceptual-overview-of-asyncio`
17+
An explanation of how asyncio works under the hood.
18+
19+
:mod:`asyncio` reference documentation
20+
The complete API reference.
21+
22+
23+
.. _asyncio-chat-server-echo:
24+
25+
Starting with an echo server
26+
============================
27+
28+
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.
31+
32+
::
33+
34+
import asyncio
35+
36+
async def handle_client(reader, writer):
37+
addr = writer.get_extra_info('peername')
38+
print(f'New connection from {addr}')
39+
40+
while True:
41+
data = await reader.readline()
42+
if not data:
43+
break
44+
writer.write(data)
45+
await writer.drain()
46+
47+
print(f'Connection from {addr} closed')
48+
writer.close()
49+
await writer.wait_closed()
50+
51+
async def main():
52+
server = await asyncio.start_server(
53+
handle_client, '127.0.0.1', 8888)
54+
addr = server.sockets[0].getsockname()
55+
print(f'Serving on {addr}')
56+
57+
async with server:
58+
await server.serve_forever()
59+
60+
asyncio.run(main())
61+
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
102+
103+
Received: 'Hello!'
104+
Received: 'How are you?'
105+
Received: 'Goodbye!'
106+
107+
You can also test using ``telnet`` or ``nc``:
108+
109+
.. code-block:: none
110+
111+
$ nc 127.0.0.1 8888
112+
113+
114+
.. _asyncio-chat-server-building:
115+
116+
Building the chat server
117+
========================
118+
119+
The chat server extends the echo server with two key additions: tracking
120+
connected clients and broadcasting messages to all of them.
121+
122+
::
123+
124+
import asyncio
125+
126+
connected_clients: dict[str, asyncio.StreamWriter] = {}
127+
128+
async def broadcast(message, *, sender=None):
129+
"""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.
137+
138+
async def handle_client(reader, writer):
139+
addr = writer.get_extra_info('peername')
140+
141+
writer.write(b'Enter your name: ')
142+
await writer.drain()
143+
data = await reader.readline()
144+
if not data:
145+
writer.close()
146+
await writer.wait_closed()
147+
return
148+
149+
name = data.decode().strip()
150+
connected_clients[name] = writer
151+
print(f'{name} ({addr}) has joined')
152+
await broadcast(f'*** {name} has joined the chat ***\n', sender=name)
153+
154+
try:
155+
while True:
156+
data = await reader.readline()
157+
if not data:
158+
break
159+
message = data.decode().strip()
160+
if message:
161+
print(f'{name}: {message}')
162+
await broadcast(f'{name}: {message}\n', sender=name)
163+
except ConnectionError:
164+
pass
165+
finally:
166+
del connected_clients[name]
167+
print(f'{name} ({addr}) has left')
168+
await broadcast(f'*** {name} has left the chat ***\n')
169+
writer.close()
170+
await writer.wait_closed()
171+
172+
async def main():
173+
server = await asyncio.start_server(
174+
handle_client, '127.0.0.1', 8888)
175+
addr = server.sockets[0].getsockname()
176+
print(f'Chat server running on {addr}')
177+
178+
async with server:
179+
await server.serve_forever()
180+
181+
asyncio.run(main())
182+
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``:
199+
200+
.. code-block:: none
201+
202+
$ nc 127.0.0.1 8888
203+
Enter your name: Alice
204+
*** Bob has joined the chat ***
205+
Bob: Hi Alice!
206+
Hello Bob!
207+
208+
Each message you type is broadcast to all other connected users.
209+
210+
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.
218+
219+
Adding an idle timeout
220+
----------------------
221+
222+
Disconnect users who have been idle for too long using
223+
:func:`asyncio.timeout`::
224+
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+
-------------------
271+
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.

Doc/howto/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Python Library Reference.
1414
:hidden:
1515

1616
a-conceptual-overview-of-asyncio.rst
17+
asyncio-chat-server.rst
1718
cporting.rst
1819
curses.rst
1920
descriptor.rst
@@ -42,6 +43,7 @@ Python Library Reference.
4243
General:
4344

4445
* :ref:`a-conceptual-overview-of-asyncio`
46+
* :ref:`asyncio-chat-server-howto`
4547
* :ref:`annotations-howto`
4648
* :ref:`argparse-tutorial`
4749
* :ref:`descriptorhowto`

Doc/library/asyncio.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ 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+
3437
:ref:`a-conceptual-overview-of-asyncio`
3538
Explanation of the fundamentals of asyncio.
3639

0 commit comments

Comments
 (0)