Edocti
Advanced Technical Training for the Software Engineer of Tomorrow

Python 3.14 without GIL: Who wins the battle between Multithreading and Asyncio?

Article written by Paul Ianas | Edocti

Python has often been criticized for a "birth defect": the inability to run pure Python code in parallel across multiple CPU cores, due to the infamous GIL (Global Interpreter Lock). However, with the adoption of PEP 703 and the stabilization of the free-threaded build in Python 3.13 and 3.14, this barrier has finally disappeared.

In this article, we analyze the business impact, how the GIL came to be, how it worked at the C level, how it was removed, and what this means for the classic duel: Multithreading vs. Asyncio.

1. Business Impact: Why does the No-GIL + Asyncio tandem matter?

Before dissecting the anatomy of the GIL at the C code level, it's worth exploring the impact of this change on software architecture decisions. Python has always been a champion of Time-to-Market, making it ideal for rapid prototyping and the data ecosystem. However, scaling CPU-bound tasks forced teams into costly compromises because of the GIL.

With Free-Threaded Python and the maturation of Asyncio, we achieve an excellent balance between development speed and raw performance:

  • Optimizing Cloud Memory Consumption: Traditionally, to use 16 cores on a server, the standard was using multiprocessing. Due to how Python manages memory references, these processes rapidly multiplied their RAM footprint. Now, with No-GIL, those 16 cores can be exploited by threads sharing the same memory space. You get a higher application density on the same hardware resources.
  • Avoiding Critical Module Rewrites: A common scaling pattern is identifying bottlenecks and rewriting those services in Go, Rust, or C++. No-GIL allows vertical scaling directly in Python for much longer, maintaining a unified codebase and reducing maintenance costs.
  • Simpler Software Architectures: If a web server had to respond quickly but also handle heavy processing tasks, you were forced to move the logic to adjacent systems (Redis, Celery). Thanks to the Asyncio + Threads symbiosis, the architecture is simplified. An event-loop handles web traffic efficiently, and CPU-bound tasks are delegated directly to a local Thread Pool.
  • Optimized Data Engineering Workflows: Data preparation stages (ETL, data ingestion) in pure Python often bottlenecked on a single core, slowing down the feeding of AI models. Native parallelization eliminates these bottlenecks from data pipelines.

2. A brief history of multithreading in Python

When Guido van Rossum created Python in the early '90s, consumer computer systems had a single core. Memory management in Python was based on reference counting.

For the system to be thread-safe, modifying an object's reference counter by two threads simultaneously had to be protected. The simplest and most efficient solution at the time was introducing a single global lock: the GIL.

Although computers evolved towards multi-core architectures, removing the GIL proved extremely difficult because past attempts destroyed single-threaded performance, and C extensions (like NumPy) heavily relied on the predictable behavior of the GIL.

3. What does the GIL do at a macro level? The C mechanism

GIL vs Free-Threaded vs Asyncio

At the level of the official interpreter (CPython), the GIL is essentially a POSIX mutex (pthread_mutex_t). It guarantees that only one Python thread can execute instructions (bytecode) at a time.

If we simplified the behavior of CPython from previous versions, the code responsible for running threads looked conceptually like this:

#include <pthread.h>

// Simplified structure of the GIL in CPython
typedef struct {
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    int locked;
} GIL_t;

GIL_t gil;

void take_gil() {
    pthread_mutex_lock(&gil.mutex);
    while (gil.locked) {
        pthread_cond_wait(&gil.cond, &gil.mutex);
    }
    gil.locked = 1;
    pthread_mutex_unlock(&gil.mutex);
}

void drop_gil() {
    pthread_mutex_lock(&gil.mutex);
    gil.locked = 0;
    pthread_cond_signal(&gil.cond);
    pthread_mutex_unlock(&gil.mutex);
}

// Main evaluation loop
void* interpreter_loop(void* arg) {
    while (has_bytecode_to_execute) {
        take_gil();

        // Execute a limited number of bytecode instructions
        execute_python_bytecode();

        drop_gil();
        // The thread gives other threads a chance to acquire the GIL
    }
}

The Impact: Because of this structure, if you had 16 cores and ran a script with 16 threads performing mathematical calculations, total CPU utilization remained stuck at 100% (the equivalent of a single core). Furthermore, threads constantly fought for the mutex, generating massive overhead due to context switching.

4. The Free-Threaded Revolution: How the GIL was removed

Removing the GIL in Python 3.14 meant profoundly rewriting how CPython manages memory, using three major techniques:

  • Biased Reference Counting (BRC): Objects now have two reference counters. A local counter (modified only by the thread that created the object) and a shared counter (modified atomically by other threads).
  • Mimalloc: An extremely fast and thread-safe memory allocator.
  • Thread-Safe Collections: Dictionaries and lists now use granular locking mechanisms (per object) instead of a global lock.

Instead of releasing and acquiring a global mutex, object manipulation now uses atomic instructions directly at the CPU level:

// Before (With GIL):
static inline void Py_INCREF(PyObject *op) {
    // Safe, the GIL guarantees nobody else modifies this memory simultaneously
    op->ob_refcnt++;
}

// Now (Without GIL / Free-Threaded):
static inline void Py_INCREF(PyObject *op) {
    // Uses CPU atomic operations (e.g., LOCK XADD on x86) or BRC
    if (_Py_IsOwnedByCurrentThread(op)) {
        op->ob_refcnt_local++;
    } else {
        atomic_fetch_add(&op->ob_refcnt_shared, 1);
    }
}

5. Benchmark: Testing performance in Python 3.14

