Python Asynchronous Programming
In this article, I will cover how asynchronous programming works in python. You will learn about the event loop, the async-await keywords, and how to run and schedule tasks and coroutines. This is extremely useful for network programming where you would have multiple connections and have periods of waiting for messages.
This is a part of a series of articles on how to develop a central multiplayer server architecture. You will be able to use this in games where you want to have multiplayer features, chats, and game room-based experiences.
Table of Contents
Thread vs. Async
Before I begin explaining async programming, I need to explain its difference compared with multi-threading. I will work with the multiplayer server example as it is the most relevant use case that you will need for now.
Consider the fact that you want to develop a multiplayer server. This server needs to accept multiple connections and wait for messages on those connections.
Multi-threading will open something like a separate process for each connection. This process usually has its own variables and execution cycle but can also share variables with the parent process or other threads. Threading can also happen simultaneously in the CPU. The thread can be idle while waiting for a message and this won’t affect the main thread.
Asynchronous programming is different by the fact that there is still a single thread (process) where the program is executed. When using async programming it’s just that the program jumps around to execute different tasks as soon as it can. So if you are waiting for the network connection to send you a message you can in the meantime do other useful stuff like updating other connections or sending messages.
The two of them can also go together because async code will just allow the code in a single thread to jump around and execute actions at different points in time. So, in summary, a thread is executed separately so it doesn’t block the main process and if it is idle it just doesn’t do anything. Async code on the other hand is executed on a thread but if it needs to wait for something it will execute other async code in that time.
So how does async code look like?
I will introduce you to the two new keywords async and await. The keyword async is used before a function definition to signalize that the function is asynchronous. Async function is also called a coroutine. Asynchronous code is also based on tasks. The keyword await will create a task. You can await other coroutines (async functions).
from asyncio import sleep
async def my_async_hello_world():
await sleep(5)
# this will be executed 5 seconds after the call to "my_async_hello_world"b
print("Hello, Async World!")
Tasks are executed by something called an event loop. Each thread has a single running event loop. The easiest way to run async code is to use the run method – it will create a new event loop and run your async code on it then delete that event loop. The other way is to create an event loop and call the method run_until_complete on it passing it the async function that you want to run.
from asyncio import sleep, run
async def my_async_hello_world():
await sleep(5)
# this will be executed 5 seconds after the call to "my_async_hello_world"b
print("Hello, Async World!")
run(my_async_hello_world())
In the above example you will notice that the program plays, waits 5 seconds, prints “Hello, Async World!” and then exits. Sleep is a special coroutine though as not only does it wait but it also frees the event loop to execute other tasks. Only sleep frees the event loop as far as I am aware. The simplest way to allow other tasks to run while your code does something is to use sleep(0). This will allow other tasks to run.
Async tasks
Well, this seems like a normal code there is nothing too async about it now and it is even more cluttered with new keywords as we need to call run and add async and await… So complicated.
The benefit is the ability to use tasks. tasks can be created so that coroutines are scheduled for later execution when sleep is called.
from datetime import datetime
from typing import Optional
from asyncio import get_event_loop, create_task, run, sleep
class Connection:
def __init__(self, name):
self.name = name
self.running = False
self._last_message_time = datetime.now()
def get_incoming_message(self) -> Optional[str]:
if (datetime.now() - self._last_message_time).seconds > 5:
self._last_message_time = datetime.now()
return f"Message from {self.name}"
else:
return None
async def start(self):
self.running = True
while self.running:
# if we don't call sleep we will enter and endless loop
# only ever updating the first connection
await sleep(0)
if (message := self.get_incoming_message()) is not None:
print(message)
async def main():
connection1 = Connection("Connection 1")
connection2 = Connection("Connection 2")
# Calling run only updates the first connection because run will wait for it to finish
# Even though there is an await sleep inside start() the second task is never started
# so the await sleep has nothing else to execute
# run(connection1.start())
# run(connection2.start())
# event_loop.run_until_complete() is the same as asyncio.run()
# it will also result in only one connection updating
# event_loop = get_event_loop()
# event_loop.run_until_complete(connection1.start())
# event_loop.run_until_complete(connection2.start())
# So to not block the current thread we need to schedule the start
create_task(connection1.start())
create_task(connection2.start())
# Then we also need to give them time to execute
while True:
# Remember - sleep gives time for other tasks to execute
await sleep(0)
# It will now grab the first connection and execute the code inside.
# The code inside will have to wait 5 seconds before a message is received but also has a sleep
# Because of the inner sleep it will now go to the second connection which also has to wait.
# Then it will go back here because there are no more tasks and repeat this loop.
# At 5 second intervals those inner connections will print messages.
# Order is not guaranteed
run(main())
I hope this example clears up some of the confusion around async code and how it runs. It literally jumps around as soon as there is a sleep method to another task that is waiting for an update. It is important not to forget to call sleep where you’re waiting on something to happen. There is only one thread that is being executed and getting into an endless loop will still freeze the program.
So if we want to not block and wait for a coroutine to finish – then we need to schedule it for when a sleep is called then we use the create_task function.
Example
Here is a little playground of the code mentioned above so that you can fully test each scenario that we discussed in this article:
Summary
In this article, I showed you some asynchronous code. This is a prerequisite for my next topics that are related to creating a central server for communication between your game clients, matchmaking, and so much more.
These articles will continue as a series for creating a multiplayer game in Godot. I already have some Godot articles in this blog if you want to get started early. You will be able to apply the skills with any other game engine out there though I am choosing Godot as it is completely free and the GDScript language is very similar to the python language making these tutorials easy to follow.
Leave a comment