All Courses
Advanced Python

Python Threading

Setup

import threading
from threading import Lock, Thread
from time import sleep

⚠️ Never name your file threading.py — it will override the built-in module and cause a circular import error.


Creating and Starting a Thread

def run(content):
    print(content)

thread1 = Thread(target=run, args=("Hello",))   # args must be a tuple
thread1.start()                                  # activates the thread
  • target — the function the thread will run (no parentheses)
  • args — a tuple of arguments to pass (add trailing comma for single-arg tuples: ("value",))
  • The thread does nothing until .start() is called

The Main Thread

Every Python program always has at least one thread: the main thread. Any threads you create are additional threads attached to the same Python process.

print(threading.active_count())   # number of currently active threads

Thread Execution Order is Not Guaranteed

Just because you start Thread 1 before Thread 2 doesn't mean Thread 1 finishes first. The OS scheduler decides which thread runs when.

thread1 = Thread(target=run, args=(1,))
thread2 = Thread(target=run, args=(2,))
thread1.start()
thread2.start()
# Output order: unpredictable

thread.join() — Waiting for a Thread

Blocks execution until the thread finishes:

thread1.start()
thread2.start()
thread1.join()   # wait for thread1 to finish
thread2.join()   # wait for thread2 to finish
print("Both done")

Waiting on multiple threads (prerequisites pattern)

def start_game(prerequisites):
    print("Waiting to start game...")
    for t in prerequisites:
        t.join()          # wait for each prerequisite thread
    print("Game started!")

load_assets_thread = Thread(target=load_assets)
load_player_thread = Thread(target=load_player)
start_game_thread = Thread(target=start_game, args=([load_assets_thread, load_player_thread],))

load_assets_thread.start()
load_player_thread.start()
start_game_thread.start()   # starts after others — join prevents it running too early

⚠️ You cannot join() a thread before it has been started — always start threads before joining them.


Locks (Mutex)

A lock (also called a mutex — mutually exclusive flag) ensures only one thread can proceed past a certain point at a time.

mutex = Lock()

mutex.acquire()   # wait until the lock is available, then claim it
# ... protected code ...
mutex.release()   # release so another thread can acquire it
  • If Thread 2 tries to acquire() a lock already held by Thread 1, Thread 2 waits
  • Thread 2 can only proceed once Thread 1 calls release()

Basic lock example

def t1(lock):
    print("Starting T1")
    lock.acquire()
    sleep(1)
    print("T1")
    lock.release()

def t2(lock):
    print("Starting T2")
    lock.acquire()
    sleep(1)
    print("T2")
    lock.release()

lock = Lock()
Thread(target=t1, args=(lock,)).start()
Thread(target=t2, args=(lock,)).start()
# Output: Starting T1, Starting T2, T1, T2 (in order)

Using Two Locks to Synchronize Thread Order

To guarantee alternating execution between two threads (e.g. print 1, 2, 3, 4, 5 in order from two threads):

def print_values(name, values, start_lock, end_lock):
    for item in values:
        start_lock.acquire()
        print(item)
        end_lock.release()

lock1 = Lock()
lock2 = Lock()
lock2.acquire()   # pre-acquire so thread2 waits until thread1 releases it

thread1 = Thread(target=print_values, args=("T1", [1, 3, 5], lock1, lock2))
thread2 = Thread(target=print_values, args=("T2", [2, 4],   lock2, lock1))

thread1.start()
thread2.start()
# Guaranteed output: 1 2 3 4 5

How it works: - Thread 1 acquires lock1 (free) → prints → releases lock2 → Thread 2 can now run - Thread 2 acquires lock2 (now free) → prints → releases lock1 → Thread 1 can now run - They alternate without needing any sleep() delays


Deadlocks

A deadlock occurs when two (or more) threads are each waiting for the other to release a lock — so nothing can proceed.

# Thread 1 waits for Thread 2, Thread 2 waits for Thread 1
# Neither can finish → program hangs silently forever
  • No error is raised — the program just freezes
  • Avoid by carefully managing lock acquisition order
  • Never have Thread A wait for Thread B while Thread B is also waiting for Thread A

Joining Threads at End of Program

Always join your threads at the end of a module to prevent unexpected interleaving if the module is imported:

t1 = Thread(target=run)
t2 = Thread(target=run)
t1.start()
t2.start()
t1.join()   # ensures both threads finish before control returns
t2.join()   # to wherever this module was imported from

Without joins, if this file is imported, the importing thread may execute before these threads finish — causing interleaved or out-of-order output.


Key Takeaways & Recap

Concept Summary
Thread(target=fn, args=(...,)) Create a thread; args must be a tuple
.start() Activates the thread — nothing runs until this is called
.join() Blocks until the thread finishes
threading.active_count() Returns number of currently active threads
Lock / Mutex Ensures only one thread executes a section at a time
.acquire() Claim the lock — waits if already held by another thread
.release() Free the lock so another thread can acquire it
Two-lock pattern Guarantees alternating thread execution without delays
Deadlock Two threads waiting on each other — program hangs silently
Join at end Always join threads at module end to prevent import-related race conditions