To demonstrate the new execution mode, we use an SMP (Symmetric Multiprocessing) scenario without communication between threads. This test equally divides the processing of large arrays across cores.

import threading
import time

N = 16_000_000
NCORES = 16

a = [i for i in range(N)]
b = [i+1 for i in range(N)]
c = [0] * N

def add(start, end):
    for times in range(100):
        for i in range(start, end):
            c[i] = a[i] + b[i]

if __name__ == "__main__":
    print(f"Starting benchmark on {NCORES} cores...")
    start_time = time.perf_counter()

    workers = []
    CHUNK_SIZE = N // NCORES

    for i in range(NCORES):
        start = i * CHUNK_SIZE
        end = start + CHUNK_SIZE
        t = threading.Thread(target=add, name=f'Worker-{i+1}', args=(start, end))
        workers.append(t)
        t.start()

    for w in workers:
        w.join()

    end_time = time.perf_counter()
    print(f"Benchmark finished in {end_time - start_time:.4f} seconds.")
    print(f"c[:5]: {c[:5]}")

To see the direct difference, run the script in the two modes:

  • With GIL enabled: python3.14 -X gil=1 benchmark.py
  • Without GIL: python3.14 -X gil=0 benchmark.py

6. Multithreading vs. Asyncio: Who wins the battle in the Free-Threaded era?

Traditionally, Python had a clear demarcation: Asyncio for network operations (I/O) and Multiprocessing for mathematical calculations (CPU). Removing the GIL redefines these roles.

Asyncio: The Cooperative Scheduling Paradigm

Asyncio is not, and never tried to be, a true parallel system. It works as a cooperative scheduler, based on a single-task execution model.

In a cooperative system, tasks are "polite". A task runs on the execution thread until it encounters an await instruction. At that moment, the task willingly yields CPU control and suspends itself, passing the baton back to the Event Loop. The Event Loop checks the operating system's event table and wakes up the next task as soon as the awaited event arrives.

Here is a practical example demonstrating the power of Asyncio for I/O-bound operations (e.g., concurrent HTTP calls):

"""
The client fires all 100 requests to the server almost instantaneously,
on a single thread. Then, the Event Loop catches the responses as
they arrive from the network.
Estimated total time on the client side: ~2 seconds (limited only by network latency).
"""
import asyncio
import httpx  # requires installation: pip install httpx
import time

async def fetch_data(client, task_id):
    print(f"Task {task_id}: Sending request...")

    # The magic word 'await' is the key to the architecture.
    # At this point, the current task says:
    # "I'm waiting for the server's response. Event Loop, take control and start another task!"
    response = await client.get('http://127.0.0.1:5000/slow')

    print(f"Task {task_id}: Response received (Status: {response.status_code})")
    return response.status_code

async def main():
    # Open a single persistent session (best practice for performance)
    async with httpx.AsyncClient(timeout=20.0) as client:
        start_time = time.time()

        # Prepare the list of coroutines (they are not executed yet)
        tasks = [fetch_data(client, i) for i in range(1, 101)]

        # Launch all 100 tasks concurrently
        await asyncio.gather(*tasks)

        total_time = time.time() - start_time
        print(f"\n--- Done! 100 requests processed in {total_time:.2f} seconds ---")

if __name__ == "__main__":
    # Entry point into the Event Loop
    asyncio.run(main())

The major advantage: There are no race conditions at the Python code level. Only one task runs at a time, eliminating the need for complex locks, while network efficiency remains maximized.

Multithreading: Preemptive, but at the cost of memory

Unlike Asyncio, Multithreading is preemptive. The operating system decides when to interrupt a thread, completely unpredictably for the programmer.

However, if we run the benchmark from Chapter 5, we will notice an important reality: even without the GIL, CPU utilization will not magically jump to 1600% on pure Python code. The reason? When 16 threads allocate and manipulate millions of new objects (integers in Python), the memory bus and reference counter updates (BRC) become the new bottleneck.

Multithreading truly shines in Python 3.14 when threads execute massive operations in C/C++ or Rust extensions (like NumPy) that free the thread from the overhead of Python object management.

Comparative Matrix

Criterion Multithreading (Free-Threaded) Asyncio
Scheduling Model Preemptive (OS decides). Cooperative (Tasks willingly yield CPU).
Execution Multi-task, true parallelism across multiple cores. Single-task on a single logical thread.
Thread Safety Complex. High risk of race conditions. Requires Locks. Simple. Implicitly safe at the code level.
Best Use Case CPU-intensive tasks, native extensions, massive isolated data. I/O-bound applications: web servers, proxies, APIs.
Weakness Memory overhead due to object management. A single heavy non-async calculation freezes the Event Loop.

The Verdict: Perfect Symbiosis

Who wins? Neither eliminates the other; we are dealing with a symbiosis.

In Python 3.14, you can keep the elegant architecture of an Asyncio Event Loop to handle tens of thousands of web connections from clients. When a heavy processing task appears (CPU-bound), you can send it directly to a native thread pool (loop.run_in_executor), knowing that the thread will run on a separate core, in the same memory space, without blocking the Event Loop and without being hindered by the GIL. The real winner is the developer.

Ready to master modern architectures in Python?

If you enjoyed this low-level performance analysis, take the next step with us. Learn to design and optimize complex software systems.

Discover Edocti's Advanced Python Topics

About this newsletter

This material is part of a series of technical articles created by the team at Edocti . We maintain a technical, detailed tone anchored in real production challenges. We focus on performance, distributed systems, system-level programming, and advanced solutions in Deep Learning and Computer Vision.