# The GIL and Choosing a Concurrency Model

## 1. What the GIL Is

CPython's **Global Interpreter Lock** lets only one thread execute Python bytecode at a time in a process. It simplifies memory management (reference counting isn't thread-safe without it) and makes single-threaded code fast, but it means **threads don't run Python code in parallel on multiple cores**.

The GIL is **released** during blocking I/O (sockets, files, `time.sleep`) and by many C extensions doing heavy work (NumPy, hashlib on large inputs, zlib). So:

| Workload | Threads help? | Best tool |
|---|---|---|
| I/O-bound (HTTP calls, DB queries, files) | Yes | `asyncio`, or threads (`ThreadPoolExecutor`) for blocking libraries |
| CPU-bound pure Python | No (GIL) | `multiprocessing` / `ProcessPoolExecutor`, or move hot loops to C (NumPy, Cython, Rust) |
| CPU-bound in C extensions that release the GIL | Yes | Threads |

Python 3.13+ offers an experimental **free-threaded** build without the GIL (PEP 703), and 3.12+ has per-interpreter GILs (subinterpreters). Most production code today still runs with the GIL.

## 2. Threads

```python
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=16) as pool:
    pages = list(pool.map(fetch, urls))          # overlapping network waits
```

The GIL doesn't make your code thread-safe: `counter += 1` is several bytecodes and can interleave. Protect shared state with `threading.Lock`, use `queue.Queue` for producer/consumer, or avoid sharing.

## 3. Processes

```python
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":                      # required with the 'spawn' start method (macOS, Windows)
    with ProcessPoolExecutor() as pool:
        results = list(pool.map(crunch, chunks, chunksize=100))
```

Each process has its own interpreter and GIL, so CPU work runs in parallel. Costs: process start-up, memory per process, and **pickling** arguments and results across process boundaries. Send big chunks of work, not tiny tasks.

## 4. asyncio

One thread, one event loop, many coroutines that `await` I/O. It scales to tens of thousands of concurrent connections with little memory, but:

- Everything in the call path must be async (use `httpx`, `asyncpg`, `aiohttp`); a blocking call freezes all tasks. Offload unavoidable blocking calls with `await asyncio.to_thread(fn, ...)` (3.9+).
- Limit concurrency with `asyncio.Semaphore`; add timeouts with `asyncio.wait_for`.
- Run concurrent tasks with `asyncio.gather` (or `asyncio.TaskGroup` in 3.11+ for structured concurrency).

## 5. Choosing

```text
Is the work waiting on I/O?            → asyncio (new code, async libraries) or threads (blocking libraries)
Is it CPU-heavy pure Python?           → processes (or vectorize with NumPy / rewrite the hot loop)
Is it a web server?                    → multiple worker processes (gunicorn/uvicorn workers) × async or threads inside
Is it a big batch job?                 → processes or a distributed framework (Dask, Ray, Spark)
```

## Common Pitfalls

1. **Using threads to speed up pure-Python CPU work**: often slower because of GIL contention.
2. **Calling blocking libraries (`requests`, `time.sleep`) inside async code**.
3. **Assuming the GIL makes code thread-safe**.
4. **Tiny tasks to a process pool**: pickling overhead dominates.
5. **Unbounded concurrency** (10,000 simultaneous requests to one API).

## Quick Reference

```text
GIL: one thread runs Python bytecode at a time · released on I/O and by many C extensions
I/O-bound → asyncio / threads · CPU-bound → processes / native code · web → workers × (async|threads)
asyncio: gather, Semaphore, wait_for, to_thread · threads: Lock, Queue · processes: big chunks, __main__ guard
```
