Python · Python Threads and Processes

Python: The GIL and the threading Module

What the GIL serializes and what still races, Lock, RLock, Event, Queue, Condition and Semaphore, and how threads report errors, exit and keep thread-local state.

  • 7 min read
  • 16 practice questions
On this page
  1. Description
  2. The GIL
  3. What the GIL protects, and what it doesn't
  4. The synchronization tools
  5. How threads start, fail and end
  6. Which tool for which job?
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

Threads let one Python program do several things at once: download ten files while a progress bar updates, or serve many web requests while some of them wait on a database. Python's threading module makes them easy to start - and Python's GIL (global interpreter lock) makes them behave differently from threads in Java or C++. This lesson explains what the GIL does and doesn't do for you, the tools for making threads cooperate safely, and how threads start, fail and stop.

Here's the belief that causes the most trouble: "the GIL makes Python code thread-safe". Look at this deposit:

def deposit(amount: int) -> None:
    current = account.balance
    audit(amount)                          # any call between the read and the write...
    account.balance = current + amount     # ...lets another thread's deposit be overwritten

Four threads making 200,000 one-dollar deposits each should end with 800,000. Three runs on Python 3.14 ended with 711,045, 740,304 and 716,669. By the end of the first two sections you'll know exactly where the missing money went, and the three-line fix.

We'll go from simple to more technical:

  1. What the GIL is, and when threads help (waiting) or don't (computing).
  2. What the GIL protects - and the read-then-write races it doesn't.
  3. Locks, events, queues, conditions and semaphores.
  4. How threads start, fail and end.

The GIL

In standard CPython, only one thread runs Python code at any moment. The thread that's running holds the GIL; the others wait for it. Python hands the GIL to another thread regularly - it asks the running thread to let go every 5 milliseconds (sys.getswitchinterval() is 0.005) - and a thread also lets go whenever it waits: for the network, a file, time.sleep, or a lock.

That gives the most important rule about Python threads:

  • Threads help when the work is waiting. Four threads that each wait 0.2 s for a web request finish in about 0.2 s, not 0.8 s - while one waits, another runs.
  • Threads don't help when the work is computing. Four threads each summing five million numbers took 0.47 s; doing the four sums one after another took 0.46 s. Only one of them can compute at a time. For CPU-heavy work, use processes (the next skill) or a library written in C that releases the GIL, such as NumPy.

What the GIL protects, and what it doesn't

Because only one thread runs at a time, a single operation on a built-in object can't be interrupted halfway. These are safe to call from many threads at once:

items.append(x)          # list
cache[key] = value       # dict
queue_deque.popleft()    # collections.deque

What isn't safe is a sequence of steps that must happen together. Python can switch threads between any two of them. In the deposit:

  1. Thread A reads the balance: 100.
  2. Thread A calls audit - and Python switches to thread B.
  3. Thread B reads the balance: still 100, adds its 10, writes 110.
  4. Thread A comes back and writes its own 100 + 10 = 110. Thread B's deposit is gone.

The same race hides in "check, then act": if key not in cache: cache[key] = load(key) can load twice. Even count += 1 is a read, an add and a write; in the standard build a switch rarely lands in the middle of it, but rarely isn't never, and on the free-threaded build (a later skill) it loses updates all the time.

The fix is a lock around the steps that belong together:

lock = threading.Lock()

def deposit(amount: int) -> None:
    with lock:                             # only one thread at a time in here
        current = account.balance
        audit(amount)
        account.balance = current + amount

with lock: releases the lock even if the block raises.

One thing Python spares you, compared with Java: there's no "visibility" problem and no volatile. When one thread assigns to a shared variable, every other thread sees the new value. Python's thread-safety problem is only about steps being interrupted.

The synchronization tools

