CodeOath
← All posts
Python70 min total · 18 parts

Python Fundamentals for Interviews: Data Structures, Comprehensions, and Gotchas

Contents — Part 17 of 18: The GIL, Threading, and Async Basics
Part 17 of 18 · ~2 min

The GIL, Threading, and Async Basics

The Global Interpreter Lock (GIL) is a mutex inside the standard CPython interpreter that allows only one thread to execute Python bytecode at a time, even on a multi-core machine. This is one of the most commonly misunderstood facts about Python performance:

import threading

def cpu_bound_work():
    total = 0
    for i in range(10_000_000):
        total += i
    return total

# Two threads running cpu_bound_work do NOT run twice as fast on a multi-core
# machine — the GIL means only one is actually executing Python bytecode at
# any given instant, so this gains essentially nothing over running it twice
# sequentially (and loses a bit to thread-switching overhead).
Workload typeBest toolWhy
CPU-bound (heavy computation)multiprocessingSeparate processes each get their own GIL and their own interpreter — genuine parallelism across cores
I/O-bound (network calls, file/disk waits)threading or asyncioThe GIL is released while waiting on I/O, so other threads/coroutines can run during that wait

Threads in Python are genuinely useful for I/O-bound work — a thread blocked on requests.get() or a database query releases the GIL during the wait, letting other threads make progress — the GIL is a bottleneck specifically for CPU-bound work spread across threads, not for I/O-bound concurrency.

asyncio basics

import asyncio

async def fetch_data(delay):
    print("start")
    await asyncio.sleep(delay)  # yields control back to the event loop during the wait
    print("done")
    return "data"

async def main():
    results = await asyncio.gather(fetch_data(1), fetch_data(2))  # runs concurrently
    print(results)

asyncio.run(main())

async/await is cooperative concurrency on a single thread: await explicitly yields control back to the event loop, letting other coroutines run during a wait, rather than the OS preemptively switching between threads. asyncio.gather runs the two fetch_data calls concurrently — the total time is roughly max(1, 2) seconds, not 1 + 2, because both sleeps are in flight at once. A common mistake is calling an async def function without await-ing it (or passing it to asyncio.run/gather) — that only creates a coroutine object and never actually runs its body, silently doing nothing.