Back to Home

GIL CPython: how it works and how to bypass senior

The article breaks down GIL in CPython: refcount protection, switching mechanism, CPU/I/O-bound differences. Discusses Lock/RLock, multiprocessing, GIL release in C extensions and asyncio.

GIL in CPython: full breakdown for senior interviews
Advertisement 728x90

The CPython GIL: Mechanism, Limitations, and Workarounds for Senior Developers

In CPython, the GIL protects reference counting from race conditions. Without it, two threads could simultaneously decrement the ob_refcnt of the same object: both see a value of 1, both free the memory—resulting in a segfault.

Key Terms:

  • Segfault: Accessing freed memory, triggering SIGSEGV.
  • Opcode: An atomic bytecode instruction (use dis to view).
  • Reference counting: When ob_refcnt drops to 0, the object is destroyed.
  • Mutex: Only one thread can enter a critical section at a time.
  • Race condition: The outcome depends on the order of thread execution.
  • Bytecode: Compiled .py into .pyc, executed by the VM.
  • Context switch: Overhead of ~microseconds, disrupts CPU cache.

Example of a race without the GIL:

Google AdInline article slot
# thread1: refcount=1 -> free(obj)
# thread2: refcount=1 -> free(obj) -> segfault

Purpose of the GIL in CPython

The GIL is a global mutex that allows only one thread to execute bytecode at a time. Threads can work in parallel during system calls, but not in Python code.

Why it's needed:

  • Protects ob_refcnt from simultaneous modifications.
  • Simplifies C extensions without thread-safety concerns.
  • Provides cheap thread-safety without complex synchronization.

The GIL is specific to CPython. Jython and IronPython do not have it.

Google AdInline article slot

Internal Mechanism of the GIL (since 3.2+)

New implementation in Python/ceval_gil.c: the _gil_runtime_state structure.

Key components:

  • locked: Atomic flag (0/1).
  • mutex + cond_var: POSIX primitives for waiting.
  • eval_breaker: Checked after each opcode.
  • gil_drop_request: Request from a waiting thread.

Algorithm:

Google AdInline article slot
  • A thread holds the GIL and executes bytecode.
  • After 5 ms (sys.getswitchinterval()), another thread signals.
  • The current thread sees the flag via eval_breaker and releases the GIL.
  • The waiting thread acquires it; the current thread waits on cond_var.

sys.setswitchinterval(): Default is 0.005 seconds. Increase to 0.05–0.1 seconds for CPU-bound tasks with NumPy (less thrashing). Do not adjust for I/O.

Why Lock/RLock Are Needed Despite the GIL

The GIL is atomic per opcode, but counter += 1 involves LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL.

counter += 1
# Can be interrupted after LOAD_GLOBAL
# Two threads: +1 instead of +2

Solution:

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

def safe_increment():
    global counter
    with lock:
        counter += 1

RLock for reentrancy:

class SafeTree:
    def __init__(self):
        self._lock = threading.RLock()

    def insert(self, value):
        with self._lock:
            self._rebalance()  # no deadlock

    def _rebalance(self):
        with self._lock:
            pass

Rule: The GIL protects the interpreter; Lock protects business logic.

CPU-bound vs I/O-bound Tasks

I/O-bound (network/disk): The GIL is released during system calls (read/write). threading/asyncio provide concurrency.

import threading, requests

def fetch(url):
    r = requests.get(url)  # GIL free
    return r.status_code

CPU-bound (computations): The GIL blocks parallelism. Use multiprocessing.

from multiprocessing import Pool

def heavy_compute(n):
    return sum(i * i for i in range(n))

with Pool(4) as p:
    results = p.map(heavy_compute, [10**7]*4)  # 4 GILs

Releasing the GIL in C Extensions

NumPy/pandas release the GIL during heavy operations:

Py_BEGIN_ALLOW_THREADS
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS

threading + NumPy = true parallelism. Pure Python in threads does not.

asyncio and Independence from the GIL

Cooperative multitasking in a single thread. Switching occurs on await.

import asyncio
import aiohttp

async def fetch_data(session, url):
    async with session.get(url) as resp:
        return await resp.json()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_data(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

threading vs asyncio for I/O:

  • threading: OS threads, overhead, race conditions.
  • asyncio: 1 thread, no overhead, explicit control.

Levels of Thread/Process/Async Safety

| Type | Problem | Shared Memory | Tools | Use Case |

|------|---------|---------------|-------|----------|

| Thread | Race in threads | Yes | Lock, Queue, local() | ThreadPoolExecutor |

| Process | Process races | Explicitly | multiprocessing.Lock, Manager | Celery, Gunicorn |

| Async | Coroutine races | Single thread | asyncio.Lock, avoid mutability | FastAPI |

Async race example:

# NOT safe
async def transfer(from_acc, to_acc, amount):
    balance = await db.get_balance(from_acc)
    if balance >= amount:  # yield here
        await db.deduct(from_acc, amount)

Use asyncio.Lock or DB transactions.

Key Takeaways

  • The GIL protects refcount, not business logic—always use Lock.
  • For CPU-bound tasks: multiprocessing, not threading.
  • C extensions (NumPy) enable parallelism in threads.
  • asyncio ignores the GIL: cooperative multitasking in one thread.
  • sys.setswitchinterval() is rarely needed, only for NumPy-heavy tasks.

— Editorial Team

Advertisement 728x90

Read Next