|
| 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. |
0 commit comments