You can develop an echo client and server using asyncio connections and streams.
An echo server accepts client connections that send a message and reply with the same message, in turn, echoing it back. Developing an echo client and server is a common exercise in network programming.
We can explore how to develop an echo client and server in asyncio to better understand how to create and manage TCP connections and how to read and write with TCP streams.
In this tutorial, you will discover how to develop asyncio echo client and server programs.
Let’s get started.
What is An Echo Client and Server
An echo client and server is a common first example when learning network programming.
It is designed to illustrate bidirectional communication between two endpoints: a client and a server.
Developing an echo client and server program provides an excellent way to learn the basics of how to use streams in asyncio.
- Server: The echo server listens for incoming connections, and upon receiving data from a client, it sends the same data back.
- Client: The echo client connects to the server, sends a message, and waits for the reply from the server before disconnecting.
The client connects once and disconnects, whereas the server accepts multiple concurrent connections.
Developing an echo client and server in asyncio provides a practical introduction to asynchronous programming concepts, showcasing how tasks can be scheduled efficiently, I/O operations can be non-blocking, and multiple connections can be managed concurrently.
Now that we know what an echo client and server are, let’s look at how to develop an example.
Run loops using all CPUs, download your FREE book to learn how.
How to Develop an Echo Client and Server
We can explore how to develop a simple echo client and server.
This is a popular example when learning to use asyncio streams. A simple echo client and server are described in the API documentation for the asyncio streams API.
Firstly, we will explore how to develop a server that supports the connection of many clients, then we will develop a single client that will connect to the server.
Develop Asyncio Echo Server
The echo server involves a number of steps, they are:
- Define and create the server.
- Accept connections until terminated
- Read message from client.
- Send message to client.
- Close connection to client.
We can define and create the server using the asyncio.start_server() function.
This function takes the name of the coroutine to call when a connection is accepted, as well as the host address and port on which to accept connections.
For example:
1 2 3 |
... # create the server server = await asyncio.start_server(handler, host_address, host_port) |
This function returns an asyncio.Server instance.
This object implements the context manager interface, which we can use via the “async with” expression, closing the server automatically once we are finished with it, or on error.
1 2 3 4 |
... # run the server async with server: # ... |
While using the server, we can configure it to run in a loop forever, accepting connections and calling our handler coroutine.
This can be achieved via the Server.serve_forever() method on the asyncio.Server instance.
Start accepting connections until the coroutine is cancelled. Cancellation of serve_forever task causes the server to be closed.
— Event Loop, Asyncio API
For example:
1 2 3 4 5 |
... # run the server async with server: # accept connections await server.serve_forever() |
We can then define the handler coroutine to handle incoming client connections.
The handler must take an asyncio.StreamReader and asyncio.StreamWriter as arguments.
1 2 3 |
# handler async def handler(reader, writer): # ... |
We can then read byte data from the client and write it back, performing the echo task.
One approach is to assume that all messages will be terminated with a newline character “\n”.
The server can then read a line of byte data from the client and write it back. When writing, we can suspend and wait for the buffer to empty before carrying on, ensuring the message was written.
For example:
1 2 3 4 5 6 7 |
... # read the message from the client msg_bytes = await reader.readline() # send the message back writer.write(msg_bytes) # wait for the buffer to empty await writer.drain() |
Finally, we can close the connection to the server.
1 2 3 4 |
... # close the connection writer.close() await writer.wait_closed() |
You can learn more about asyncio streams in the tutorial:
And that’s it.
The server can be terminated manually, such as by killing the process.
Next, let’s look at how we might develop an echo client.
Develop Asyncio Echo Client
The echo server involves a number of steps, they are:
- Open a connection to the server.
- Send a message to the server.
- Read the response from the server.
- Close the connection.
We can open a connection to the server via the asyncio.open_connection() function.
This takes the address and port number for the server and returns an asyncio.StreamReader and asyncio.StreamWriter for writing and reading messages to and from the server.
Establish a network connection and return a pair of (reader, writer) objects. The returned reader and writer objects are instances of StreamReader and StreamWriter classes.
— Streams, Asyncio API.
For example:
1 2 3 |
... # open a connection to the server reader, writer = await asyncio.open_connection(server_address, server_port) |
The writer can then be used to write a message to the server in bytes and the buffer drained to ensure the message was transmitted.
1 2 3 4 5 |
... # send the message writer.write(msg_bytes) # wait for buffer to empty await writer.drain() |
The client can then read the echoed response from the server.
1 2 3 |
... # read the response result_bytes = await reader.readline() |
Finally, the connection can be closed.
1 2 3 4 |
... # close the connection writer.close() await writer.wait_closed() |
And that’s it.
Now that we know how to develop an echo client and server, let’s look at a worked example.
Example of Echo Client and Server
We can develop a worked example of an echo client and server.
The client and server will be developed as separate Python programs, both intended to be run on the same workstation.
We can develop the server first, then the client, then experiment by running both programs at the same time.
Example of Echo Server
Firstly, we can develop the echo server.
Our server will have two parts, the handler for accepting incoming client connections and the main server coroutine for creating and serving connections forever.
Firstly, we can define the client connection handler.
As we explored above, the handler coroutine must take a reader and writer as arguments for interacting with the client.
1 2 3 |
# handle an echo client connection async def handle_echo_client(reader, writer): ... |
We will first read the line of data from the client in bytes and report the message.
Recall that we can transform byte data to a human-readable string via the decode() method on the array of byte data. Also recall that we can call the strip() method on a string to remove trailing white space, such as the new line character at the end of the message.
1 2 3 4 5 6 7 |
... # report progress print('Client connected') # read the message from the client msg_bytes = await reader.readline() # report the message print(f'Got: {msg_bytes.decode().strip()}') |
Next, we will suspend the server for a moment, just to slow things down a bit so we can keep up.
This can be achieved with a simple sleep.
1 2 3 |
... # wait a moment await asyncio.sleep(1) |
Next, we can send the message back to the client.
1 2 3 4 5 6 7 |
... # report progress print('Echoing message...') # send the message back writer.write(msg_bytes) # wait for the buffer to empty await writer.drain() |
Finally, we can close the connection to the client.
1 2 3 4 5 6 |
... # close the connection writer.close() await writer.wait_closed() # report progress print('Closing connection') |
Tying things together, the complete handle_echo_client() coroutine is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# handle an echo client connection async def handle_echo_client(reader, writer): # report progress print('Client connected') # read the message from the client msg_bytes = await reader.readline() # report the message print(f'Got: {msg_bytes.decode().strip()}') # wait a moment await asyncio.sleep(1) # report progress print('Echoing message...') # send the message back writer.write(msg_bytes) # wait for the buffer to empty await writer.drain() # close the connection writer.close() await writer.wait_closed() # report progress print('Closing connection') |
Next, we can define the main() coroutine that creates the server.
Firstly, we can define the local address of the server and the port on which we will expect to make connections.
We will use port 8888, an unused port and common for network programming experiments on a workstation.
We can then open the connection and accept client connections forever.
Tying this together, the complete main() coroutine is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 |
# echo server async def main(): # define the local host host_address, host_port = '127.0.0.1', 8888 # create the server server = await asyncio.start_server(handle_echo_client, host_address, host_port) # run the server async with server: # report message print('Server running...') # accept connections await server.serve_forever() |
Finally, we can start the asyncio event loop and run our server.
1 2 3 |
... # start the event loop asyncio.run(main()) |
Tying this together, the complete example is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# SuperFastPython.com # example of an echo server using streams import asyncio # handle an echo client connection async def handle_echo_client(reader, writer): # report progress print('Client connected') # read the message from the client msg_bytes = await reader.readline() # report the message print(f'Got: {msg_bytes.decode().strip()}') # wait a moment await asyncio.sleep(1) # report progress print('Echoing message...') # send the message back writer.write(msg_bytes) # wait for the buffer to empty await writer.drain() # close the connection writer.close() await writer.wait_closed() # report progress print('Closing connection') # echo server async def main(): # define the local host host_address, host_port = '127.0.0.1', 8888 # create the server server = await asyncio.start_server(handle_echo_client, host_address, host_port) # run the server async with server: # report message print('Server running...') # accept connections await server.serve_forever() # start the event loop asyncio.run(main()) |
Next, let’s look at how we can develop an echo client.
Example of Echo Client
We can develop an echo client to connect to and communicate with our server.
Our client will have three parts, a coroutine for sending a message, another coroutine for reading a response, and a main coroutine for opening the connection, driving the exchange, and closing the connection.
Firstly, we can define a coroutine to send a message to the server.
The coroutine takes the StreamWriter and the string message to write.
The message is encoded to an array of bytes via the encode() method, and then transmitted.
The send_message() coroutine below implements this.
1 2 3 4 5 6 7 8 9 10 |
# send message to server async def send_message(writer, message): # report progress print(f'Sending message: {message.strip()}') # encode message to bytes msg_bytes = message.encode() # send the message writer.write(msg_bytes) # wait for buffer to empty await writer.drain() |
Next, we can define a coroutine to read the response from the server.
The coroutine takes the StreamReader as an argument and reads bytes data until a newline is encountered, then reports the message that was read.
The read_response() coroutine below implements this.
1 2 3 4 5 6 7 8 9 10 |
# read response from the server async def read_response(reader): # report progress print('Waiting for response...') # read the response result_bytes = await reader.readline() # decode response response = result_bytes.decode() # report the response print(f'Received response: {response.strip()}') |
Next, we can define the main() coroutine that drives the program.
Firstly, a connection is opened to the server, specifying the local address and expected port number,
1 2 3 4 5 6 7 8 9 |
... # define the server details server_address, server_port = '127.0.0.1', 8888 # report message print(f'Connecting to {server_address}:{server_port}...') # open a connection to the server reader, writer = await asyncio.open_connection(server_address, server_port) # report message print('Connected') |
The send_message() coroutine is then called to send the message, followed by the read_response() coroutine to read the response.
1 2 3 4 5 |
... # send the message await send_message(writer, 'Hello There\n') # read response await read_response(reader) |
Finally, the connection to the server is closed.
1 2 3 4 5 |
... # close the connection print('Shutting down...') writer.close() await writer.wait_closed() |
Tying this together, the complete main() coroutine that implements this is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# echo client async def main(): # define the server details server_address, server_port = '127.0.0.1', 8888 # report message print(f'Connecting to {server_address}:{server_port}...') # open a connection to the server reader, writer = await asyncio.open_connection(server_address, server_port) # report message print('Connected') # send the message await send_message(writer, 'Hello There\n') # read response await read_response(reader) # close the connection print('Shutting down...') writer.close() await writer.wait_closed() |
Finally, we can start the asyncio event loop and run our program.
1 2 3 |
... # run the event loop asyncio.run(main()) |
Tying this together, the complete echo client is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# SuperFastPython.com # example of an echo client using streams import asyncio # send message to server async def send_message(writer, message): # report progress print(f'Sending message: {message.strip()}') # encode message to bytes msg_bytes = message.encode() # send the message writer.write(msg_bytes) # wait for buffer to empty await writer.drain() # read response from the server async def read_response(reader): # report progress print('Waiting for response...') # read the response result_bytes = await reader.readline() # decode response response = result_bytes.decode() # report the response print(f'Received response: {response.strip()}') # echo client async def main(): # define the server details server_address, server_port = '127.0.0.1', 8888 # report message print(f'Connecting to {server_address}:{server_port}...') # open a connection to the server reader, writer = await asyncio.open_connection(server_address, server_port) # report message print('Connected') # send the message await send_message(writer, 'Hello There\n') # read response await read_response(reader) # close the connection print('Shutting down...') writer.close() await writer.wait_closed() # run the event loop asyncio.run(main()) |
Next, let’s run the client and server programs and see what happens.
Example of Running The Server With One Client
We can run the server and client and observe what happens.
Firstly, we can run the server.
For example, we can save the server program to the program server.py, then run it from the command line with the command:
1 |
python server.py |
Next, we can run the client.
We can save the client program to a file named client.py and run it from the command line in a separate terminal window.
For example:
1 |
python client.py. |
Immediately, the client connects to the server.
The server reports the connection.
The client sends the message and waits for the response.
The server receives the response and echos it back and closes the connection.
The client receives the response and closes its connection.
Below is a sample of the output from the server program.
1 2 3 4 5 |
Server running... Client connected Got: Hello There Echoing message... Closing connection |
Below is a sample of the output from the client program.
1 2 3 4 5 6 |
Connecting to 127.0.0.1:8888... Connected Sending message: Hello There Waiting for response... Received response: Hello There Shutting down... |
And that’s it.
Next, let’s explore what happens if multiple clients connect to our echo server.
Free Python Asyncio Course
Download your FREE Asyncio PDF cheat sheet and get BONUS access to my free 7-day crash course on the Asyncio API.
Discover how to use the Python asyncio module including how to define, create, and run new coroutines and how to use non-blocking I/O.
Example of Multiple Echo Clients
A benefit of asyncio is that we can do many things concurrently in our programs.
For example, our asyncio echo server can accept many clients at the same time, potentially thousands or tens of thousands.
Similarly, we can choose to run many echo clients from the same asyncio program.
In this case, we can update our client program to connect many times to our single echo server and confirm that the server can handle the concurrent client connections.
Let’s dive in.
Develop Example With Many Concurrent Clients
We must update our client program to make multiple connections to our echo server.
Firstly, we can rename the main() coroutine to be a client() coroutine that manages a single connection to the server.
The coroutine takes a unique identifier or client number which is used in the echo message sent to the server. This will help us see in the output the different clients connecting and echoing their message.
The updated client() coroutine is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# echo client async def client(identifier): # define the server details server_address, server_port = '127.0.0.1', 8888 # report message print(f'Connecting to {server_address}:{server_port}...') # open a connection to the server reader, writer = await asyncio.open_connection(server_address, server_port) # report message print('Connected') # send the message await send_message(writer, f'Hello there from {identifier}\n') # read response await read_response(reader) # close the connection print('Shutting down...') writer.close() await writer.wait_closed() |
Next, we can define a main() coroutine that runs many examples of the client() concurrently.
This can be achieved using an asyncio.TaskGroup and issuing each call to the client() coroutine as a separate task. The TaskGroup will then suspend until all clients are done.
1 2 3 4 5 6 7 |
# entry point async def main(): # run a number of clients concurrently async with asyncio.TaskGroup() as group: # issues tasks tasks = [group.create_task(client(i)) for i in range(10)] # wait for tasks to complete |
You can learn more about the asyncio.TaskGroup in the tutorial:
And that’s it.
Tying this together, the complete example is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# SuperFastPython.com # example of running many concurrent clients import asyncio # send message to server async def send_message(writer, message): # report progress print(f'Sending message: {message.strip()}') # encode message to bytes msg_bytes = message.encode() # send the message writer.write(msg_bytes) # wait for buffer to empty await writer.drain() # read response from the server async def read_response(reader): # report progress print('Waiting for response...') # read the response result_bytes = await reader.readline() # decode response response = result_bytes.decode() # report the response print(f'Received response: {response.strip()}') # echo client async def client(identifier): # define the server details server_address, server_port = '127.0.0.1', 8888 # report message print(f'Connecting to {server_address}:{server_port}...') # open a connection to the server reader, writer = await asyncio.open_connection(server_address, server_port) # report message print('Connected') # send the message await send_message(writer, f'Hello there from {identifier}\n') # read response await read_response(reader) # close the connection print('Shutting down...') writer.close() await writer.wait_closed() # entry point async def main(): # run a number of clients concurrently async with asyncio.TaskGroup() as group: # issues tasks tasks = [group.create_task(client(i)) for i in range(10)] # wait for tasks to complete # run the event loop asyncio.run(main()) |
Next, let’s look at an example of running multiple clients connecting to our single server.
Firstly, we can run the same server program as before.
For example, we can run it from the command line as follows:
1 |
python server.py |
Next, we can run the updated client program.
We can save the client program to a file with the name multiple_clients.py and run it from the command line in a new terminal window as follows:
1 |
python multiple_clients.py |
Running the client program starts 10 clients that all connect to the server at the same time.
Each client connection to the server is handled separately by the server with a distinct call to the handle_echo_client() function.
On the server, we can see a connection message for each client that is connected, the message transmitted, and the response transmitted back both containing the unique client number.
Below is the sample output from the server program accepting and handling the connections from 10 concurrent echo clients.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
Client connected Client connected Client connected Client connected Client connected Client connected Client connected Client connected Client connected Client connected Got: Hello there from 0 Got: Hello there from 1 Got: Hello there from 2 Got: Hello there from 3 Got: Hello there from 4 Got: Hello there from 5 Got: Hello there from 6 Got: Hello there from 7 Got: Hello there from 8 Got: Hello there from 9 Echoing message... Echoing message... Echoing message... Echoing message... Echoing message... Echoing message... Echoing message... Echoing message... Echoing message... Echoing message... Closing connection Closing connection Closing connection Closing connection Closing connection Closing connection Closing connection Closing connection Closing connection Closing connection |
The client program runs a separate call to the client() coroutine for each connection to the server.
The client’s open connection message is reported, followed by the message with the unique client id and the response with the same id.
A sample output from the client program is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connecting to 127.0.0.1:8888... Connected Sending message: Hello there from 0 Waiting for response... Connected Sending message: Hello there from 1 Waiting for response... Connected Sending message: Hello there from 2 Waiting for response... Connected Sending message: Hello there from 3 Waiting for response... Connected Sending message: Hello there from 4 Waiting for response... Connected Sending message: Hello there from 5 Waiting for response... Connected Sending message: Hello there from 6 Waiting for response... Connected Sending message: Hello there from 7 Waiting for response... Connected Sending message: Hello there from 8 Waiting for response... Connected Sending message: Hello there from 9 Waiting for response... Received response: Hello there from 0 Shutting down... Received response: Hello there from 1 Shutting down... Received response: Hello there from 2 Shutting down... Received response: Hello there from 3 Shutting down... Received response: Hello there from 4 Shutting down... Received response: Hello there from 5 Shutting down... Received response: Hello there from 6 Shutting down... Received response: Hello there from 7 Shutting down... Received response: Hello there from 8 Shutting down... Received response: Hello there from 9 Shutting down... |
This example highlights the ability of the server to effortlessly handle multiple concurrent client connections.
It also highlights how we can develop a client program that itself makes many concurrent connections to the same server.
Overwhelmed by the python concurrency APIs?
Find relief, download my FREE Python Concurrency Mind Maps
Further Reading
This section provides additional resources that you may find helpful.
Python Asyncio Books
- Python Asyncio Mastery, Jason Brownlee (my book!)
- Python Asyncio Jump-Start, Jason Brownlee.
- Python Asyncio Interview Questions, Jason Brownlee.
- Asyncio Module API Cheat Sheet
I also recommend the following books:
- Python Concurrency with asyncio, Matthew Fowler, 2022.
- Using Asyncio in Python, Caleb Hattingh, 2020.
- asyncio Recipes, Mohamed Mustapha Tahrioui, 2019.
Guides
APIs
- asyncio — Asynchronous I/O
- Asyncio Coroutines and Tasks
- Asyncio Streams
- Asyncio Subprocesses
- Asyncio Queues
- Asyncio Synchronization Primitives
References
Takeaways
You now know how to develop asyncio echo client and server programs.
Did I make a mistake? See a typo?
I’m a simple humble human. Correct me, please!
Do you have any additional tips?
I’d love to hear about them!
Do you have any questions?
Ask your questions in the comments below and I will do my best to answer.
Photo by Tyler Clemmensen on Unsplash
Do you have any questions?