The GIL: What It Is and Why It Exists
CPython allows only one thread to execute bytecode at a time. That single lock explains why threads help with waiting and never with computation.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
What it is
The global interpreter lock is a mutex inside CPython. A thread must hold it to execute Python bytecode, and only one thread can hold it at a time.
Four threads doing pure computation, one core's worth of progress:
thread 1 #### #### ####
thread 2 #### ####
thread 3 #### ####
thread 4 #### ####
-------------------------------> time
only one runs at any instantFour threads waiting on I/O:
thread 1 #....................#
thread 2 #...................#
thread 3 #..................#
thread 4 #.................#
-------------------------------> time
# = holding the GIL, . = waiting, GIL releasedSeeing it
import threading
import time
def cpu_work(n):
total = 0
for i in range(n):
total += i * i
return total
def io_work(seconds):
time.sleep(seconds)
def measure(label, target, args, threads=4):
start = time.perf_counter()
workers = [threading.Thread(target=target, args=args) for _ in range(threads)]
for w in workers:
w.start()
for w in workers:
w.join()
return time.perf_counter() - start
single_cpu = measure("", cpu_work, (5_000_000,), threads=1)
multi_cpu = measure("", cpu_work, (5_000_000,), threads=4)
single_io = measure("", io_work, (1,), threads=1)
multi_io = measure("", io_work, (1,), threads=4)
print(f"CPU: 1 thread {single_cpu:.2f}s, 4 threads {multi_cpu:.2f}s")
print(f"I/O: 1 thread {single_io:.2f}s, 4 threads {multi_io:.2f}s")CPU: 1 thread 0.45s, 4 threads 1.85s no gain, and some overhead
I/O: 1 thread 1.00s, 4 threads 1.00s four times the work, same timeWhen the lock is released
| Operation | GIL released? |
|---|---|
time.sleep() | Yes |
| Reading or writing a file | Yes |
| Network requests | Yes |
| Database queries | Yes, in a well written driver |
| Waiting on a lock or a queue | Yes |
| Pure Python loops and arithmetic | No |
| String and list manipulation | No |
The rule is simple: the GIL is released whenever a thread is waiting for something outside the interpreter, and held whenever it is executing bytecode. Extensions written in C can also release it deliberately during a long computation, which is how numeric libraries achieve real parallelism.
import sys
print(sys.getswitchinterval()) # 0.005 seconds by default
# sys.setswitchinterval(0.001) # how long a thread may hold the GILA thread holding the GIL is asked to release it after roughly five milliseconds so another can run. That is what makes threads appear to interleave even during computation.
Why it exists
- Reference counting safety. Every object has a count that changes constantly. Without a lock, two threads updating one count would corrupt it, and the object would be freed too early or never.
- Simplicity. One lock is far easier to reason about than fine grained locking on every object, and it made the interpreter simpler and faster for single threaded code.
- C extension compatibility. Decades of extensions were written assuming their data structures are not touched concurrently. The GIL provides that guarantee for free.
- Single threaded speed. Fine grained locking would add overhead to every operation, including the vast majority of programs that use one thread.
The GIL is a property of CPython, not of the Python language. Jython and IronPython have no GIL. PyPy has one. Any implementation that does not use reference counting does not need one.
It does not make your code thread safe
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # less than 400000The GIL guarantees that one bytecode instruction runs at a time. counter += 1 is several instructions - load, add, store - and a thread switch can happen between them. You still need locks.
import dis
dis.dis("counter += 1") LOAD_NAME counter
LOAD_CONST 1
BINARY_OP +=
STORE_NAME counter <- a switch between LOAD and STORE loses an updateWorking with it, not against it
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def cpu_task(n):
return sum(i * i for i in range(n))
def io_task(seconds):
time.sleep(seconds)
return seconds
if __name__ == "__main__":
work = [2_000_000] * 4
start = time.perf_counter()
with ThreadPoolExecutor(4) as pool:
list(pool.map(cpu_task, work))
print(f"CPU with threads: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ProcessPoolExecutor(4) as pool:
list(pool.map(cpu_task, work))
print(f"CPU with processes: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(4) as pool:
list(pool.map(io_task, [1] * 4))
print(f"I/O with threads: {time.perf_counter() - start:.2f}s")| Your workload is | Use | Because |
|---|---|---|
| Waiting on files or the network | Threads or asyncio | The GIL is released while waiting |
| Pure Python computation | Processes | Each has its own GIL |
| Numeric work in a C library | Threads may work | The extension can release the GIL |
| Very many connections | asyncio | One thread, no lock contention |
Diagnosing which kind you have
import time
def profile(func, *args):
wall_start = time.perf_counter()
cpu_start = time.process_time()
result = func(*args)
wall = time.perf_counter() - wall_start
cpu = time.process_time() - cpu_start
kind = "CPU bound" if cpu / wall > 0.8 else "I/O bound"
print(f"wall {wall:.2f}s cpu {cpu:.2f}s -> {kind}")
return result
profile(lambda: sum(i * i for i in range(3_000_000)))
profile(lambda: time.sleep(1))process_time counts only CPU time. If it is close to the wall clock time, the work is computation and threads will not help. If it is near zero, the program is waiting and threads will.
The future
- Sub-interpreters (PEP 684, Python 3.12): each interpreter in a process gets its own GIL, giving parallelism without full process overhead.
- Free threaded builds (PEP 703, from Python 3.13): an optional build with no GIL at all, using fine grained locking. It is opt in, and single threaded code is somewhat slower.
- Extensions must be updated to work safely without the GIL, so the transition is gradual.
import sys
print(sys.version)
# Python 3.13+ free threaded builds report:
# print(sys._is_gil_enabled())Answering the interview question
A concise, complete answer:
The GIL is a mutex in CPython that lets only one thread execute Python bytecode at a time. It exists mainly to make reference counting safe without locking every object individually, and to keep C extensions simple. It means threads give no speedup for CPU bound work, because only one runs at a time; but it is released during I/O, so threads are effective for files, network and database work. For CPU bound parallelism you use multiprocessing, where each process has its own interpreter and its own GIL. The GIL does not make code thread safe - x += 1 is several bytecode instructions and can still be interrupted, so shared state still needs a lock. It is a CPython implementation detail, not part of the language, and recent versions are introducing per-interpreter GILs and an optional free threaded build.Common mistakes
- Believing the GIL makes shared state thread safe.
- Using threads to speed up computation and concluding that Python is broken.
- Thinking the GIL is part of the Python language rather than of CPython.
- Assuming it is never released, so threads are useless for everything.
- Reaching for multiprocessing on an I/O bound workload, paying the overhead for nothing.
- Trying to disable the GIL in a standard build.
Best practices
- Measure whether your workload is CPU bound or I/O bound before choosing.
- Threads and asyncio for waiting; processes for computing.
- Use locks for shared mutable state regardless of the GIL.
- Prefer queues and executors to hand rolled thread coordination.
- Keep hot computation in libraries that release the GIL, when one exists.
Practice
- Measure four threads against one on a CPU bound task and explain the result.
- Measure four threads against one on an I/O bound task and explain the difference.
- Show that
x += 1compiles to several instructions and can lose updates. - Classify three workloads as CPU or I/O bound using
process_time. - Write the interview answer above in your own words, in under a minute.
Conclusion
One lock, one thread executing bytecode at a time, released whenever a thread waits. That single sentence predicts everything: threads help with I/O, processes are needed for computation, and shared state still needs locks because bytecode is not atomic.