Develop an Asyncio Echo Client and Server

March 14, 2024 Python Asyncio

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.

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.

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:

  1. Define and create the server.
  2. Accept connections until terminated
  3. Read message from client.
  4. Send message to client.
  5. 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:

...
# 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.

...
# 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:

...
# 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.

# 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:

...
# 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.

...
# 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:

  1. Open a connection to the server.
  2. Send a message to the server.
  3. Read the response from the server.
  4. 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:

...
# 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.

...
# 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.

...
# read the response
result_bytes = await reader.readline()

Finally, the connection can be closed.

...
# 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.

# 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.

...
# 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.

...
# wait a moment
await asyncio.sleep(1)

Next, we can send the message back to the client.

...
# 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.

...
# 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.

# 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.

# 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.

...
# start the event loop
asyncio.run(main())

Tying this together, the complete example is listed below.

# 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.

# 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.

# 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,

...
# 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.

...
# send the message
await send_message(writer, 'Hello There\n')
# read response
await read_response(reader)

Finally, the connection to the server is closed.

...
# 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.

# 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.

...
# run the event loop
asyncio.run(main())

Tying this together, the complete echo client is listed below.

# 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:

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:

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.

Server running...
Client connected
Got: Hello There
Echoing message...
Closing connection

Below is a sample of the output from the client program.

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.

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.

# 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.

# 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.

# 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:

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:

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.

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.

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.

Takeaways

You now know how to develop asyncio echo client and server programs.