Skip to content

threading Module Complexity

The threading module runs Python code in operating-system threads and provides the locks, conditions, semaphores, events and barriers that coordinate them. Starting a thread costs an OS thread and its stack. Event, Semaphore and Barrier are built on a Condition, which keeps a queue of the threads waiting on it.

Every blocking call costs the time it spends blocked, w: the wait for another thread, or the timeout. u is the operating system's thread start-up latency, s is the stack size of a new thread, q is the number of threads queued on the primitive, T is the number of live threads, n is the count passed to notify(n) or release(n), and f is a function the caller supplies, whose time and space are its own. For the iterator wrappers, g is one step of the wrapped iterator, k is the number of copies concurrent_tee() makes, and d is how many items the furthest-ahead copy has taken that the furthest-behind one has not. A wait whose timeout expires removes itself from the primitive's waiter queue, which is the O(q) term in the wait rows.

Complexity Reference

Thread

Operation Time Space Notes
Thread(group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None) O(1) O(1) Nothing runs, and enumerate() does not list it, until start()
Thread.start() O(u) O(s) Returns once the new thread is running and has stored its ident
Thread.run() O(f) O(f) Calls the target once, or nothing without one; start() runs it in the new thread, a direct call in the caller's
Thread.join(timeout=None) O(w) O(1) Immediate on a finished thread; a timeout that expires returns with the thread still alive
Thread.is_alive() O(1) O(1)
Thread.name, Thread.ident, Thread.native_id, Thread.daemon O(1) O(1) ident and native_id are None until started
Thread.getName(), Thread.setName(), Thread.isDaemon(), Thread.setDaemon() O(1) O(1) Deprecated aliases; each call emits a DeprecationWarning

Timer

Operation Time Space Notes
Timer(interval, function, args=None, kwargs=None) O(1) O(1) A Thread subclass; start() spawns the thread
Timer.run() O(w + f) O(f) Waits the interval, then calls the function once
Timer.cancel() O(1) O(1) A timer still waiting ends at once instead of after the interval; one already in its function finishes it

Lock and RLock

Operation Time Space Notes
Lock(), RLock() O(1) O(1)
Lock.acquire(blocking=True, timeout=-1) O(w) O(1) Immediate when free
Lock.release() O(1) O(1) Raises RuntimeError when the lock is not held
Lock.locked() O(1) O(1)
RLock.acquire(blocking=True, timeout=-1) O(w) O(1) Immediate for the owner, which only increments a count
RLock.release() O(1) O(1) Frees the lock only when the count returns to zero
RLock.locked() O(1) O(1) Python 3.14+

Condition

Operation Time Space Notes
Condition(lock=None) O(1) O(1) Wraps the lock given, or a new RLock
Condition.acquire(), Condition.release() O(w) O(1) The underlying lock's own methods
Condition.locked() O(1) O(1) Python 3.14+; the underlying lock's
Condition.wait(timeout=None) O(w + q) O(1) Releases the lock, waits, re-acquires
Condition.wait_for(predicate, timeout=None) O(w + q + c·f) O(f) c = predicate calls: one before waiting and one per wakeup
Condition.notify(n=1) O(n) O(1) Wakes the n oldest waiters
Condition.notify_all() O(q) O(1)
Condition.notifyAll() O(q) O(1) Deprecated alias; each call emits a DeprecationWarning

Semaphore and BoundedSemaphore

Operation Time Space Notes
Semaphore(value=1), BoundedSemaphore(value=1) O(1) O(1)
Semaphore.acquire(blocking=True, timeout=None) O(w + q) O(1) Immediate while the counter is positive
Semaphore.release(n=1) O(n) O(1) Adds n to the counter and wakes up to n waiters
BoundedSemaphore.release(n=1) O(n) O(1) As Semaphore.release(), but raises ValueError past the initial value

Event

Operation Time Space Notes
Event() O(1) O(1)
Event.set() O(q) O(1) Wakes every waiter
Event.clear() O(1) O(1)
Event.is_set() O(1) O(1)
Event.isSet() O(1) O(1) Deprecated alias; each call emits a DeprecationWarning
Event.wait(timeout=None) O(w + q) O(1) Immediate once set

Barrier

Operation Time Space Notes
Barrier(parties, action=None, timeout=None) O(1) O(1)
Barrier.wait(timeout=None) O(w + q + f) O(f) The last of the parties runs the action, f, and wakes the others; a timeout that expires breaks the barrier
Barrier.reset(), Barrier.abort() O(w + q) O(1) Wake every waiter; a party still waiting for the barrier to fill raises BrokenBarrierError. w includes a running action, which holds the barrier's lock
Barrier.parties, Barrier.n_waiting, Barrier.broken O(1) O(1)

local

Operation Time Space Notes
local() O(1) O(1)
local attribute access O(1) O(1) Each thread that touches the object gets its own attribute dict; the first access in a thread also re-runs a subclass's __init__ with the original arguments, at that __init__'s own cost

Iterator wrappers

