Exploring Python 3.13 Free Threading and AsyncIO Performance Without the GIL
This article examines the new free‑threading feature introduced in Python 3.13 via PEP‑703, demonstrates how asyncio.to_thread can be used for CPU‑bound tasks, and compares execution times with and without the GIL on an M3 MacBook Pro, showing a clear performance gain.
With the release of Python 3.13, the most exciting change is the introduction of free threading (PEP‑703), which makes it possible to run Python without the Global Interpreter Lock (GIL) and dramatically improve performance.
Before Python 3.13, because of the GIL, threads were mainly used for I/O‑bound work and asyncio was also limited to I/O. Developers could wrap a function in a thread using asyncio.to_thread , for example:
<code>await asyncio.to_thread(io_bound_task, "first_arg", optional="optional")</code>The question is whether this approach can be applied to CPU‑intensive tasks. The asyncio documentation notes that asyncio.to_thread() can be used for CPU‑bound functions when the GIL is released, such as in extensions or alternative Python implementations.
Note: Because of the GIL, asyncio.to_thread() is usually only suitable for turning I/O‑bound functions into non‑blocking ones, but for GIL‑free extension modules or alternative Python implementations it can also be used for CPU‑bound functions; the only thing stopping us is the GIL.
The following script tests this idea:
<code>import argparse
import os
import sys
import time
from asyncio import get_running_loop, run, to_thread, TaskGroup
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
@contextmanager
def timer():
start = time.time()
yield
print(f"Elapsed time: {time.time() - start}")
def cpu_bound_task(n):
"""A CPU‑bound task that computes the sum of squares up to n."""
return sum(i * i for i in range(n))
async def main():
parser = argparse.ArgumentParser(description="Run a CPU‑bound task with threads")
parser.add_argument("--threads", type=int, default=4, help="Number of threads")
parser.add_argument("--tasks", type=int, default=10, help="Number of tasks")
parser.add_argument("--size", type=int, default=5000000, help="Task size (n for sum of squares)")
args = parser.parse_args()
get_running_loop().set_default_executor(ThreadPoolExecutor(max_workers=args.threads))
with timer():
async with TaskGroup() as tg:
for _ in range(args.tasks):
tg.create_task(to_thread(cpu_bound_task, args.size))
if __name__ == "__main__":
print("Parallel with Asyncio")
print(f"GIL {sys._is_gil_enabled()}") # type: ignore
run(main())
</code>Running the script on an M3 MacBook Pro produced the following results:
<code>➜ python parallel_asyncio.py
Parallel with Asyncio
GIL False
Elapsed time: 0.5552260875701904</code>When the interpreter was built with the GIL enabled (standard Python 3.13), the output was:
<code>➜ python parallel_asyncio.py
Parallel with Asyncio
GIL True
Elapsed time: 1.6787209510803223</code>As expected, using AsyncIO with free threading significantly reduced execution time, confirming that CPU‑bound workloads benefit from the removal of the GIL.
Note: The article also includes promotional material for a free Python course and additional reading links, but the technical discussion and code examples constitute the core academic content.
Python Programming Learning Circle
A global community of Chinese Python developers offering technical articles, columns, original video tutorials, and problem sets. Topics include web full‑stack development, web scraping, data analysis, natural language processing, image processing, machine learning, automated testing, DevOps automation, and big data.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.