Threading

Python 3.12 ยท โœ“ verified by execution on 2026-07-30

Threading in Python allows you to run multiple operations concurrently in a single process. However, the presence of the Global Interpreter Lock (GIL) fundamentally changes how Python threads behave compared to true parallel threads in other languages like C++ or Java.

In this lesson, we will explore how to manage concurrency, overcome I/O wait times, and write thread-safe logic using Locks.

Threading vs Multiprocessing

Multiprocessing โ€” real parallelism Threading โ€” one GIL Process 1 Process 2 Process 1 running running GIL Thread 1 runs Thread 2 waits
Multiprocessing gives each process its own interpreter and its own core. Threads share one process โ€” and one GIL, so only one thread runs Python bytecode at a time.

Threading Basics

You give a Thread its work in one of two ways: pass a callable as target=, or subclass Thread and override run(). The first is almost always the simpler choice.

Creating, starting, and joining a thread.

python
import threading
import time

def worker(num):
    print(f'Worker {num} started')
    time.sleep(0.01)
    print(f'Worker {num} finished')

t = threading.Thread(target=worker, args=(1,))
t.start()
print('Main thread waiting')
t.join()
print('Main thread done')
Output
Worker 1 started
Main thread waiting
Worker 1 finished
Main thread done

The ThreadPoolExecutor and I/O tasks

Threading shines for I/O bound tasks like network requests. Modern Python provides the ThreadPoolExecutor.

python ยท visualize
import time
workers = 1

def io_task(i):
    print(f"Task {i} started")
    time.sleep(0.5)

print(f"Running with {workers} workers")

Adjust the slider above to see how increasing worker threads drastically reduces overall completion time when tasks are waiting on I/O!

Shared Memory & Race Conditions

All threads live inside one process and share the same memory. That is what makes them cheap to start and easy to pass data between โ€” and simultaneously what makes them dangerous, because two threads can reach the same variable at once.

Threads share memory and can modify the same variables. Note: in Python 3.10+ due to GIL optimizations, a simple += 1 might not always trigger a visible race condition with small loop counts, but it is fundamentally unsafe without a lock.

python
import threading

counter = 0

def increment():
    global counter
    for _ in range(10000):
        counter += 1

threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(f'Counter is roughly {counter}')
Output
Counter is roughly 20000

Using Locks (Context Managers)

Use a Lock as a context manager โ€” with lock: โ€” rather than calling acquire() and release() by hand. The with form releases even when the block raises; a manual release() on the error path is the classic source of a hung program.

Using a Lock as a context manager to synchronize access to shared state.

python
import threading

counter = 0
lock = threading.Lock()

def safe_increment():
    global counter
    for _ in range(10000):
        with lock:
            counter += 1

threads = [threading.Thread(target=safe_increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(f'Safe counter is exactly {counter}')
Output
Safe counter is exactly 20000

Check yourself

What does the GIL prevent?

Reveal answer

Two threads executing Python bytecode simultaneously โ€” Only one thread runs Python bytecode at a time. Threads still exist and interleave โ€” they just do not run Python in parallel.

Which workload does threading genuinely speed up?

Reveal answer

I/O-bound work, where threads sit waiting on network or disk โ€” The GIL is released while waiting on I/O, so other threads run. For CPU-bound work you need multiprocessing.

Why is counter += 1 unsafe across threads?

Reveal answer

It is read-modify-write, so a thread can be interrupted between the read and the write โ€” The statement is three operations. Interleaving them loses updates, which is precisely what a Lock prevents.

What is the safest way to use a threading.Lock?

Reveal answer

Use it as a context manager: with lock: โ€” with lock: releases even when an exception is raised. A manual release() is skipped on the error path, which deadlocks the program.

Challenges

Challenge 1 +100 XP

Write a function `run_in_background(func, arg)` that takes a function and a single argument, spawns a new thread to run `func(arg)`, starts the thread, and returns the thread object without waiting for it to finish.

python
  • Test 1 โ€” expects "Hello\nTrue\n"
Need a hint? (โˆ’25% XP)

Don't forget the comma in `args=(arg,)` to make it a tuple!

Show solution (0 XP)
import threading

def run_in_background(func, arg):
    t = threading.Thread(target=func, args=(arg,))
    t.start()
    return t

def print_msg(m): print(m)
t = run_in_background(print_msg, 'Hello')
t.join()
print(type(t) is threading.Thread)

Challenge 2 +100 XP

Complete the `BankAccount` class by adding a threading lock so that `deposit` and `withdraw` are thread-safe. Initialize the lock in `__init__` and use it as a context manager.

python
  • Test 1 โ€” expects "True\n"
Need a hint? (โˆ’25% XP)

Create `self.lock = threading.Lock()` and use `with self.lock:` around the state modifications.

Show solution (0 XP)
import threading

class BankAccount:
    def __init__(self, balance):
        self.balance = balance
        self.lock = threading.Lock()
        
    def deposit(self, amount):
        with self.lock:
            self.balance += amount
            
    def withdraw(self, amount):
        with self.lock:
            self.balance -= amount

b = BankAccount(100)
b.deposit(50)
b.withdraw(20)
print(b.balance == 130)