Skip to content

Threading & the GIL Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on threading and the GIL — what the GIL is, threads vs multiprocessing, CPU-bound vs I/O-bound work, race conditions and locks, and ThreadPoolExecutor vs ProcessPoolExecutor.

Read the in-depth guidePython Threading and the GIL Explained — Threads vs Multiprocessing(opens in new tab)
15 of 15

The Global Interpreter Lock is a mutex in CPython that allows only one thread to execute Python bytecode at a time. Even on a multi-core machine, a multithreaded pure-Python program runs its bytecode on one core at a time — threads take turns holding the lock.

It exists to make CPython's memory management (especially reference counting) simple and fast: without it, every refcount update would need its own lock. The interpreter releases the GIL periodically and around blocking I/O so other threads can run.

import threading
# both threads exist, but the GIL serializes their bytecode execution
def work():
    total = 0
    for _ in range(10_000_000):   # CPU-bound — holds the GIL
        total += 1

t1 = threading.Thread(target=work)
t2 = threading.Thread(target=work)
t1.start(); t2.start(); t1.join(); t2.join()   # ~no speedup vs one thread

Why it matters: the GIL is a CPython implementation detail (not in the language spec, and absent in Jython/the free-threaded 3.13+ build) that shapes when threads do and don't help.

Threads share one process and one memory space, so they're cheap to create and share data directly — but in CPython they're serialized by the GIL. Processes each have their own interpreter and memory, so they run on separate cores in true parallel, bypassing the GIL — at the cost of higher startup overhead and needing to serialize (pickle) data to communicate.

from threading import Thread
from multiprocessing import Process

Thread(target=fn)    # shared memory, GIL-bound, light
Process(target=fn)   # separate memory, real parallelism, heavier

Threads communicate through shared objects (guarded by locks); processes communicate through Queue, Pipe, or shared-memory primitives because they don't share state.

Rule of thumb: threads for I/O-bound concurrency, processes for CPU-bound parallelism.

For CPU-bound work, threads are constantly executing bytecode, so they're always contending for the GIL — only one runs at a time, and you get no parallel speedup (often a small slowdown from lock contention and context switches).

For I/O-bound work, a thread that's waiting on the network, disk, or a database is blocked outside the interpreter — and CPython releases the GIL during blocking I/O. So other threads run while one waits, giving real concurrency.

import requests, threading

def fetch(url):
    requests.get(url)     # blocks on the network -> GIL released here

# 10 threads overlap their waiting time -> much faster than sequential
threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]

Rule of thumb: if your bottleneck is waiting, use threads (or asyncio); if it's computing, use processes to get past the GIL.

A race condition occurs when two threads access shared mutable state and the result depends on timing. Even x += 1 is not atomic — it's read, add, write, and a thread can be switched out mid-sequence, so updates get lost.

A lock (threading.Lock) creates a critical section: only the thread holding it can proceed, the rest wait, so the read-modify-write happens atomically. Using with lock: guarantees the lock is always released, even on exceptions.

import threading
counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:           # only one thread in here at a time
            counter += 1     # now safe from lost updates

Beware over-locking: acquiring multiple locks in different orders can cause deadlock. Rule of thumb: guard every access to shared mutable state, keep critical sections small, and prefer thread-safe queue.Queue for handoff.

Both come from concurrent.futures and share the same API — submit / map returning Future objects — so you can swap them with one line. The difference is the worker type: ThreadPoolExecutor runs tasks in threads (shared memory, GIL-bound) and ProcessPoolExecutor runs them in separate processes (true parallelism, pickled args).

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# I/O-bound: many network/file waits -> threads
with ThreadPoolExecutor(max_workers=20) as ex:
    results = list(ex.map(download, urls))

# CPU-bound: heavy computation -> processes (one per core)
with ProcessPoolExecutor() as ex:
    results = list(ex.map(crunch_numbers, datasets))

Use ThreadPoolExecutor for I/O-bound tasks (downloads, DB calls) where waiting dominates, and ProcessPoolExecutor for CPU-bound tasks to use all cores — remembering its arguments and return values must be picklable.

Rule of thumb: pick the executor by the bottleneck (waiting vs computing), and let concurrent.futures handle the pool lifecycle and result collection.

Because CPython manages memory with reference counting, and incrementing / decrementing refcounts from multiple threads without protection would corrupt them. The GIL is a single coarse lock that makes the interpreter and refcounting thread-safe cheaply, keeps single-threaded code fast, and makes integrating C extensions simple.

object -> refcount field
thread A: incref            # not atomic on their own
thread B: decref            # GIL serializes these -> no corruption

Rule of thumb: the GIL is a deliberate trade — simpler, faster single-threaded execution and easy C interop, at the cost of multi-core threading for pure Python.

A thread releases the GIL periodically (driven by sys.setswitchinterval, ~5ms by default) so others can run, and around blocking I/O — file/socket reads, time.sleep, etc. Well-written C extensions (NumPy, hashlib, zlib) also release it during heavy native work, allowing real parallelism.

import sys
sys.getswitchinterval()    # ~0.005 seconds between forced switches

Rule of thumb: pure-Python CPU loops hold the GIL; I/O and GIL-releasing C code let other threads make progress — which is why threads help those cases.

Python 3.13 introduced an experimental free-threaded build (PEP 703) that can run without the GIL, enabling true multi-core threading. It's opt-in (a separate build), still maturing, and can slow single-threaded code. The standard CPython you get by default still has the GIL.

python3.13          # GIL build (default)
python3.13t         # free-threaded ("t") build, no GIL, experimental

Rule of thumb: for the foreseeable future, design as if the GIL exists; the free-threaded build is promising but not yet the default.

A Lock can be acquired once; if the same thread tries to acquire it again before releasing, it deadlocks. An RLock (reentrant lock) can be acquired multiple times by the thread that holds it, requiring an equal number of releases — useful for recursive code or nested method calls that each lock.

import threading
lock = threading.RLock()

def outer():
    with lock:
        inner()           # re-acquires same lock -> fine with RLock

def inner():
    with lock:
        ...               # would deadlock with a plain Lock

Rule of thumb: use Lock by default; reach for RLock only when the same thread legitimately needs to re-acquire a lock it already holds.

A deadlock happens when threads wait on each other's locks in a cycle — classically thread A holds lock 1 and wants lock 2 while thread B holds lock 2 and wants lock 1. Neither proceeds. Prevent it by always acquiring locks in a consistent global order, using timeouts, or holding fewer locks.

# both threads acquire in the SAME order -> no cycle possible
def transfer(a, b):
    first, second = sorted((a, b), key=id)
    with first.lock:
        with second.lock:
            ...

Rule of thumb: impose a fixed lock-acquisition order everywhere, and keep critical sections small.

Individual operations on list, dict, and queue.Queue are protected by the GIL so they won't corrupt, but compound operations are not atomic. x += 1 or "check then act" sequences can interleave and race. Use a Lock for multi-step updates, or queue.Queue, which is fully synchronized for producer/consumer hand-off.

from queue import Queue
q = Queue()                # thread-safe put/get, no manual lock needed

counter = 0
def inc():
    global counter
    counter += 1           # NOT atomic: load, add, store can interleave

Rule of thumb: rely on the GIL only for single C-level operations; guard any multi-step update with a lock or use queue.Queue.

A daemon thread is a background thread the program does not wait for at exit — when all non-daemon threads finish, the interpreter shuts down and kills daemons abruptly (no cleanup, finally may not run). Non-daemon threads, by contrast, keep the process alive until they complete.

import threading
t = threading.Thread(target=loop, daemon=True)
t.start()                  # won't block program exit

Rule of thumb: use daemon threads for fire-and-forget background work, but never for tasks that must finish or flush state cleanly on shutdown.

join() blocks the calling thread until the target thread finishes (or an optional timeout elapses). It's how you wait for results / ensure completion before moving on. Without joining, the main thread may continue or exit while workers are still running.

threads = [threading.Thread(target=work, args=(i,)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()      # wait for all to complete
print("all done")

Rule of thumb: start all threads first, then join them all, so they run concurrently rather than one-at-a-time.

threading.local() creates an object whose attributes are per-thread — each thread sees its own independent values on the same object. It's used to hold thread-specific state (a DB connection, a request context) without passing it around or locking shared state.

import threading
ctx = threading.local()

def handler():
    ctx.user = get_current_user()   # private to this thread
    ...

Rule of thumb: use thread-locals to avoid sharing mutable state at all — no shared data means no locks and no races.

No. The GIL guarantees only that a single bytecode operation won't be interrupted mid-way — it does not make multi-step logic atomic. A thread can be suspended between bytecodes, so counter += 1 (load, add, store) can still race and lose updates.

# with two threads each doing 100000 increments,
# the final value is often LESS than 200000 without a lock
counter = 0
def work():
    global counter
    for _ in range(100_000):
        counter += 1        # interleaves -> lost updates

Rule of thumb: never assume "the GIL protects me" — guard shared mutable state with explicit locks regardless.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel