Skip to content

Repository files navigation

FThreads

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 to map_reduce() creates a temporary event loop and uses asyncio.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 use concurrent.futures.ThreadPoolExecutor directly, 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
Loading

Setup

  1. Install uv if not already installed in your machine.
  2. Setup dev environment using python3.13t.
uv sync --dev

Tests

  • 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 2000

The 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.

Benchmark: GIL vs No-GIL

A benchmark script is included to compare server performance with the GIL enabled (PYTHON_GIL=1) versus disabled (PYTHON_GIL=0).

Summary Table

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.


Thread Timeline Comparison

Using activity.py, thread execution states (running, waiting, blocked, io_wait) were sampled across server workers to visualize how the GIL impacts concurrency.

A. Scaling Test: Parallel Execution (No-GIL)

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

B. Intensity Test: Parallel Execution (No-GIL)

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).

C. GIL Deadlock Proof: Resource Starvation

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

Analysis & Comparison

True Parallel Scaling

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 327ms to 499ms. 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.

The GIL Contention & Deadlock Mechanics

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 pickle and marshal for 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.

Author

  • Mario Reyes Ojeda

About

A free threading comparison using Python 3.13 and 3.14

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages