How to Shutdown Asyncio Server Safely

March 21, 2024 Python Asyncio

You can run the asyncio server as a background task in an asyncio program.

This has the benefit of allowing the main() coroutine to perform other tasks while the server is running and to cancel the server on demand if needed.

The asyncio server will not close correctly if it is canceled. We can close the asyncio server safely manually with a try-except or try-finally block. Alternatively, we can automatically close the asyncio server when it is canceled by using the context manager interface on the server object itself.

In this tutorial, you will discover how to run the asyncio server as a background task and safely close the server on demand.

Let's get started.

Need to Run Asyncio Server As a Background Task

The typical way to use the asyncio server is to create an instance of the server object and run it until the user forcefully terminates the program.

For example:

...
# create an asyncio server
server = await asyncio.start_server(handler, '127.0.0.1', port=8888)
# accept client connections forever (kill via control-c)
await server.serve_forever()

You can learn more about starting an asyncio server in the tutorial:

The problem with this approach is that it does not allow any control over the server because the main coroutine is suspended while the server accepts client connections.

How can we run an asyncio server in the background and perform other activities in our program while the server is running?

How to Run An Asyncio Server As a Background Task

An asyncio server can be run as a background task.

This can be achieved by defining a coroutine that creates the server and accepts client connections forever. The coroutine can then be scheduled as an asyncio task and run in the background.

In treating the server itself as a task, it allows other benefits too, such as:

This can be achieved by first defining a coroutine to create and run the server.

For example:

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', port=8888)
    # accept client connections forever (kill via control-c)
    await server.serve_forever()

This coroutine can then be scheduled as a task from the main coroutine.

For example:

...
# start and run the server as a background task
server_task = asyncio.create_task(background_server())

The main coroutine can then choose to await it if it chooses.

For example:

...
# wait for the server
await server_task

It can also choose to cancel the server task.

For example:

...
# cancel the server task
server_task.cancel()

A limitation of this approach is that if the server task is canceled, it will terminate immediately, and will not shut down safely.

How to Shutdown The Asyncio Server Task Safely

We can shut down the asyncio server task safely by wrapping the body of the task in a try-finally block.

The finally block can call the close() method on the server and wait for all client connections to be closed before moving on

If the server fails with an unexpected exception or is canceled, the finally block will execute and safely close the server.

For example:

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', port=8888)
    try:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()
    except asyncio.CancelledError:
        # stop taking new clients
        server.close()
        # wait for all clients to close
        await server.wait_closed()
        # re-raise cancellation
        raise

Thankfully, we don't have to implement this ourselves.

The asyncio.Server class implements the context manager interface and will close the server and all client connections for us.

Therefore, we can achieve the same thing with less code as follows:

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', port=8888)
    # ensure the server is closed correctly
    async with server:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()

This will ensure that regardless of how the server is terminated, it is closed safely.

Now that we know how to run the asyncio server as a background task and how to close it safely, let's look at some worked examples.

Example of Creating an Asyncio Server (Foreground Task)

Before we explore how to run an asyncio server as a background task, let's review how to start and run the server as a foreground task.

In this example, we will define a client handler that does nothing, as we won't be accepting any client connections. We will then create a new server on port 8888, report its details, and accept client connections forever in the main coroutine via the server_forever() method.

The complete example is listed below.

# SuperFastPython.com
# example of creating a server and serving forever
import asyncio

# handler for client connections
async def handler(reader, writer):
    pass

# main coroutine
async def main():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    # accept client connections forever (kill via control-c)
    await server.serve_forever()

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

Running the example starts the asyncio event loop and runs the main() coroutine.

The main() coroutine runs and creates a new server for the local host, accepting connections on port 8888. The server starts accepting client connections as soon as it is created.

The details of the server are then reported, confirming the host address and port.

Finally, the server_forever() method is called and the main coroutine blocks forever while the server accepts client connections.

The server must then be killed by killing the process, such as sending the SIGINT signal via the Control-C keyboard command.

<Server sockets=(<asyncio.TransportSocket fd=6, family=2, type=1, proto=6, laddr=('127.0.0.1', 8888)>,)>

The problem with this approach to running the server is that the main coroutine is suspended for the lifetime of the server.

It is unable to perform other activities while the server is running.

Next, let's explore an example of running the asyncio server as a background task.

Example of Asyncio Server in Background Task

We can explore how to run the asyncio server as a background task.

In this case, we can define a new coroutine that creates the server and serves forever.

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    # accept client connections forever (kill via control-c)
    await server.serve_forever()

We can then schedule this coroutine as a background task, freeing the main coroutine to continue on with other activities.

...
# start and run the server as a background task
server_task = asyncio.create_task(background_server())