Operation Time Space Notes
serialize_iterator(iterable) O(1) O(1) Python 3.15+. Calls iter() once and pairs the iterator with a new Lock; nothing is consumed yet
next() on a serialize_iterator, serialize_iterator.send(value), serialize_iterator.throw(...), serialize_iterator.close() O(w + g) O(g) Python 3.15+. One call at a time, through a non-reentrant lock; send(), throw() and close() raise AttributeError when the wrapped iterator lacks them
synchronized_iterator(func) O(1) O(1) Python 3.15+. Returns a wrapper that calls func and wraps each iterator it returns in a serialize_iterator; usable as a decorator on a generator function
concurrent_tee(iterable, n=2) O(k) O(k) Python 3.15+. Returns a tuple of k = n iterators sharing one lock; () for 0, ValueError for a negative n
next() on a concurrent_tee iterator O(w + g) for the copy that takes an item from the source; O(1) for a copy reaching an item already taken, or O(w) if it arrives while that item is being taken O(d) across the copies Python 3.15+. Each item is taken from the source once and kept until the furthest-behind copy has passed it. Each copy is safe only in one thread; share one copy between threads through serialize_iterator

Module functions

Operation Time Space Notes
current_thread() O(1) O(1)
main_thread() O(1) O(1)
active_count() O(1) O(1)
enumerate() O(T) O(T) A new list of every live thread
get_ident(), get_native_id() O(1) O(1)
activeCount(), currentThread() O(1) O(1) Deprecated aliases; each call emits a DeprecationWarning
settrace(func), setprofile(func) O(1) O(1) Stores the hook for threads started afterwards; running threads are not touched
settrace_all_threads(func), setprofile_all_threads(func) O(T) O(1) Python 3.12+; also installs the hook in every running thread
gettrace(), getprofile() O(1) O(1)
stack_size([size]) O(1) O(1) Returns the previous size and sets the one for threads started afterwards; no argument means the platform default, and a size below the platform's minimum, never less than 32 KiB, raises ValueError
excepthook(args) O(b) O(b) Called once per unhandled exception in a thread; b = the size of the traceback it prints

ExceptHookArgs

Operation Time Space Notes
ExceptHookArgs O(1) O(1) The four-field record excepthook() receives

Constants and exceptions

Operation Time Space Notes
TIMEOUT_MAX O(1) O(1) The largest timeout a lock acquire or a Condition, Event, Semaphore or Barrier wait accepts; larger values raise OverflowError
ThreadError, BrokenBarrierError O(1) O(1) ThreadError is RuntimeError; BrokenBarrierError subclasses it

Starting and Joining Threads

Building a Thread starts nothing. start() pays for an OS thread and its stack and returns once the thread is running; join() costs only the wait, and returns at once when the thread has already finished.

import threading

results = []

def task(name):
    results.append(name)

threads = [threading.Thread(target=task, args=(i,)) for i in range(5)]  # O(1) each
assert threads[0].ident is None  # nothing has started yet

for thread in threads:
    thread.start()  # O(u) - returns once the thread is running
for thread in threads:
    thread.join()   # O(w) - immediate once the thread has finished

assert sorted(results) == [0, 1, 2, 3, 4]
assert not any(thread.is_alive() for thread in threads)  # O(1) each

Timers

A Timer is a thread that waits before calling its function. Cancelling one that is still waiting ends its thread at once, so a long interval does not keep join() waiting.

import threading

fired = []
timer = threading.Timer(60, fired.append, args=['late'])  # O(1)
timer.start()   # O(u)
timer.cancel()  # O(1) - the waiting thread wakes and exits
timer.join()    # O(w) - no 60-second wait

assert fired == []
assert not timer.is_alive()

Locks

An uncontended acquire() returns at once; a contended one waits for the holder, or for its timeout. A with block releases the lock even when the body raises. An RLock lets its owner acquire it again without waiting.

import threading

counter = 0
lock = threading.Lock()

def increment(times):
    global counter
    for _ in range(times):
        with lock:  # O(w) - immediate when free; released on exit in O(1)
            counter += 1

threads = [threading.Thread(target=increment, args=(10_000,)) for _ in range(4)]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
assert counter == 40_000

lock.acquire()
assert lock.acquire(timeout=0.01) is False  # O(w) - gives up after the timeout
lock.release()

rlock = threading.RLock()
with rlock:
    with rlock:  # O(1) - the owner only increments a count
        pass

Waiting and Waking

A Condition keeps a queue of its waiters. notify(n) wakes the n oldest and costs O(n); notify_all() wakes all q. A wait whose timeout expires removes itself from that queue, which is O(q): Event, Semaphore and Barrier are built on a Condition, so their timed waits pay the same.

import threading

condition = threading.Condition()
items = []
taken = []

def consumer():
    with condition:
        condition.wait_for(lambda: items)  # O(w + q + c) - the predicate runs once per wakeup
        taken.append(items.pop())

threads = [threading.Thread(target=consumer) for _ in range(3)]
for thread in threads:
    thread.start()

for value in range(3):
    with condition:
        items.append(value)
        condition.notify()  # O(1) - wakes the oldest waiter only

for thread in threads:
    thread.join()
assert sorted(taken) == [0, 1, 2]

with condition:
    assert condition.wait(timeout=0.01) is False  # O(w + q) - expires and leaves the queue

Event, Semaphore and Barrier

import threading

ready = threading.Event()
slots = threading.Semaphore(2)
finish = threading.Barrier(3)
order = []

def worker(name):
    ready.wait()  # O(w + q) - immediate once set
    with slots:  # O(w + q) - at most two workers inside at once
        order.append(name)
    finish.wait()  # O(w + q) - the last arrival wakes the other two

threads = [threading.Thread(target=worker, args=(name,)) for name in "abc"]
for thread in threads:
    thread.start()

ready.set()  # O(q) - wakes every worker already waiting; later ones pass straight through
for thread in threads:
    thread.join()
assert sorted(order) == ['a', 'b', 'c']
assert finish.n_waiting == 0 and not finish.broken  # O(1)

Thread-Local Data

A local gives each thread its own attribute dict. For a subclass, the first access from a thread runs __init__ again with the arguments the object was built with, so that cost is paid once per thread that touches it.

import threading

inits = []

class Context(threading.local):
    def __init__(self, user):
        inits.append(user)  # runs again in each thread that touches the object
        self.user = user

context = Context("default")
seen = []

def show():
    seen.append(context.user)  # O(1) - this thread's own copy, from a fresh __init__
    context.user = "worker"

thread = threading.Thread(target=show)
thread.start()
thread.join()

assert seen == ["default"]
assert context.user == "default"  # the worker's assignment stayed in its thread
assert inits == ["default", "default"]

Sharing an Iterator Between Threads

A generator raises ValueError when two threads advance it at once. From 3.15, serialize_iterator puts one lock around every step, so each thread waits for the step in progress and every item goes to exactly one consumer. concurrent_tee instead gives each thread its own copy of the stream: each item is read from the source once, and held until every copy has passed it.

import sys
import threading

if sys.version_info >= (3, 15):
    def numbers():
        yield from range(10_000)

    shared = threading.serialize_iterator(numbers())  # O(1) - nothing consumed yet
    taken = []

    def drain():
        for value in shared:  # O(w + g) per item - one step at a time
            taken.append(value)

    threads = [threading.Thread(target=drain) for _ in range(4)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    assert sorted(taken) == list(range(10_000))  # each item went to exactly one thread

    pulls = []

    def source():
        for value in range(1_000):
            pulls.append(value)
            yield value

    copies = threading.concurrent_tee(source(), 3)  # O(k)
    totals = []

    def total(copy):
        totals.append(sum(copy))  # each copy is read in one thread only

    threads = [threading.Thread(target=total, args=(copy,)) for copy in copies]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    assert totals == [sum(range(1_000))] * 3
    assert pulls == list(range(1_000))  # the source was read once, not once per copy

Common Patterns

A Background Worker With a Stop Event

Event.wait(timeout) doubles as an interruptible sleep: it returns True as soon as the event is set, so the worker stops without finishing its current interval.

import threading

stop = threading.Event()
ticks = []

def heartbeat():
    while not stop.wait(0.001):  # O(w + q) - returns True as soon as stop is set
        ticks.append(1)

worker = threading.Thread(target=heartbeat, daemon=True)
worker.start()
stop.set()  # O(q)
worker.join(timeout=5)  # O(w)

assert not worker.is_alive()

Performance Best Practices

✅ Do:

  • Reuse a fixed set of threads, or a concurrent.futures.ThreadPoolExecutor, for many short tasks: every start() pays for an OS thread and its stack
  • Hold locks with with, so the release runs even when the body raises
  • Wake with notify(n) when only n waiters can proceed; notify_all() wakes all q, and each must then re-acquire the lock in turn
  • Call active_count() when the count is all you need: len(enumerate()) builds an O(T) list
  • Block in join() or wait() rather than polling is_alive() or is_set() in a loop: a blocked thread uses no CPU while it waits

❌ Avoid:

  • Pure-Python CPU-bound work in threads on the default build: one thread runs bytecode at a time, so threads add start-up cost without a parallel speed-up; use multiprocessing, or the free-threaded build

Version Notes

  • Python 3.10+: activeCount(), currentThread(), notifyAll(), isSet(), getName(), setName(), isDaemon() and setDaemon() emit DeprecationWarning
  • Python 3.12+: settrace_all_threads() and setprofile_all_threads() added
  • Python 3.13+: the optional free-threaded build runs Python code in parallel without the GIL; the default build still runs bytecode in one thread at a time
  • Python 3.14+: RLock.locked() added; passing arguments to RLock() is deprecated
  • Python 3.15+: serialize_iterator, synchronized_iterator and concurrent_tee added; passing arguments to RLock() raises TypeError
  • queue - Thread-safe FIFO, LIFO and priority queues for handing work between threads
  • concurrent.futures - Thread and process pools that reuse their workers
  • multiprocessing - Processes instead of threads, for CPU-bound Python code on the default build
  • contextvars - Context-local state that also follows asyncio tasks
  • asyncio - Many concurrent I/O tasks on one thread