You can suffer race conditions with coroutines in asyncio.
In this tutorial, you will discover examples of ascynio race conditions with coroutines in Python.
Let’s get started.
What is a Race Condition
A race condition is a bug in concurrency programming.
It is a failure case where the behavior of the program is dependent upon the order of execution by two or more threads. This means that the behavior of the program will not be predictable, possibly changing each time it is run.
Race condition: A flaw in a concurrent application in which the result is dependent on the timing or sequence of multiple threads’ execution.
— Glossary, The Art of Concurrency, 2009.
There are many types of race conditions, although a common type of race condition is when two or more threads attempt to change the same data variable.
NOTE: Race conditions are a real problem in Python when using threads, even in the presence of the global interpreter lock (GIL). The refrain that there are no race conditions in Python because of the GIL is dangerously wrong.
You can learn more about race conditions for Python threads in the tutorials:
Next, let’s consider race conditions in asyncio.
Run loops using all CPUs, download your FREE book to learn how.
Race Conditions Are Possible in Asyncio
Race conditions are typically described in terms of threads.
Nevertheless, they are possible with other units of concurrency, such as Python processes.
Are race conditions possible in asyncio?
Asyncio coroutines run in a single thread. Within the event loop, only a single coroutine can run at a time.
Unlike Python threads, we have a lot of control over when asyncio coroutines run. Specifically, a coroutine is only suspended and yields to other coroutines when we explicitly await an awaitable.
Therefore, the types of race conditions we may see with Python threads and processes where the operating system controls the time and duration of context switches will not be seen with coroutines.
Are race conditions possible in asyncio?
I’ll give you a hint.
The API provides a mutex lock via the asyncio.Lock class. The lock is only present to ensure that execution of blocks of code is executed in a mutually exclusive manner among coroutines.
You can learn more about mutex locks in asyncio in the tutorial:
Yes, race conditions are possible in asyncio.
Let’s dig into this further.
Asyncio Race Conditions with Shared Memory
Coroutines can suffer race conditions with shared memory used among multiple coroutines.
They are just more obvious than similar race conditions with Python threads.
For example, a common race condition with Python threads involves when adding or subtracting a value from an integer.
1 2 3 |
... # add a value to an integer value += 1 |
This is a possible source of race conditions because this expression is in fact at least three Python bytecode instructions:
- Read the current value of the variable.
- Calculate a new value for the variable.
- Write a new value for the variable.
A Python thread may be context switched by the operating system in the middle of these operations, leaving the value in an inconsistent state.
You can see examples of this in the tutorial:
Race conditions with this expression are not possible with asyncio coroutines.
The reason is that a coroutine is only context-switched when we explicitly suspend via the await expression or similar expressions such as “async for” or “async with“.
Nevertheless, this type of race condition can happen in an asyncio program, as long as we space out the operations and suspend the coroutine between or more of the operations.
For example:
1 2 3 4 5 6 7 8 9 10 11 |
... # retrieve the value tmp = value # suspend for a moment await asyncio.sleep(0) # update the tmp value tmp = tmp + 1 # suspend for a moment await asyncio.sleep(0) # store the updated value value = tmp |
Executing this block among multiple coroutines can result in a race condition.
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.
Asyncio Race Conditions with Shared Resources
Coroutines can suffer race conditions when using a shared resource.
This is perhaps more common and more accidental in practice.
We see this when multiple threads share an I/O resource such as a file handle or stream handle. Multiple threads reading or writing from the same handle result in data loss and corruption. This even applies to simple streams like standard input and standard output.
We can suffer similar problems when sharing I/O resources in asyncio programs among multiple coroutines.
In this case, an I/O source resource is typically a socket.
A single socket connection is a connection from one process (or computer) to another and both parties generally expect it to operate serially with send and receive commands.
Nevertheless, if multiple coroutines race to operate on the same resource concurrently, then the data read from the socket may be unexpected, incomplete, or corrupted. Messages sent to the resource may be out of order, resulting in confusing the remote party.
Next, let’s look at a concrete example of a race condition between coroutines in asyncio.
Overwhelmed by the python concurrency APIs?
Find relief, download my FREE Python Concurrency Mind Maps
Example of an Asyncio Race Condition
We can explore a race condition with multiple coroutines.
Although contrived, this example is indicative of a broader class of races among coroutines for a shared resource.
We can define a task coroutine that simply increments a shared global variable. It does this in a multi-step manner, forcing a sleep and suspension between each step to allow other coroutines to run.
The task() coroutine below implements this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# task that operates on a shared variable async def task(): # declare global variable global value # retrieve the value tmp = value # suspend for a moment await asyncio.sleep(0) # update the tmp value tmp = tmp + 1 # suspend for a moment await asyncio.sleep(0) # store the updated value value = tmp |
Next, in the main coroutine, we can declare the global variable and initialize it with a value of 0.
We can then create 10,000 instances of the coroutine to update the variable and execute them all concurrently.
Finally, the value of the global variable is reported.
1 2 3 4 5 6 7 8 9 10 11 12 |
# main coroutine async def main(): # declare the global variable global value # define the global variable value = 0 # create many coroutines to update the global sate coros = [task() for _ in range(10000)] # execute all coroutines await asyncio.gather(*coros) # report the value of the counter print(value) |
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 |
# SuperFastPython.com # example of an asyncio race condition with shared memory import asyncio # task that operates on a shared variable async def task(): # declare global variable global value # retrieve the value tmp = value # suspend for a moment await asyncio.sleep(0) # update the tmp value tmp = tmp + 1 # suspend for a moment await asyncio.sleep(0) # store the updated value value = tmp # main coroutine async def main(): # declare the global variable global value # define the global variable value = 0 # create many coroutines to update the global sate coros = [task() for _ in range(10000)] # execute all coroutines await asyncio.gather(*coros) # report the value of the counter print(value) # entry point asyncio.run(main()) |
Running the example first creates the main() coroutine and executes it as the entry point of the program.
The main() coroutine runs and initializes the global variable.
The 10,000 coroutines are then created and awaited.
Each coroutine runs, copying the value of the global variable, suspending, updating the temporary variable, sleeping, then storing the updated variable back into the global variable.
All the coroutines finish and the main() coroutine reports the value of the global variable.
The value is one, instead of the expected 10,000.
1 |
1 |
What happened?
We might have expected each coroutine to execute as an atomic block, but it didn’t.
Each coroutine was suspended (like a context switch) two times, once after copying the global variable, and once after incrementing the temporary variable.
The first coroutine runs and suspends at the first point, the second coroutine runs and suspends at the first point, and so on. This continues on for all 10,000 coroutines.
They have all copied the current value of the global variable of zero into the temporary value.
The first coroutine resumes and increments the temporary variable from zero to one then suspends. The second coroutine does the same, and so on for all 10,000 coroutines.
They all have a value of one in their temporary variables.
Finally, the first coroutine copies the temporary variable into the global variable. The second coroutine does the same and so on for all 10,000 coroutines.
The global variable is set to one each time.
Therefore, when the main() coroutine reports the value of the global variable, it is one, instead of the expected 10,000.
Example Fixing an Asyncio Race Condition
We can achieve the desired result by making the content of the task() coroutine an atomic block using a mutex lock.
This means that only one coroutine can hold the lock and execute the content of the coroutine at a time and the global variable is updated sequentially by coroutines as was intended.
The updated task() coroutine with this change is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# task that operates on a shared variable async def task(lock): # acquire the lock async with lock: # declare global variable global value # retrieve the value tmp = value # suspend for a moment await asyncio.sleep(0) # update the tmp value tmp = tmp + 1 # suspend for a moment await asyncio.sleep(0) # store the updated value value = tmp |
The complete example with this change 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 |
# SuperFastPython.com # example of an asyncio race condition with shared memory import asyncio # task that operates on a shared variable async def task(lock): # acquire the lock async with lock: # declare global variable global value # retrieve the value tmp = value # suspend for a moment await asyncio.sleep(0) # update the tmp value tmp = tmp + 1 # suspend for a moment await asyncio.sleep(0) # store the updated value value = tmp # main coroutine async def main(): # declare the global variable global value # define the global variable value = 0 # create the shared lock lock = asyncio.Lock() # create many coroutines to update the global sate coros = [task(lock) for _ in range(10000)] # execute all coroutines await asyncio.gather(*coros) # report the value of the counter print(value) # entry point asyncio.run(main()) |
The example runs as before.
This time, the lock is passed to each task coroutine and must be acquired before the body is executed.
Only a single coroutine can acquire the lock at a time, acquiring the lock is mutually exclusive. All other coroutines must wait for the lock to be released, remaining suspended.
The single coroutine updates the global variable and releases the lock. The next coroutine acquires the lock and all other coroutines remain suspended, and so on.
The final value of the global variable is reported and has the expected value of 10,000.
This highlights how we can fix an asyncio race condition with shared data.
1 |
10000 |
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 about asyncio race conditions with coroutines in Python.
Do you have any questions?
Ask your questions in the comments below and I will do my best to answer.
Photo by Alison Ivansek on Unsplash
Do you have any questions?