Recall that when the main coroutine exits, the event loop will cancel all other running coroutines.

This means we can exit the main coroutine normally and the server will be canceled automatically.

You can learn more about the main coroutine in the tutorial:

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of asyncio server in a background task
import asyncio

# handler for client connections
async def handler(reader, writer):
    pass

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    # accept client connections forever (kill via control-c)
    await server.serve_forever()

# main coroutine
async def main():
    # start and run the server as a background task
    server_task = asyncio.create_task(background_server())
    # wait around for a while
    await asyncio.sleep(2)
    # report progress
    print('Shutting down...')

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

Running the example starts the asyncio event loop and runs the main() coroutine.

The main() coroutine runs and creates and schedules the background_server() coroutine as a background task. It then suspends and sleeps for 2 seconds.

The background_server() task runs, creates the server, reports the details of the server, and serves forever accepting client connections.

The main() coroutine resumes and reports a final message before exiting.

The event loop then terminates the background_server() task, forcefully closing the server.

<Server sockets=(<asyncio.TransportSocket fd=6, family=2, type=1, proto=6, laddr=('127.0.0.1', 8888)>,)>
Shutting down...

The problem with this approach is that the server task is canceled, and is not allowed to close the connections to any connected clients gracefully.

Next, let's explore how we can safely close the server when the program exits.

Example of Closing Asyncio Server Safely

We can explore how to safely close the asyncio server when the server task is canceled.

In this case, we can update the background_server() to use a try-except block when accepting client connections forever and handle a request to cancel.

Within the except block, we can report progress, close the server, and wait for all clients to disconnect safely before finally re-raising the asyncio.CancelledError as expected by the caller.

The updated background_server() coroutine with this change is listed below.

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    try:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()
    except asyncio.CancelledError:
        # report progress
        print('Closing server...')
        # stop taking new clients
        server.close()
        # wait for all clients to close
        await server.wait_closed()
        # re-raise cancellation
        raise

The main() coroutine can then explicitly cancel the server task and wait for the server to shut down.

This can be achieved using the "cancel and wait" idiom.

...
# cancel the server task
server_task.cancel()
# wait for the server to shutdown
try:
    await server_task
except asyncio.CancelledError:
    pass

You can learn more about cancel and wait in the tutorial:

Tying this together, the updated main() coroutine is listed below.

# main coroutine
async def main():
    # start and run the server as a background task
    server_task = asyncio.create_task(background_server())
    # wait around for a while
    await asyncio.sleep(2)
    # report progress
    print('Shutting down...')
    # cancel the server task
    server_task.cancel()
    # wait for the server to shutdown
    try:
        await server_task
    except asyncio.CancelledError:
        pass

This allows the main() coroutine to control the life-cycle of the server task and perform any additional cleanup action after the server has shut down.

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of asyncio server in a background task
import asyncio

# handler for client connections
async def handler(reader, writer):
    pass

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    try:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()
    except asyncio.CancelledError:
        # report progress
        print('Closing server...')
        # stop taking new clients
        server.close()
        # wait for all clients to close
        await server.wait_closed()
        # re-raise cancellation
        raise

# main coroutine
async def main():
    # start and run the server as a background task
    server_task = asyncio.create_task(background_server())
    # wait around for a while
    await asyncio.sleep(2)
    # report progress
    print('Shutting down...')
    # cancel the server task
    server_task.cancel()
    # wait for the server to shutdown
    try:
        await server_task
    except asyncio.CancelledError:
        pass

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

Running the example starts the asyncio event loop and runs the main() coroutine.

The main() coroutine runs and creates and schedules the background_server() coroutine as a background task. It then suspends and sleeps for 2 seconds.

The background_server() task runs, creates the server, reports the details of the server, and serves forever accepting client connections.

The main() coroutine resumes and reports a final message. It then cancels the server task and suspends, awaiting the task to be done.

An asyncio.CancelledError exception is raised in the background_server() task, which is handled. Progress is reported and the server is closed. It then waits for all client connections to be closed safely before re-raising the asyncio.CancelledError.

The main() coroutine resumes and ignores the asyncio.CancelledError, and terminates the event loop.

This highlights how the main coroutine can explicitly cancel the server when the server is run as a background task.

<Server sockets=(<asyncio.TransportSocket fd=6, family=2, type=1, proto=6, laddr=('127.0.0.1', 8888)>,)>
Shutting down...
Closing server...

A limitation of this approach is that we have to explicitly handle the exception raised in the background_server() and manually shut down the server.

Next, let's look at how we can automatically close the server safely using the context manager interface.

Example of Closing Asyncio Server Safely With Context Manager

