Python Race Conditions: What are they & How to prevent them

In this Python tutorial we will discuss the synchronization problem known as “Race Conditions”. We will also explore various solutions to them through the use of Thread Locks and Semaphores.


What are Race Conditions?

When we are using multiple threads in our program, we need to be careful in how they interact with each other. When multiple threads share a resource, such as a variable there can be synchronization problems.

For example, let’s say we have a “variable x” with a value of hundred. If there are two threads reading data from “x”, then there is no problem. This is perfectly safe as the value of “x” is not being modified in any way.

However, when either one of the two threads, or maybe even both, attempt to modify the value of “x” a synchronization problem known as a “race condition” can occur.

Using Locks to prevent Race Conditions in Python

In the above diagram, we are illustrating how two threads are attempting to access and modify the value of “x” by adding/subtracting 10 to/from it. Can you guess what the possible answer will be?

The answer is that there are actually three possible answers, out of which only one is the “correct answer”.

The above image shows the three possible values of X after both threads have finished execution.

How is this possible?


How do Race Conditions occur?

Before we discuss how this occurs, you need to understand that even a simple operation like X = X + 10, comprises of multiple instructions.

We can break down the above operation into three instructions.

  1. Read the value of X and store it in a register
  2. Add 10 into the value in the register
  3. Update the value of X

With this knowledge in mind, let’s discuss how it’s possible for the value of “x” to be 90. (In the earlier example with two threads)

  1. Initial value of X is 100
  2. Thread#1 reads the value 100 and saves it locally
  3. Thread#1 updates local value to 110
  4. Thread#2 reads the value 100 and saves it locally
  5. Thread#1 updates X with the value 110
  6. Thread#2 updates local value to 90
  7. Thread#2 updates X with the value 90

Hence, the final answer is 90. The answers vary because there is no fixed order to how the above events execute. If step4 occurs after step5, the final answer will be 100 for example.

Can you figure out how the final value of “x” may become 110?


Race Conditions in Python – Example

Here is an actual example where we can observe race conditions removing the output.

The code features two functions. Function# 1 increments by one the variable “x” 1000000 times. Function#2 decrements the variable “x” 1000000 times. If we call both functions, the final answer should be logically be zero right? Let’s see what happens.

from threading import Thread, Semaphore
from time import sleep

x = 0

def add_one():
    global x
    for i in range(1000000):
        x += 1

def subtract_one():
    global x
    for i in range(1000000):
        x -= 1      

thread1 = Thread(target=add_one)
thread2 = Thread(target=subtract_one)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print(x)

We ran the code 5 times, and listed down the outputs below. As expected, they do not give us the right answer.

-13497
-230960
153609
-445308
106004

There is actually a very miniscule 0.00001% something chance of getting the right answer here if we run the code enough times.

Note: We had the increase the number of iterations significantly otherwise thread#1 would have completed it’s execution before thread#2 even began executing. Modern computers are very fast after all!


Preventing Race Conditions with Thread Locks

Let’s take a look at the previous code example and implement locks to get the correct answer.

from threading import Thread, Lock
from time import sleep

lock = Lock()
x = 0

def add_one(lock):
    global x
    for i in range(1000000):
        lock.acquire()
        x = x + 1
        lock.release()

def subtract_one(lock):
    global x
    for i in range(1000000):
        lock.acquire()
        x = x - 1      
        lock.release()

thread2 = Thread(target=subtract_one, args = (lock,))
thread1 = Thread(target=add_one, args = (lock,))

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print(x)

The above code features three main components.

First is the Lock Class, used to create a lock. This “lock” serves as a sort of “permission slip”. In order to access the shared resource “x”, a thread needs this permission slip, so it tries to acquire() it. If no-one is currently using the permission slip, it will acquire() it successfully otherwise it needs to wait until the other thread releases() it.

Did that explanation make sense? Now look at the above code again with this explanation in mind.


There is actually going to be some significant overhead here because we called acquire() and release() so many times. You will notice that this code takes some time to actually execute. This is not really a problem though as this code has no practical use (was just for demonstration purposes, no one writes such redundant code).


Using Semaphores

Another solution to Race Conditions in Python is a Semaphore. There is little difference in Python between a Semaphore and Thread Lock. In fact, the Semaphore is often called an advanced version of a Thread Lock as it has all of it’s features with the addition of one. (we’ll talk about that later)

You can see from the below example, that we only swapped Lock() with Semaphore(). The rest of the code is the same.

There is one more change we made, just to show you that you don’t have to pass “lock” as an argument. You can also make it global to the same effect. It’s upto you whichever you want to use.

from threading import Thread, Semaphore
from time import sleep

lock = Semaphore()
x = 0

def add_one():
    global x, lock
    for i in range(1000000):
        lock.acquire()
        x = x + 1
        lock.release()

def subtract_one():
    global x, lock
    for i in range(1000000):
        lock.acquire()
        x = x - 1      
        lock.release()

thread2 = Thread(target=subtract_one)
thread1 = Thread(target=add_one)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print(x)

The new feature in a Semaphore is called a “counting semaphore”. By passing in a number “n” into the parameter we can allow “n” number of threads to access the resource. It isn’t used to prevent Race Conditions though, it has a different set of uses.

You can learn more about Semaphores and their uses here.


This marks the end of the Python Race Conditions Tutorial. Any suggestions or contributions for CodersLegacy are more than welcome. Questions regarding the tutorial content can be asked in the comments section below.

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments