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.

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 instant
Four threads waiting on I/O:

thread 1  #....................#
thread 2   #...................#
thread 3    #..................#
thread 4     #.................#
          -------------------------------> time
          # = holding the GIL, . = waiting, GIL released

Seeing 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 time

When the lock is released

OperationGIL released?
time.sleep()Yes
Reading or writing a fileYes
Network requestsYes
Database queriesYes, in a well written driver
Waiting on a lock or a queueYes
Pure Python loops and arithmeticNo
String and list manipulationNo

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 GIL

A 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 400000

The 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 update

Working 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 isUseBecause
Waiting on files or the networkThreads or asyncioThe GIL is released while waiting
Pure Python computationProcessesEach has its own GIL
Numeric work in a C libraryThreads may workThe extension can release the GIL
Very many connectionsasyncioOne 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

  1. Measure four threads against one on a CPU bound task and explain the result.
  2. Measure four threads against one on an I/O bound task and explain the difference.
  3. Show that x += 1 compiles to several instructions and can lose updates.
  4. Classify three workloads as CPU or I/O bound using process_time.
  5. 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Threading

Threads let a program wait for several slow things at once. In Python they help with input and output, and cannot speed up pure computation.

Read more
Python

Multiprocessing

Processes each run their own interpreter with their own GIL, so they can use every processor core. The cost is that nothing is shared by default.

Read more
Python

async and await

Asynchronous code runs many waiting tasks in one thread. A coroutine pauses at await, the event loop runs something else, and thousands of tasks becom...

Read more
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.