This repo is a playground for testing out Python's new free-threading features in versions 3.13 and 3.14. I wanted to see firsthand how things change now that we're moving away from the Global Interpreter Lock (GIL).
To put it to the test, I built a MapReduce setup that uses multiple workers for parallel processing. Python 3.13+ offers an experimental free-threaded build (python3.13t) that disables the GIL entirely. Packages with C extensions need to opt in by declaring thread-safety, but pure-Python code works out of the box.
I also got a lot of inspiration from Tiago Rodrigues Antão's book, Fast Python (2023). He does a deep dive into concurrency using asyncio and multiprocessing in Chapter 3, but since the book came out before these latest Python updates, I decided to do a fresh exploration using the python3.13t build.
Note on architecture: The current implementation uses
asyncio.run()inside worker threads — each call tomap_reduce()creates a temporary event loop and usesasyncio.to_thread()to fan out CPU work. This pattern (threads → event loops → thread pool) is intentionally layered to demonstrate how free-threading resolves contention that would deadlock under the GIL. In production free-threaded code, you would typically useconcurrent.futures.ThreadPoolExecutordirectly, since threads can now do CPU work in true parallel without needing asyncio as an intermediary.
sequenceDiagram
actor Client
participant Server
participant WQ as Worker Queue
participant Worker as Worker Thread<br/>(Isolated Thread)
participant RQ as Result Queue
Client->>Server: request (MR)
activate Server
Server->>WQ: job params
Server-->>Client: returns Job id
deactivate Server
Note over Worker: No GIL
Worker->>WQ: pick
activate Worker
Worker->>Worker: execute
Worker->>RQ: publish result
deactivate Worker
Client->>RQ: pick up result
RQ-->>Client: result
- Install
uvif not already installed in your machine. - Setup dev environment using python3.13t.
uv sync --dev- Run the server using the following command:
export PYTHON_GIL=0
uv run python server.py- Run the load test in a different console with the desired parameters:
# uv run python test_load.py {num_requests} --chunk-size {down_chunk_size} {up_chunk_size}
uv run python test_load.py 3 --chunk-size 1000 2000The num_requests indicate the number of client requests to the server.
The down_chunk_size and up_chunk_size are the bound limits for the corpus length.
A benchmark script is included to compare server performance with the GIL enabled (PYTHON_GIL=1) versus disabled (PYTHON_GIL=0).
| Mode | Requests | Chunk Size (words) | Elapsed Time | Status |
|---|---|---|---|---|
no-gil (PYTHON_GIL=0) |
5 | 1000 – 3000 | 0.327s | completed |
no-gil (PYTHON_GIL=0) |
10 | 1000 – 3000 | 0.429s | completed |
no-gil (PYTHON_GIL=0) |
20 | 1000 – 3000 | 0.499s | completed |
no-gil (PYTHON_GIL=0) |
10 | 6000 – 9000 | 0.612s | completed |
with-gil (PYTHON_GIL=1) |
10 | 1000 – 3000 | TIMEOUT (>60s) | not completed |
Note on Execution Times: The new measurements reflect the true server capability. The free-threaded server processes 10 concurrent requests of 3000 words in just
429ms.
Using activity.py, thread execution states (running, waiting, blocked, io_wait) were sampled across server workers to visualize how the GIL impacts concurrency.
Scenario: 10 requests, 1000-3000 words, PYTHON_GIL=0
no-gil 10 1000
───────────────────────────────────────────────
0.0s 0.3s 0.6s 1.0s 1.3s 1.6s 1.9s
worker-0 ░░░░░░░░░░░░░░░░░░░▒█░░░
worker-1 ░░░░░░░░░░░░░░░░░░░██░░░
worker-2 ░░░░░░░░░░░░░░░░░░░██░░░
worker-3 ░░░░░░░░░░░░░░░░░░░██░░░
Legend: █ running ░ waiting ▒ blocked · io_wait
Scenario: 10 requests, 6000-9000 words, PYTHON_GIL=0
(Timeline remains visually identical to the Scaling Test, just spanning a marginally wider active execution window of ~612ms).
Scenario: 10 requests, 1000-3000 words, PYTHON_GIL=1
with-gil 10 1000
──────────────────────────────────────
0.0s 0.3s 0.7s 1.0s 1.3s 1.6s 1.9s
worker-0 ░░░░░░░░░░░░░░░░░░░░·▒█·░░░
worker-1 ░░░░░░░░░░░░░░░░░░░░▒██··░░
worker-2 ░░░░░░░░░░░░░░░░░░░░▒██··░░
worker-3 ░░░░░░░░░░░░░░░░░░░░··█·▒░░
Legend: █ running ░ waiting ▒ blocked · io_wait
With the artificial polling delays removed, we can see the true scaling capabilities:
-
5 to 20 Requests: Quadrupling the concurrent requests (from 5 to 20) only increases elapsed time from
327msto499ms. The 4 server worker threads process the jobs instantaneously in parallel (the solid█blocks). -
Workload Intensity: Pushing the chunk sizes to 6000-9000 words pushes elapsed time to
612ms.asyncio.to_thread()flawlessly fans out these massive chunks across the cores without bottlenecking the main event loop workers.
Under PYTHON_GIL=1, the thread state profile completely changes. Instead of solid running states (█), the workers are saturated with ▒ (blocked) and · (io_wait/lock wait) states.
Degradation:
Each worker thread calls map_reduce(), which internally calls asyncio.run() to create a temporary event loop and uses asyncio.to_thread() to fan out work to a shared executor (default 8 threads).
With 4 workers each blocking on their event loop while their executor threads wait for GIL time-slices on a single core, the nested blocking pattern starves the executor threads. Because the GIL only allows one thread to execute Python bytecode at a time, they can't complete fast enough to unblock the workers, resulting in an effective deadlock.
Free-threading in Python 3.13 provides true horizontal CPU utilization, physically enabling layered architectures (like asyncio.to_thread() fanning out work from within worker threads) that are completely crippled by lock contention under traditional GIL constraints.
Security note: This demo uses
pickleandmarshalfor serialization simplicity. In production, these enable arbitrary code execution on untrusted input. Use JSON, MessagePack, or Protocol Buffers for data, and avoid transmitting executable code over the network.
- Mario Reyes Ojeda