@@ -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
2828Before 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``:
11685Building 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
219179Adding 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) ...
0 commit comments