We can explore how to automatically close the server safely using the context manager interface on the asyncio server object itself.

In this case, we can update the background_server() coroutine to use the context manager interface when accepting client connections forever.

For example:

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    # ensure the server is closed correctly
    async with server:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()

This will have the same effect as the previous example of closing the server on exception and waiting for all clients to disconnect safely.

It has the benefit that it will work for any exception raised in the coroutine, such as an error or cancellation, and uses less code.

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of asyncio server in a background task with context manager
import asyncio

# handler for client connections
async def handler(reader, writer):
    pass

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    # ensure the server is closed correctly
    async with server:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()

# main coroutine
async def main():
    # start and run the server as a background task
    server_task = asyncio.create_task(background_server())
    # wait around for a while
    await asyncio.sleep(2)
    # report progress
    print('Shutting down...')
    # cancel the server task
    server_task.cancel()
    # wait for the server to shutdown
    try:
        await server_task
    except asyncio.CancelledError:
        pass

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

Running the example starts the asyncio event loop and runs the main() coroutine.

The main() coroutine runs and creates and schedules the background_server() coroutine as a background task. It then suspends and sleeps for 2 seconds.

The background_server() task runs, creates the server, reports the details of the server, and opens the context manager then serves forever accepting client connections.

The main() coroutine resumes and reports a final message. It then cancels the server task and suspends, awaiting the task to be done.

An asyncio.CancelledError exception is raised in the background_server() task. The exception is propagated up the stack and the context manager is exited, which automatically closes the server and waits for all client connections to disconnect.

The main() coroutine resumes and ignores the asyncio.CancelledError, and terminates the event loop.

This highlights how we can automatically shut down the server in a safe manner if the server task is canceled or any other exception is raised in the task.

<Server sockets=(<asyncio.TransportSocket fd=6, family=2, type=1, proto=6, laddr=('127.0.0.1', 8888)>,)>
Shutting down...

We can confirm that the server is no longer serving after the context manager is exited.

That is, we can confirm that the context manager closes the server after it is exited.

This can be achieved by wrapping the context manager in a try-finally block and reporting the serving status of the server in the finally block.

For example:

...
try:
    # ensure the server is closed correctly
    async with server:
        # accept client connections forever (kill via control-c)
        await server.serve_forever()
finally:
    print(f'Server closed: serving={server.is_serving()}')

The updated background_server() coroutine with this change is listed below.

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    try:
        # ensure the server is closed correctly
        async with server:
            # accept client connections forever (kill via control-c)
            await server.serve_forever()
    finally:
        print(f'Server closed: serving={server.is_serving()}')

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of asyncio server in a background task with context manager
import asyncio

# handler for client connections
async def handler(reader, writer):
    pass

# task for running the server in the background
async def background_server():
    # create an asyncio server
    server = await asyncio.start_server(handler, '127.0.0.1', 8888)
    # report the details of the server
    print(server)
    try:
        # ensure the server is closed correctly
        async with server:
            # accept client connections forever (kill via control-c)
            await server.serve_forever()
    finally:
        print(f'Server closed: serving={server.is_serving()}')

# main coroutine
async def main():
    # start and run the server as a background task
    server_task = asyncio.create_task(background_server())
    # wait around for a while
    await asyncio.sleep(2)
    # report progress
    print('Shutting down...')
    # cancel the server task
    server_task.cancel()
    # wait for the server to shutdown
    try:
        await server_task
    except asyncio.CancelledError:
        pass

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

Running the example starts the asyncio event loop and runs the main() coroutine.

The main() coroutine runs and creates and schedules the background_server() coroutine as a background task. It then suspends and sleeps for 2 seconds.

The background_server() task runs, creates the server, reports the details of the server, and opens the context manager then serves forever accepting client connections.

The main() coroutine resumes and reports a final message. It then cancels the server task and suspends, awaiting the task to be done.

An asyncio.CancelledError exception is raised in the background_server() task. The exception is propagated up the stack and the context manager is exited, which automatically closes the server and waits for all client connections to disconnect. The finally block executes and reports the status of the server, confirming that it is no longer serving.

The main() coroutine resumes and ignores the asyncio.CancelledError, and terminates the event loop.

This highlights that the context manager does indeed close the server as described in the API documentation.

<Server sockets=(<asyncio.TransportSocket fd=6, family=2, type=1, proto=6, laddr=('127.0.0.1', 8888)>,)>
Shutting down...
Server closed: serving=False

Takeaways

You now know how to run the asyncio server as a background task and safely close the server on demand.



If you enjoyed this tutorial, you will love my book: Python Asyncio Jump-Start. It covers everything you need to master the topic with hands-on examples and clear explanations.