ToolWhat it's for
LockOnly one thread at a time in a block of code
RLockA lock the same thread may take again (re-entrant)
EventA flag threads can wait on: "stop now", "data is ready"
queue.QueueHanding work from producer threads to consumer threads
ConditionWaiting until some state becomes true
Semaphore / BoundedSemaphoreAt most N threads doing something at once
stop = threading.Event()
while not stop.wait(timeout=1.0):   # sleeps up to 1 s; stop.set() wakes it at once
    poll()

jobs = queue.Queue(maxsize=100)     # put() waits when full, get() waits when empty

Details worth knowing:

  • A Lock can't be taken twice by the same thread. If a method holding the lock calls another method that takes the same lock, the thread waits for itself forever. Use an RLock there - or, better, keep locked sections small and don't call out while holding them.
  • queue.Queue is the simplest safe design. Instead of sharing data and locking it, threads pass work through a queue; task_done() and join() let you wait until every item has been processed.
  • A Condition must be held to wait or notify, and you wait in a loop, because by the time a thread wakes up the state may have changed again:
cond = threading.Condition()
with cond:
    while not items:                # always check again after waking
        cond.wait()                 # releases the lock while waiting
    item = items.pop()
# producer:  with cond: items.append(job); cond.notify()

cond.notify() outside with cond: raises RuntimeError: cannot notify on un-acquired lock. cond.wait_for(lambda: items) writes the loop for you.

  • Semaphore(n) limits concurrency - say, at most 10 calls in flight to a fragile partner API. A release() without a matching acquire() silently adds a permit (a test's Semaphore(2) ended up allowing 3). BoundedSemaphore raises ValueError: Semaphore released too many times instead. Use it as with sem: so acquire and release always pair.

How threads start, fail and end

t = threading.Thread(target=import_file, args=(path,), name="worker")
t.start()        # begins running import_file(path) in the new thread
t.join()         # waits for it to finish

Three behaviors surprise people:

  • An exception in a thread doesn't reach join(). It's printed to stderr as Exception in thread worker: with the traceback, that thread ends, and join() returns normally - the main program carries on as if nothing happened. Catch exceptions inside the target, or use concurrent.futures.ThreadPoolExecutor, where future.result() re-raises the thread's exception.
  • Daemon threads are killed at exit. Python waits for all normal threads before the program exits. A thread started with daemon=True is stopped as soon as the main thread finishes - mid-task, without running its finally blocks. Use daemon threads only for work that's fine to abandon.
  • threading.local() outlives a task in a pool. It gives each thread its own copy of some attributes - but a pool reuses threads, so a value set by one task (say, the current user) is still there for the next task on that thread. Reset it at the start of each task, or pass the value explicitly (or use contextvars).

Which tool for which job?

SituationUse
Many slow network or disk callsthreads (or ThreadPoolExecutor)
Heavy computation in Pythonprocesses, not threads
Several steps on shared dataa Lock around them
Producer and consumer threadsqueue.Queue
Telling a thread to stopthreading.Event
At most N calls at onceBoundedSemaphore(N)
Getting a thread's result or exceptionThreadPoolExecutor and future.result()

Common mistakes

MistakeWhat happensDo this instead
Trusting the GIL to protect read-then-writelost updatesa Lock around the sequence
if key not in d: d[key] = ... from many threadswork done twicea lock, or d.setdefault for simple values
Threads for CPU-bound workno speed-upprocesses
Taking the same Lock twice in one threadthe thread hangsRLock, or restructure
cond.wait() inside an ifacts on a state that changeda while loop, or wait_for
An unmatched Semaphore.release()the limit silently growsBoundedSemaphore and with
Relying on join() to report errorsexceptions are only printedThreadPoolExecutor and result()
Important work in a daemon threadkilled at exita normal thread, joined
threading.local in a poolvalues leak to the next taskreset per task, or pass them in

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Where did the missing deposits go?

Show answer

Python switched threads between a deposit's read and its write. Two threads read the same balance, and the second write overwrote the first. The GIL only makes single operations uninterruptible; put a Lock around the read, the call and the write.

2. A worker thread raises an exception. What does join() do?

Show answer

It returns normally. The exception was printed to stderr and ended only that thread. Use ThreadPoolExecutor and future.result() if you need the exception re-raised in the caller.

3. Four threads each crunch numbers for 1 second. Roughly how long does the program take in standard CPython?

Show answer

About 4 seconds. Only the thread holding the GIL can run Python code, so CPU-bound threads take turns. Use processes to use several cores.

Key takeaways

  • Only one thread runs Python code at a time: threads overlap waiting, not computing.
  • The GIL makes single operations on built-ins safe, not sequences - lock read-then-write and check-then-act.
  • No volatile in Python: the issue is interruption, not visibility.
  • Lock/RLock, Event, Queue, Condition (in a loop), BoundedSemaphore.
  • Thread exceptions don't reach join(); daemon threads die at exit; thread-locals outlive pool tasks.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

SituationUse
Many slow network or disk callsthreads, or ThreadPoolExecutor
Heavy computation in Pythonprocesses (or a C library that releases the GIL)
Several steps on shared dataLock around them
A lock taken again by the same threadRLock (or restructure)
Producers and consumersqueue.Queue (task_done / join)
"Stop now" / "ready" flagthreading.Event
Wait until a state holdsCondition + while (or wait_for)
At most N at onceBoundedSemaphore(N) with with
A thread's result or exceptionThreadPoolExecutor + future.result()

Snippets worth knowing by heart

lock = threading.Lock()
with lock:
    current = account.balance
    account.balance = current + amount

stop = threading.Event()
while not stop.wait(timeout=1.0):
    poll()

with cond:
    while not items:
        cond.wait()
    item = items.pop()

with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch, urls))

