Race condition
In short: A race condition is a bug where the result depends on how threads interleave: left -= 1 is three steps even with the GIL, so two threads can lose a sale.
Problem
The sale counts wrong
Friday, flash sale. Headphones are half price and the orders pour in. Two tills sell from one warehouse: each sale subtracts 1 from the stock.
The tills are two threads. Each sells 10,000 items and callssell() for each one. sell reads the stock, calls time.sleep(0) (let the other threads run for a moment) and only then writes. Why time.sleep(0) is needed is further down:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. read time.sleep(0) # the other thread gets in here after = seen - 1 # 2. subtract 1 self.left = after # 3. writestock = Stock(20_000)
def checkout(): for _ in range(10_000): stock.sell()
a = threading.Thread(target=checkout)b = threading.Thread(target=checkout)a.start()b.start()a.join()b.join()print(f"Orders: {2 * 10_000}")print("Sold according to the stock:")print(20_000 - stock.left)Customers have paid for 20,000 orders. Here is how many sales the warehouse counts over five runs:
Sold according to the stock: 10000Sold according to the stock: 10000Sold according to the stock: 10000Sold according to the stock: 10000Sold according to the stock: 10000The idea
self.left -= 1 is three steps
self.left -= 1 is one line, but Python does it in three steps. In sell they are written out, and time.sleep(0) sits exactly between the read and the write:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. read time.sleep(0) # the other thread gets in here after = seen - 1 # 2. subtract 1 self.left = after # 3. writeStep by step, in words
- 5 items are left. Till A and till B each sell one at the same moment.
- Till A reads the stock: 5.
- time.sleep(0) lets the other thread in. Till B reads before A has written: also 5.
- Till A subtracts 1 in its own variable: 4.
- Till B does the same from its own 5: also 4.
- Till A writes 4. One sale, 4 left: correct so far.
- Till B writes 4 too, over the 4 of A. Two sales, and the stock is 4, not 3.
- One sale is lost: both read the same number before either wrote. That is a race condition, and the stock now shows one item that is already sold.
- Read the stock into your own variable.
- Subtract 1 from your copy.
- Write the copy back.
Two threads can take turns between any two steps. If both read 5 before either writes, both write 4. Two sales, and the stock goes down by one.
When the result depends on which thread does which step first, that is a race condition. The three steps "read, change, write" on shared data are its most common form.
The GIL does not save you. It lets only one thread run Python code at a time, but it can switch threads between the two steps. time.sleep(0) makes the switch happen exactly there. Without it the switch is still possible, just rarer (see below).
More precisely, if you are curious
The dis module shows the instructions Python runs for self.left -= 1. This is Python 3.12, which the code is checked on:
import dis
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # read, subtract 1, write
for step in dis.get_instructions(Stock.sell): print(f"{step.opname} {step.argrepr}".strip())RESUMELOAD_FAST selfCOPYLOAD_ATTR leftLOAD_CONST 1BINARY_OP -=SWAPSTORE_ATTR leftRETURN_CONST NoneLOAD_ATTR left reads, BINARY_OP -= subtracts, STORE_ATTR left writes. Three separate instructions: between any two the GIL can hand over to another thread.
Try it
Three tills, 5 in stock
Here are three tills, interleaved by hand in one of the possible orders. Each reads into its own variable, then writes 1 less:
left = 5seen_a = left # A readsseen_b = left # B readsleft = seen_a - 1 # A writesseen_c = left # C readsleft = seen_b - 1 # B writesleft = seen_c - 1 # C writesprint("Sales: 3")print(f"In stock: {left}")A and B read 5. A writes 4. C reads the 4 that was just written. B writes 4 (its own 5 minus 1), and C writes 3. Three sales, and the stock is 3 instead of 2: only B's sale got lost, because B read before A wrote.
The idea
"It works on my machine"
The same program, with fewer sales per till:
In Python, try it without time.sleep(0): sell is just self.left -= 1, and each till sells 100,000:
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # read, subtract 1, writestock = Stock(200_000)
def checkout(): for _ in range(100_000): stock.sell()
a = threading.Thread(target=checkout)b = threading.Thread(target=checkout)a.start()b.start()a.join()b.join()print(f"Orders: {2 * 100_000}")print("Sold according to the stock:")print(200_000 - stock.left)Sold according to the stock: 200000Sold according to the stock: 200000Sold according to the stock: 200000Sold according to the stock: 200000Sold according to the stock: 200000sell or Python without the GIL (3.13) lose sales.That is what makes a race condition treacherous:
- the test passes on the laptop and fails on the server with 64 cores and thousands of customers;
- the bug comes "sometimes" and does not repeat when you look for it;
- add a print line, the order of the threads changes and the bug goes away.
A test without a loss proves nothing. Safety is proven by how the code is written: which data is shared and who protects it.
Problem
The last item
One item is left. Two buyers click "Buy" at the same moment. buy checks whether there is one, charges the card (about 100 ms) and only then takes the item:
class Stock: def __init__(self, left): self.left = left
def buy(self): if self.left > 0: # check: is there one? time.sleep(0.1) # the card is charged, ~100 ms self.left -= 1 # act: take the item return True return Falsestock = Stock(1)bought = []
def buyer(): bought.append(stock.buy())
first = threading.Thread(target=buyer)second = threading.Thread(target=buyer)first.start()second.start()first.join()second.join()print("In stock before: 1")print("Sold:")print(bought.count(True))print("In stock after:")print(stock.left)Sold: 2, in stock after: -1Sold: 2, in stock after: -1Sold: 2, in stock after: -1Sold: 2, in stock after: -1Sold: 2, in stock after: -1Both pass the check: while the first one pays, the second sees the same 1. Both paid, and the stock is at -1. Here the race condition happens every time, because there are a whole 100 ms between the check and the action.
This is the second form of a race condition: check, then act (check-then-act). The decision is made on a value that someone else can change before the decision is carried out. It is everywhere: "is there a free room", "is the username free", "is there money in the account".
Try it
Order numbers
Every order gets a number: 1001, 1002, 1003… The tills are threads and call take() at the same time. Sometimes two orders get the same number. After which line does the other thread have to come in for that to happen?
Pick the line with the bug.
After the read. If both threads read 1001 before either writes 1002, both return 1001. Two orders with one number: one will overwrite the other in the database or reach the wrong customer. It is the same "read, change, write".
The idea
When there can be a race condition
It takes three things at once:
- Shared data: something several threads can see (the stock, the counter, the number).
- At least one thread changes it. If everyone only reads, there is nothing to get wrong.
- Nothing orders the access: threads go into their steps whenever their turn comes.
Take away one of the three and there is no race condition:
- Don't share. Each thread counts in its own variable, and at the end the results are added up. That is how the 1000 invoices worked in "Why threads": each part computes its own.
- Don't change. An object nobody changes can be read by as many threads as you like. That is the lesson "Atomic operations and immutable objects".
- Order the access. Only one thread at a time in the three steps. That is the lock, the next lesson: "Locking".
More precisely, if you are curious
There is also a quieter problem: visibility. In Python with the GIL every thread sees the last written value, so this problem hardly shows up here. In Python without the GIL the rules are like in Java. The lock solves this too: whatever one thread wrote before releasing it, the next one sees after taking it.
How it is in other languages: in Java and C# count++ loses just like here. In Python it loses less often because of the GIL, but it loses. In JavaScript it loses between Workers on a SharedArrayBuffer, and on one thread a race condition exists only between two awaits.
Exercise
The sale's revenue
Solve the task in your editor.
The revenue. Two tills sell 10,000 headphones each at €3. Each till counts its sales in its own variable, and the revenue is shared: a Revenue object with a method add(euros) that does total += euros. In add, read, call time.sleep(0) and only then write. Print each till's sales and the revenue. Run the program several times.
Till 1: 10000 salesTill 2: 10000 salesRevenue (€):60000The tills' sales are right on every run: each counts in its own variable. The revenue is smaller and different every time. The fix is in the next lesson.
More tasks
- How much is lost. Run the sale with 2, 4 and 8 tills. Is more lost with more tills?
- Find what is shared. Take a program with threads you have written and list the data that more than one thread touches. Which of them does some thread change? - Free room. Write a hotel with a method "book room 12 if it is free" and run two guests at once, with a 100 ms pause between the check and the write. How many guests get the room?
Test
Test yourself
Related lessons
Section titled "Related lessons"- Builds on: Lists: append, remove and sort · Lambdas · Threads · Why threads
- Continues in: Locking · Atomic operations and immutable objects · Deadlock · Producer and consumer
- Tasks on this topic: Concurrency and race conditions