Threads and Race Conditions
25 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A program often waits: for a file, a server, a user. Threads let other work go on during that wait. They also share memory, which brings a new kind of bug.
Start a thread and wait for it
Thread is a separate line of execution inside one process. All threads of a process share the same memory, so they can read and change the same variables.
A thread is a second line of work inside your program. start() begins it, and join() makes the caller wait until it ends.
import threading
def greet(name, out): out.append(f"hello {name}")
out = []t = threading.Thread(target=greet, args=("Asha", out))t.start()t.join()print(out)Use join() before you read what the thread produced.
Example 1 · Threads that fill separate keys
import threading
totals = {}
def total_for(region, sales): totals[region] = sum(sales)
jobs = {"north": [120, 80, 50], "south": [200, 40], "east": [75, 75, 75]}threads = [threading.Thread(target=total_for, args=(r, s)) for r, s in jobs.items()]for t in threads: t.start()for t in threads: t.join()print(dict(sorted(totals.items()))){'east': 225, 'north': 250, 'south': 240}What this code does
- Each thread writes its own key, so they never touch the same data.
- All threads are started first and joined after, so they overlap.
- The dictionary is sorted before printing, so the order of finishing does not show.
target is the function to run and args are its arguments. Without join(), the print could run before the thread has added anything.
Shared data and the race condition
Race condition is a bug where the result depends on which thread runs first. It appears when threads read and change shared data without any coordination.
Threads share variables. If two threads read a value, then both write back a changed copy, one change is lost.
The picture shows the order that causes it.
Example 2 · A lost update, made certain
import threading
balance = 0barrier = threading.Barrier(2)
def deposit(): global balance seen = balance # read barrier.wait() # both threads have read before either writes balance = seen + 100 # write
threads = [threading.Thread(target=deposit) for _ in range(2)]for t in threads: t.start()for t in threads: t.join()print(balance)100
What this code does
- Two deposits of 100 should give 200. The barrier forces both threads to read 0 before either writes.
- Real programs hit this by chance, not on purpose. The barrier only makes the bad order happen every run.
- Both write
0 + 100, so one deposit is lost.
The bug depends on timing, so it may pass a hundred runs and then fail in production. That is what makes it hard to find.
Fix it with a Lock
Lock is a tool that only one thread can hold at a time. A thread that asks for a held lock waits until the holder releases it.
A lock lets one thread at a time into a block of code. Wrap the whole read-change-write in with lock:.
import threading
balance = 0lock = threading.Lock()
def deposit(times): global balance for _ in range(times): with lock: balance += 1Other threads wait at the with line until the lock is free.
Example 3 · The same work with a Lock
import threading
balance = 0lock = threading.Lock()
def deposit(times): global balance for _ in range(times): with lock: balance += 1
threads = [threading.Thread(target=deposit, args=(1000,)) for _ in range(4)]for t in threads: t.start()for t in threads: t.join()print(balance)4000
What this code does
- Four threads each add 1 a thousand times. The lock makes each
+= 1one uninterrupted step. - The total is always 4000. Without the lock it can be lower, because
+=is a read, an add and a write.
Keep the locked block small. A long block makes every other thread wait. If two threads each hold one lock and wait for the other, both wait forever; that is a deadlock.
Pass work through a Queue
Thread-safe queue is a queue several threads can use at once without corrupting it. In Python it is queue.Queue; it locks inside, and get waits when it is empty.
A queue lets threads hand work to each other without sharing variables. Producers put items. Workers get them.
import queue
tasks = queue.Queue()tasks.put(5)tasks.put(None) # end markerprint(tasks.get(), tasks.get())A special end marker, often None, tells a worker to stop.
Example 4 · Workers fed by a Queue
import queueimport threading
tasks = queue.Queue()results = []
def worker(): while True: n = tasks.get() if n is None: break results.append(n * n)
threads = [threading.Thread(target=worker) for _ in range(2)]for n in range(1, 6): tasks.put(n)for _ in threads: tasks.put(None) # one stop marker per workerfor t in threads: t.start()for t in threads: t.join()print(sorted(results))[1, 4, 9, 16, 25]
What this code does
- Two workers share one queue. Which worker takes which number can change from run to run.
- The results are sorted, so the printed list is always the same.
- Each worker stops when it reads its own
None.
queue.Queue does its own locking, so you do not add a lock around put and get.
Next step
Check your understanding
10 quick questions