# 208. Python’s GIL Workarounds

#### 🔹 **1. Using `multiprocessing` for Parallel Processing**

* The `multiprocessing` module bypasses the GIL by using separate processes instead of threads.

```python
from multiprocessing import Pool

def square(n):
    return n * n

with Pool(processes=4) as pool:
    results = pool.map(square, range(10))
    print(results)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
```

🔍 **How it works:**

* Unlike threads, **each process has its own GIL**, allowing true parallel execution.

***

#### 🔹 **2. Using `concurrent.futures` for Process-Based Parallelism**

```python
from concurrent.futures import ProcessPoolExecutor

def cube(n):
    return n ** 3

with ProcessPoolExecutor(max_workers=4) as executor:
    results = executor.map(cube, range(10))
    print(list(results))  # [0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
```

🔍 **How it works:**

* `ProcessPoolExecutor` automatically **distributes tasks** across CPU cores.

***

#### 🔹 **3. Running a CPU-Bound Task in Cython (Compiling to C)**

* Cython allows you to write C-optimized Python code that **releases the GIL**.

```cython
# Save as `compute.pyx` and compile using `cythonize`
cimport cython

@cython.boundscheck(False)
@cython.wraparound(False)
def compute(int n):
    cdef int i, total = 0
    for i in range(n):
        total += i
    return total
```

🔍 **How it works:**

* Use `nogil` in Cython to **release the GIL** and enable true parallel execution.

***

#### 🔹 **4. Using `numba` JIT Compilation (Releases GIL)**

```python
from numba import jit

@jit(nopython=True, nogil=True)
def compute(n):
    total = 0
    for i in range(n):
        total += i
    return total

print(compute(10**6))  # Runs without GIL constraints
```

🔍 **How it works:**

* `numba.jit(nogil=True)` allows **true parallel execution**.

***

#### 🔹 **5. Using `joblib` for Parallel Execution**

* `joblib` provides an easy way to parallelize CPU-bound tasks.

```python
from joblib import Parallel, delayed

def power(n):
    return n ** 2

results = Parallel(n_jobs=4)(delayed(power)(i) for i in range(10))
print(results)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
```

🔍 **How it works:**

* Uses multiple **processes** under the hood to bypass the GIL.

***

#### 🔹 **6. Using `ctypes` to Call a GIL-Free C Function**

* Use **C functions** directly in Python.

```c
// Save as `compute.c` and compile: gcc -shared -o compute.so -fPIC compute.c
#include <stdio.h>

int sum_numbers(int n) {
    int total = 0;
    for (int i = 0; i < n; i++)
        total += i;
    return total;
}
```

```python
import ctypes

compute = ctypes.CDLL('./compute.so')
print(compute.sum_numbers(10**6))  # Calls C function, bypassing GIL
```

🔍 **How it works:**

* Calls a compiled **C function** that doesn’t rely on Python’s GIL.

***

#### 🔹 **7. Using `multiprocessing` for Parallel Matrix Multiplication**

```python
from multiprocessing import Pool
import numpy as np

def matrix_multiply(_):
    A = np.random.rand(100, 100)
    B = np.random.rand(100, 100)
    return A @ B

with Pool(processes=4) as pool:
    results = pool.map(matrix_multiply, range(4))
```

🔍 **How it works:**

* **Each process runs on a separate core**, making it faster.

***

#### 🔹 **8. Using `asyncio` for I/O-Bound Tasks**

* Async I/O doesn’t bypass the GIL but **avoids blocking it**.

```python
import asyncio

async def fetch_data():
    print("Fetching data...")
    await asyncio.sleep(2)  # Simulates I/O delay
    print("Data received!")

async def main():
    await asyncio.gather(fetch_data(), fetch_data())

asyncio.run(main())
```

🔍 **How it works:**

* **Non-blocking execution** reduces GIL contention.

***

#### 🔹 **9. Using `threading` for I/O-Bound Workloads**

* **Threads don’t bypass the GIL**, but they improve I/O-bound tasks.

```python
import threading
import time

def task():
    time.sleep(2)
    print("Task completed!")

threads = [threading.Thread(target=task) for _ in range(5)]

for t in threads:
    t.start()

for t in threads:
    t.join()
```

🔍 **How it works:**

* Works well for I/O-heavy workloads like network requests.

***

#### 🔹 **10. Using `Dask` for Parallel Processing**

* `Dask` enables **distributed computation**.

```python
import dask.array as da

arr = da.random.random((10000, 10000), chunks=(1000, 1000))
result = arr.mean().compute()  # Runs in parallel
print(result)
```

🔍 **How it works:**

* **Dask schedules computations** across multiple processes or machines.

***

#### **🚀 Summary of GIL Workarounds**

| Method               | Bypasses GIL? | Best for                  |
| -------------------- | ------------- | ------------------------- |
| `multiprocessing`    | ✅ Yes         | CPU-bound tasks           |
| `concurrent.futures` | ✅ Yes         | CPU-bound tasks           |
| `cython`             | ✅ Yes         | Performance-critical code |
| `numba`              | ✅ Yes         | Numerical computations    |
| `joblib`             | ✅ Yes         | Parallel execution        |
| `ctypes`             | ✅ Yes         | C extensions              |
| `asyncio`            | ❌ No          | I/O-bound tasks           |
| `threading`          | ❌ No          | I/O-bound tasks           |
| `Dask`               | ✅ Yes         | Big data workloads        |

***

#### **🚀 Final Thoughts**

* **Use `multiprocessing`, Cython, or Numba** for CPU-heavy workloads.
* **Use `asyncio` or threading** for I/O-bound tasks.
* **Consider `Dask` for large-scale parallelism**.
