In this article, we’ll briefly compare async/await implementations in Python with C#, and then discuss common race conditions that still occur even when using async/await.

Async/Await in Python vs C#

In Python, all coroutines are run a single thread (the event loop thread), which means that there is absolutely no risk of data races (of course, there is still the risk of other race conditions, which we’ll talk about later).

In C#, different coroutines may run simultaneously on different threads. This is because coroutines in C# run in a thread pool. So you can have data races.

Common Race Conditions

Let’s assume you are using async/await in an environment where you can’t have data race. What are race conditions that can still occur? These are basically all race conditions that are not data races lol. Let’s go over some of the most common, so that you can recognize/avoid them in your async/await code.

Check-then-Act

This is when you check a condition, and then act, but in between the check and the act, the condition may have changed (by another coroutine).

Here’s a classical bank account example:

async def withdraw(account, amount):
    if account.balance >= amount:  # Check
        await asyncio.sleep(0)  # Yield control to the event loop (other coroutines may run here)
        account.balance -= amount 

account.balance = 120
await asyncio.gather(withdraw(account, 100), withdraw(account, 100)) # may succeed and leave account.balance = -80

Two coroutines can both get past the check, withdraw money, and you may end up with a negative balance.

To avoid this, make sure both the check and the act are done atomically. You can do this either by not putting an await between them, or by using a lock (asyncio.Lock in Python).

Read-Modify-Write

This is when you read a value, modify it, and then write it back. If another coroutine modifies the value in between the read and the write, you may end up with an incorrect value.

Here’s an example:

async def increment(counter):
    value = counter.value  # Read
    await asyncio.sleep(0)  # Yield
    counter.value = value + 1  # Write

counter.value = 0
await asyncio.gather(increment(counter), increment(counter))  # may end up with counter.value = 1

Again, to avoid this, you can either not yield control between the read and the write, or use a lock.

Event Ordering Dependencies

This is when you are depending on the order of execution of your coroutines. The problem is, in Python, this order is not guaranteed.

The best solution here is to avoid depending on the order of execution of coroutines. If you must, you can use asyncio.Event to signal when a coroutine has completed its work, and then wait for that event in the other coroutines.

Lock Starvation

This is when a coroutine is waiting for a lock, but other coroutines keep acquiring the lock. In Python (and in underlying Operating Systems), lock acquiring is not fair, meaning that the one waiting the longest may not be the one to acquire the lock next. Thus anytime you use a lock, you should be aware of the possibility of starvation.

Javascript Note

Both Python and Javascript use a single-threaded event loop for async/io, so in Javascript as well, you don’t have to worry about data races.