The traps

  • "The GIL makes it thread-safe" - only single operations; read-then-write and check-then-act race.
  • Threads for CPU work - four threads take as long as one.
  • Same Lock twice in one thread - hangs.
  • cond.wait() in an if - recheck in a while; notify outside with cond raises RuntimeError.
  • Stray Semaphore.release() - the limit grows; BoundedSemaphore raises ValueError.
  • Exceptions in threads - printed, not raised by join().
  • Daemon threads - killed at exit without finally.
  • threading.local in a pool - leaks into the next task.

Staff-level points, one line each

  • Handover: a waiter waits 5 ms, then sets a drop request; the holder checks it at calls and loop jumps (the "new GIL", 3.2+).
  • Blocking I/O, sleep, hashlib, zlib and NumPy loops release the GIL in C.
  • Convoy: one long C call (a 30M-item sort, ~0.85 s) delayed a 1 ms sleeper by 846 ms.
  • I/O threads competing with a CPU thread wait up to a switch interval for every wake-up.
  • Atomic = one bytecode calling C that doesn't call back into Python: a Python __hash__/__eq__ key, a sort key, or __del__ breaks it.
  • Lock has no fairness; RLock tracks owner and count.
  • Condition = a FIFO of per-waiter locks; queue.Queue = a deque + one mutex + three conditions.
  • ThreadPoolExecutor defaults to min(32, cpu_count + 4) workers - sized for I/O.
  • Shutdown joins non-daemon threads, then atexit; daemon threads are frozen holding their locks.
  • fork() with threads copies their held locks locked - DeprecationWarning since 3.12; 3.14 Linux default is forkserver.

Say it in an interview

  • "This is I/O-bound, so a thread pool works despite the GIL."
  • "The GIL won't save this read-modify-write; I'll hold a lock across it."
  • "I'd use a Queue between the threads rather than share a list and lock it."
  • "A long C call holds the GIL, so I'd move the report to a process to protect latency."

Practice

This skill comes with 16 multiple-choice questions that tell you which misconception a wrong answer reveals. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python: The GIL and the threading Module free

Practice Python: The GIL and the threading Module free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime