Competitiveness in Python: from GIL to Free Threading
Internal Architecture of Python: GIL, Its Limitations, and a Future Without the Global Lock
It is impossible to seriously discuss concurrency in Python without understanding one of the most well-known and simultaneously most controversial elements of its architecture — the Global Interpreter Lock (GIL).
For many developers, the GIL is associated exclusively with the limitations of multithreading. However, in practice, it is not just a technical feature of CPython, but a fundamental architectural decision that has shaped parallel computing approaches in the Python ecosystem for decades.
Understanding how the GIL works not only helps explain the behavior of multithreaded applications, but also enables more informed architectural decisions when choosing between threads, processes, and asynchronous programming.
What is the GIL
The GIL is a global interpreter lock — a special mutex that guarantees that only one thread can execute CPython bytecode at any given time.
In practice, this means that even if an application is running on a server with dozens of CPU cores, Python code inside a single process cannot execute in multiple threads simultaneously. While one thread holds the GIL and executes bytecode, the others must wait for the lock to be released.
This is why multithreading in CPython does not provide true parallelism for CPU-bound tasks. Threads effectively run one after another, constantly competing for ownership of the GIL.
Another issue is context switching. Every time control is transferred to another thread, the interpreter performs a series of internal synchronization operations. Under high contention for the GIL, these switches introduce noticeable overhead and can degrade application performance.
Why the GIL was introduced
To understand why the GIL exists, it is necessary to look at the internal architecture of CPython.
Python heavily relies on reference counting for memory management. Each object stores information about the number of active references to it. When this count reaches zero, the object can be safely deallocated.
In a multithreaded environment, this mechanism quickly becomes a potential source of errors. Imagine a situation where one thread modifies an object’s reference counter while another thread simultaneously attempts to free its memory. Without proper synchronization, this could lead to data corruption, memory leaks, or program crashes.
The CPython developers chose a relatively simple and reliable solution: centralizing access to the object model through a single global lock.
This approach significantly simplified the interpreter implementation and allowed the platform to remain highly stable. In effect, the GIL became a trade-off for simplicity in the internal architecture and ease of Python development.
For many years, this compromise was considered reasonable, since most workloads were not CPU-intensive but rather I/O-bound.
When the GIL is not a major problem
The impact of the GIL strongly depends on the nature of the workload.
For applications that are primarily I/O-bound, the global lock is usually not a serious issue.
This category includes:
- web servers;
- HTTP clients;
- database interactions;
- file operations;
- network services;
- messaging systems.
When a thread performs a blocking operation, such as sending an HTTP request or waiting for a database response, control is handed over to the operating system. Before that happens, the interpreter releases the GIL, allowing another thread to continue execution.
As a result, a process can efficiently handle a large number of concurrent operations despite the presence of a global lock.
This is why multithreading remains an effective tool for I/O-bound tasks. For the same reason, asynchronous programming and multithreaded models are successfully used in high-load web applications, API services, and integration systems.
When the GIL becomes a serious limitation
The situation is quite different for CPU-bound tasks.
If a program performs long computations, processes large datasets, executes complex mathematical calculations, or trains machine learning models, a thread will almost constantly hold the GIL.
In such cases, other threads cannot execute Python code and are effectively idle, waiting for the lock to be released.
The result is often unexpected for many developers: an application with multiple threads may run not faster, but even slower than a single-threaded version. This is due to the overhead of context switching and constant competition for the GIL.
For this reason, launching multiple threads rarely helps speed up CPU-intensive tasks in CPython. Even if a server has multiple cores, the interpreter still uses only one of them for executing Python code within a single process.
How Python works around GIL limitations
Understanding the limitations of the GIL led to several approaches that allow efficient use of modern multicore systems.
Multiprocessing
The most common solution is using separate processes instead of threads.
The multiprocessing module creates independent processes, each with its own Python interpreter instance and its own copy of the GIL.
Since processes are fully isolated from each other, the operating system can execute them in parallel on different CPU cores.
This is why for CPU-bound tasks, multiprocessing usually delivers significantly better performance than threading.
concurrent.futures
To simplify concurrency, Python provides the high-level module concurrent.futures.
It offers two main executors:
ThreadPoolExecutorProcessPoolExecutor
The first is intended for I/O-bound tasks, the second for CPU-intensive workloads.
This separation makes it possible to explicitly choose an execution model based on the task type and results in much cleaner code compared to using threads or processes directly.
C extensions
Many high-performance libraries bypass GIL limitations by implementing performance-critical parts in C or C++.
During long computations, such extensions can temporarily release the GIL and perform work outside the interpreter.
This is why libraries like NumPy are able to efficiently utilize system resources despite the existence of the global lock.
Numba and JIT compilation
Another way to improve performance is JIT compilation.
Tools like Numba transform Python functions into machine code at runtime.
As a result, part of the computation is executed outside the standard interpreter, significantly reducing the impact of the GIL on performance.
Alternative Python implementations
There are also other implementations of the language.
For example, PyPy uses its own virtual machine and more efficient threading mechanisms. Some experimental builds include interpreter variants without a GIL, allowing exploration of the potential benefits of true multithreaded execution.
The future of Python: life without the GIL
The most significant development in recent years has been work on a free-threaded version of CPython.
Starting with Python 3.13, developers gained access to an experimental interpreter build without the global lock. This direction is actively evolving and is considered one of the most important stages in the language’s evolution.
If the technology matures, Python could for the first time provide true thread-level parallelism within a single process without relying on multiprocessing.
The potential impact is hard to overestimate:
- more efficient use of multicore processors;
- simpler architecture for high-load applications;
- reduced need for process-based parallelism;
- new approaches to building computational systems in Python.
However, the transition will not be completely painless.
Removing the GIL requires additional synchronization mechanisms inside the interpreter. Some early tests already show that in certain scenarios, free-threaded versions may be slower than classic CPython due to new overhead.
In addition, developers of libraries and large projects will need to adapt existing code to the new concurrency model.
Nevertheless, the direction of evolution is clear: the Python ecosystem is gradually moving toward full multithreaded parallelism.
Brief summary of the GIL
| Characteristic | Description | Impact |
|---|---|---|
| What the GIL is | A global lock in the CPython interpreter | Prevents multiple threads from executing Python bytecode at once |
| Main purpose | Simplifying memory management and ensuring thread safety | Improves interpreter stability |
| I/O-bound tasks | The GIL is released during I/O waiting | Multithreading works efficiently |
| CPU-bound tasks | Threads compete for the lock | Performance limited to a single core |
| Workarounds | multiprocessing, C/C++ extensions, Numba, ProcessPoolExecutor | Allow utilization of multiple cores |
| Future | Free-threaded CPython | True parallelism without the GIL |
Conclusion
For an experienced Python developer, understanding the GIL is not a theoretical detail of interpreter internals, but a practical system design tool.
It is precisely this knowledge that helps determine when to use threads, when to use processes, and when to switch to asynchronous execution models. It explains why some programs scale almost linearly, while others hit a single CPU core limit regardless of hardware power.
And although the global lock has accompanied Python for most of its history, today the ecosystem is on the verge of major changes. The emergence of a GIL-free version of CPython may become one of the most important events in the language’s development over recent decades and significantly reshape approaches to writing concurrent applications.
Multithreading in Python: an Effective Tool for I/O-bound Tasks
When it comes to concurrent programming in Python, the first tool most developers encounter is the threading module.
Multithreading allows multiple execution threads to run within a single process, enabling several tasks to be performed concurrently. However, in Python this works somewhat differently than in many other programming languages. The reason is well known — the Global Interpreter Lock (GIL), which imposes certain limitations on parallel execution of code.
Despite this, multithreading remains one of the most effective tools for solving an entire class of tasks related to input/output.
How threads work in Python
A thread can be viewed as a separate execution path within a process.
Each thread has its own call stack and local variables, but all threads within a process share the same address space. They have access to the same objects, global variables, file descriptors, and network connections.
It is precisely this shared memory model that makes threads lightweight and fast compared to processes. Creating a new thread requires significantly fewer resources than starting a separate process.
However, this approach has a downside — the need to synchronize access to shared data.
How multithreading works under the GIL
At first glance, multithreading may seem useless because of the GIL. But that is not entirely true.
Yes, only one thread can execute Python bytecode at a time. However, the key point is that a thread releases the GIL during blocking operations.
For example:
- waiting for a web service response;
- executing an SQL query;
- reading a file;
- writing to disk;
- waiting for network data.
While one thread waits for an external operation to complete, another can take control and continue execution.
In practice, the application does not idle during I/O waiting but switches to performing other tasks.
This is why multithreading is well suited for services that spend a significant portion of their time waiting for external resources.
Why multithreading is effective for I/O-bound tasks
Let’s consider a simple example.
Suppose an application needs to download 100 web pages.
A single-threaded implementation would execute requests sequentially:
- Send a request.
- Wait for the response.
- Process the result.
- Move to the next request.
If each request takes one second, the total execution time would be close to 100 seconds.
A multithreaded implementation works differently. A thread pool can be created and requests distributed among threads. While one thread waits for a server response, others are already performing their own requests.
As a result, the total execution time is determined not by the sum of all request delays, but by the slowest operations.
This is why multithreading is widely used in:
- web scraping;
- API integrations;
- microservice architecture;
- messaging systems;
- network applications;
- file operations;
- ETL processes.
In such scenarios, threads often provide a significant throughput increase without substantially increasing architectural complexity.
Why multithreading is not suitable for computations
A completely different situation arises with CPU-bound tasks.
If a thread performs long computations, it constantly holds the GIL and almost never releases it.
For example:
- image processing;
- mathematical modeling;
- working with large datasets;
- machine learning;
- scientific computations.
In such scenarios, threads begin to compete for the GIL and execute one after another.
This leads to two consequences:
- Only one CPU core is used.
- Additional overhead from context switching appears.
As a result, a multithreaded program may run slower than an equivalent single-threaded implementation.
Therefore, using threading for computational tasks is generally considered bad practice.
If a task is CPU-bound, multiprocessing or other approaches that provide true parallelism should be used.
The main problem of threads — shared memory
Since all threads operate in a shared address space, they simultaneously access the same objects.
This creates the risk of race conditions.
Imagine a simple situation: two threads simultaneously increment a shared counter.
Without proper synchronization, both threads may read the same value, increment it independently, and write it back. As a result, one of the updates is lost.
Such bugs are especially dangerous because they are non-deterministic and often only reproducible under load.
To solve this problem, Python provides a set of synchronization primitives.
Core synchronization primitives
The threading module includes several tools for safe access to shared resources.
Lock
The simplest synchronization mechanism.
A lock can only be in two states:
- free;
- acquired.
Before entering a critical section, a thread must acquire the lock:
lock.acquire()
After completing the work, the lock is released:
lock.release()
While one thread holds the Lock, others are forced to wait.
In practice, a context manager is more commonly used:
with lock:
shared_counter += 1
This approach is safer and automatically releases the lock even if an exception occurs.
RLock
A reentrant lock.
It allows the same thread to acquire the same lock multiple times without causing a deadlock.
It is useful in complex scenarios with nested function calls.
Semaphore
A semaphore limits the number of threads that can access a resource simultaneously.
It is often used to restrict the number of concurrent requests to an API or database.
Event
A notification mechanism between threads.
One thread can wait for an event, while another signals its occurrence.
This approach enables coordination between threads without continuous polling.
The danger of deadlocks
Improper use of locks can lead to a deadlock.
A typical situation looks like this:
- the first thread holds lock A and waits for lock B;
- the second thread holds lock B and waits for lock A.
Both threads become permanently blocked.
Such errors are among the most unpleasant issues in concurrent programming, as they may appear rarely and are extremely difficult to diagnose in production.
ThreadPoolExecutor: a modern way to work with threads
Although the threading module provides full control over threads, most modern projects use a higher-level abstraction — concurrent.futures.
The main tool here is ThreadPoolExecutor.
Instead of manually creating threads, the developer defines a task, and the thread pool distributes work among worker threads.
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch_url(url):
return requests.get(url).status_code
urls = [
"https://example.com",
"https://httpbin.org/delay/1"
]
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch_url, urls))
In this example, the pool automatically creates multiple threads, distributes tasks among them, and collects the results.
The developer does not need to manage thread lifecycles, task queues, or result handling manually.
Advantages of ThreadPoolExecutor
Compared to direct use of threading, it provides several benefits:
| Aspect | threading | ThreadPoolExecutor |
|---|---|---|
| Level of abstraction | Low | High |
| Thread management | Manual | Automatic |
| Result retrieval | Via queues and events | Via Future |
| Error handling | Requires extra code | Built-in |
| Readability | Medium | High |
| Main use case | Specialized scenarios | Most I/O-bound tasks |
This is why ThreadPoolExecutor is considered the preferred solution for most modern projects.
When to use ProcessPoolExecutor
It is important to remember that ThreadPoolExecutor does not eliminate GIL limitations.
If the executed function is CPU-intensive, threads will still compete for the global lock.
In such cases, the correct choice is ProcessPoolExecutor, which runs tasks in separate processes and allows multiple CPU cores to be used simultaneously.
A simple rule of thumb:
- I/O-bound tasks → ThreadPoolExecutor
- CPU-bound tasks → ProcessPoolExecutor
Conclusion
Multithreading in Python remains one of the key tools of concurrent programming, despite the existence of the GIL.
Its main purpose is not to speed up computations, but to efficiently utilize waiting time for external operations. This is why threads are well suited for network communication, file operations, databases, and other I/O sources.
However, as soon as the main workload shifts to the CPU, the advantages of multithreading quickly disappear. In such cases, it is necessary to switch to multiprocessing or other mechanisms of true parallelism.
For an experienced engineer, the choice between threads and processes should be determined not by habit or API convenience, but by the nature of the workload. Understanding this difference is what enables the design of truly performant and scalable Python systems.
Multiprocessing in Python: True Parallelism for CPU-bound Tasks
Multithreading and asynchronous programming handle I/O tasks very well, but as soon as the main workload shifts to the CPU, their advantages quickly disappear.
The reason is well known — the GIL.
While one thread executes Python code, the others are forced to wait their turn. As a result, a multithreaded application cannot fully utilize multiple CPU cores for computation.
This is exactly the problem solved by the multiprocessing module.
Unlike threads, it creates full operating system processes, each with its own Python interpreter instance and its own copy of the GIL.
Because of this, processes can truly run in parallel on different CPU cores.
Why multiprocessing provides true parallelism
The key difference between a process and a thread is isolation.
If threads run inside a single process and share memory, each process has its own address space.
In simplified terms, the architecture looks like this:
Process 1
├── Python interpreter
├── Own GIL
└── Own memory
Process 2
├── Python interpreter
├── Own GIL
└── Own memory
Process 3
├── Python interpreter
├── Own GIL
└── Own memory
Since processes are completely independent from each other, the operating system can distribute them across different CPU cores.
As a result, Python gains true parallelism that is not available with standard multithreading.
If a server has eight physical cores, the application can execute eight computational tasks simultaneously.
This is why multiprocessing remains the primary tool for CPU-bound workloads.
When multiprocessing is truly necessary
Multiprocessing makes sense in situations where most of the time is spent on computation.
Typical examples include:
- image processing;
- video processing;
- machine learning;
- scientific computations;
- simulation;
- large-scale data analysis;
- report generation;
- cryptographic operations;
- computational pipelines.
In all these scenarios, the bottleneck is the CPU rather than the network or disk subsystem.
If such workloads are parallelized using threads, the GIL quickly eliminates most of the benefits.
Processes, on the other hand, allow full utilization of all available system resources.
The cost of true parallelism
Multiprocessing has a downside.
A process is significantly heavier than a thread.
When creating a new process, the operating system must:
- allocate a separate address space;
- start a new Python interpreter instance;
- load required modules;
- initialize the execution environment.
Because of this, process creation is noticeably more expensive in both time and memory.
If a thread is created almost instantly, spawning many processes can become a significant overhead on its own.
For this reason, processes are usually created in advance and reused via process pools.
The main challenge — data exchange
Threads can directly access shared memory objects.
With processes, it is different.
Each process is isolated and cannot directly access objects of another process.
For example, the following code will not work:
shared_list.append("data")
If the list belongs to another process, the changes will not be visible.
To exchange data, Python provides special inter-process communication mechanisms.
Main ways of inter-process communication
Queue
The most popular tool.
A queue allows safe passing of objects between processes.
from multiprocessing import Queue
queue = Queue()
queue.put("message")
One process puts data into the queue, another retrieves it.
This approach works well for most data processing tasks.
Pipe
A Pipe provides a two-way communication channel between two processes.
It is faster than queues in simple scenarios but less convenient when many participants are involved.
Shared Memory
For large data workloads, shared memory can be used.
This avoids constant copying of objects between processes.
However, this approach requires careful access management and usually increases architectural complexity.
Data serialization and its performance impact
Many developers encounter an unexpected issue when working with multiprocessing.
Before transferring an object between processes, Python must serialize it into a byte representation and then reconstruct it in another process.
This process is called:
- pickling — serialization;
- unpickling — deserialization.
For small objects, the cost is usually negligible.
But if large data structures are constantly passed between processes, serialization overhead can become a serious bottleneck.
Therefore, efficient multiprocessing architectures aim to minimize the amount of transferred data.
ProcessPoolExecutor: a modern approach
Although the multiprocessing module provides low-level tools for process management, in most cases it is more convenient to use ProcessPoolExecutor.
It is part of the concurrent.futures module and offers the same interface as ThreadPoolExecutor.
from concurrent.futures import ProcessPoolExecutor
import math
def calculate_factorial(n):
return math.factorial(n)
numbers = [1000, 1500, 2000, 2500]
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(
executor.map(
calculate_factorial,
numbers
)
)
In this example, tasks are automatically distributed across four processes.
Each process uses a separate CPU core, enabling true parallel execution.
For most projects, ProcessPoolExecutor is the recommended way to work with multiprocessing.
Why ProcessPoolExecutor is more convenient
Compared to direct use of multiprocessing, it provides several advantages:
- automatic process lifecycle management;
- convenient
map()interface; Futuresupport;- built-in exception handling;
- more readable code;
- easy replacement with
ThreadPoolExecutorif needed.
In practice, developers work with tasks rather than processes.
Asyncio and multiprocessing
In real-world systems, it is rare to use only one concurrency model.
A hybrid architecture is often used:
FastAPI / Asyncio
│
▼
Event Loop
│
▼
ProcessPoolExecutor
│
▼
CPU-bound computations
In this setup:
- the async server handles network requests;
- the event loop remains free;
- heavy computations are delegated to separate processes;
- results are returned back to the client.
This approach is considered the standard for high-load Python applications today.
Comparison of approaches
| Approach | Best suited for | Strengths | Limitations |
|---|---|---|---|
| Threading | I/O-bound | Simple data sharing, low overhead | Limited by GIL |
| Asyncio | High-load I/O | Maximum scalability and efficiency | Not suitable for heavy computation |
| Multiprocessing | CPU-bound | True parallelism and full core utilization | Higher memory usage and IPC cost |
Simple selection rule
In practice, the choice usually comes down to three questions:
Does the task wait for external resources?
Use asyncio or threads.
Does the task heavily use the CPU?
Use processes.
Does it involve both I/O and computation?
Combine asyncio with ProcessPoolExecutor.
This rule covers the vast majority of real-world scenarios.
Conclusion
Multiprocessing remains a key tool for computational workloads in the Python ecosystem.
Despite the rise of asynchronous programming, the evolution of multithreading, and CPython’s move toward a free-threaded architecture, processes are still the most reliable way to fully utilize modern multicore systems.
Yes, true parallelism comes at the cost of additional memory usage, more complex data exchange, and process startup overhead. However, this model is what allows Python to efficiently handle CPU-intensive workloads and scale on modern hardware.
For an experienced engineer, understanding the differences between threads, coroutines, and processes is one of the fundamental skills in designing high-performance systems.
Practical Use of Concurrency in Python: Architectural Patterns and Real-World Scenarios
The theory of threads, asynchronous programming, and multiprocessing is only useful when it helps make correct architectural decisions.
In practice, real systems rarely belong exclusively to either I/O-bound or CPU-bound categories. Most modern applications work with mixed workloads: they handle network requests, interact with databases, communicate with external APIs, and simultaneously perform computations of varying complexity.
That is why an experienced Python engineer does not think in terms of “which is better — asyncio or threading,” but rather in terms of workload distribution and choosing the optimal tool for each stage of data processing.
Modern Python ecosystems provide all the necessary mechanisms for building such hybrid solutions.
Architectural Principle #1: Separate I/O and Computation
One of the most common mistakes is trying to handle the entire task lifecycle using a single tool.
For example, a web service receives an image from a user:
- Uploads the file.
- Performs preprocessing.
- Applies a computer vision model.
- Saves the result.
- Returns a response to the client.
At first glance, this looks like a single operation.
In reality, it contains completely different types of workloads:
| Stage | Workload type |
|---|---|
| File upload | I/O-bound |
| Storage read | I/O-bound |
| Neural network work | CPU-bound or GPU-bound |
| Saving result | I/O-bound |
| Sending response | I/O-bound |
If all stages are executed within a single event loop, the computational part quickly becomes a bottleneck and starts blocking the application.
Therefore, the first principle of high-load system design is separation of I/O operations and computation.
Pattern #1: High-Performance Web Scraper
Web scraping is a classic example of an I/O-bound task.
Imagine a system that needs to process 100,000 pages.
A synchronous implementation will wait for each request sequentially.
A multithreaded version improves performance but quickly hits thread limits and overhead.
An asynchronous approach is significantly more efficient:
Asyncio Event Loop
│
▼
aiohttp Client
│
▼
Hundreds and thousands of requests
Each request is sent asynchronously, while the event loop switches between tasks while waiting for responses.
To prevent overloading the remote server, a semaphore is usually used:
```python id="1k8m2v"
semaphore = asyncio.Semaphore(100)
It limits the number of concurrent active connections.
A typical stack looks like this:
* asyncio
* aiohttp
* BeautifulSoup
* lxml
This approach allows efficient use of network resources and significantly increases system throughput.
## Pattern #2: Image and File Processing
Consider a more complex scenario.
A user uploads an image, and the system must:
* resize it;
* apply filters;
* perform OCR;
* save the result.
Here we have a mixed workload.
Downloading the file is I/O.
Image processing is CPU-bound.
The most common architecture looks like this:
```text
Asyncio
│
▼
File upload
│
▼
ProcessPoolExecutor
│
▼
Image processing
│
▼
Asyncio
│
▼
Sending result
For relatively lightweight operations, the following can be used:
await asyncio.to_thread(
process_image,
image
)
For heavy processing, separate processes via ProcessPoolExecutor are usually preferred.
This approach prevents blocking the event loop while utilizing all available CPU cores.
Pattern #3: Modern Web Service
Most modern Python backends follow a hybrid model.
Consider a typical API request:
- Receive HTTP request.
- Validate JWT token.
- Validate input data.
- Query the database.
- Execute business logic.
- Return response.
In such systems, different components use different concurrency mechanisms.
A typical architecture looks like this:
FastAPI
│
▼
Asyncio Event Loop
│
├── PostgreSQL (asyncpg)
├── Redis
├── External APIs
│
▼
ProcessPoolExecutor
│
▼
CPU-intensive calculations
In this model:
- network operations remain asynchronous;
- the event loop is not blocked;
- computations are executed in separate processes;
- the server continues handling incoming requests.
This approach is considered the standard for high-load Python APIs today.
Pattern #4: Real-Time Systems
Chat platforms, game servers, notification systems, and streaming services have specific requirements.
The main challenge is handling thousands of concurrent connections.
In such cases, a pure asynchronous model is typically used:
WebSocket Connections
│
▼
Asyncio
│
▼
Message Processing
Since most connections spend their time waiting for messages, threads or processes are usually unnecessary.
A typical stack:
- FastAPI
- websockets
- asyncio
- Redis Pub/Sub
That is why most modern real-time systems are built around asynchronous execution.
Pattern #5: Data Aggregation from Multiple Sources
Another common scenario is retrieving data simultaneously from multiple external systems.
For example:
- CRM;
- payment service;
- analytics platform;
- internal API.
A synchronous implementation would wait for each request sequentially.
An asynchronous version executes them concurrently:
results = await asyncio.gather(
crm_request(),
payment_request(),
analytics_request(),
internal_request()
)
The total execution time is determined by the slowest request, not the sum of all requests.
This is one of the clearest demonstrations of the advantages of asynchronous programming.
Performance cannot be chosen theoretically
One of the most common mistakes is making architectural decisions based solely on general recommendations.
In practice, performance depends on many factors:
- nature of the workload;
- data volume;
- number of CPU cores;
- network infrastructure;
- library specifics;
- operating system.
Therefore, any assumption must be validated through measurements.
Before optimization, three questions must be answered:
- Where is the bottleneck?
- What exactly is limiting the system?
- What effect will the chosen solution have?
Without this data, any changes become guesswork.
Profiling tools
Python provides many tools for performance analysis.
The most useful include:
| Tool | Purpose |
|---|---|
| py-spy | Profiling running processes |
| cProfile | Built-in Python profiler |
| scalene | CPU, memory, and I/O analysis |
| yappi | Multithreaded application profiling |
| perf | Low-level Linux performance analysis |
py-spy is especially useful because it allows inspection of a running process without modifying source code or stopping execution.
State management remains critical
Regardless of the concurrency model used, careful handling of shared state is essential.
For threads this means:
- using
Lock; - using
RLock; - minimizing critical sections;
- preventing race conditions.
For processes this means:
- using
Queue; - using
Pipe; - careful use of shared memory;
- minimizing data transfer size.
Most concurrency issues are not caused by the choice of tool, but by incorrect state management.
Preparing for the Free-Threaded Python era
The Python ecosystem is gradually moving toward a GIL-free interpreter.
This may significantly change traditional concurrency approaches.
However, it is important to understand that removing the GIL does not eliminate:
- race conditions;
- synchronization issues;
- thread overhead;
- complexity of shared memory management.
Therefore, architectural principles will remain relevant even after widespread adoption of free-threaded CPython.
Conclusion
In practice, concurrency in Python is rarely reduced to a choice between threads, processes, or asynchronous programming.
Most modern systems use multiple mechanisms simultaneously.
The most effective approach looks like this:
- I/O-bound operations → asyncio
- CPU-bound operations → multiprocessing
- Small blocking tasks → threading
- Mixed workloads → hybrid architecture
The ability to correctly separate workloads and choose the right tool for each task is one of the key skills of an engineer working with high-performance Python systems.
This approach enables the creation of applications that remain fast, scalable, and resilient under increasing load.
The Future of Concurrency in Python: What Free Threading Will Change
For the past two decades, almost any discussion about Python performance inevitably came down to the GIL — the Global Interpreter Lock.
This global interpreter lock has shaped many architectural decisions in the Python ecosystem: the choice between threads and processes, the need to use multiprocessing for computation, and even the design of high-load systems.
Today, this situation is beginning to change.
One of the most significant directions in CPython development is the Free Threading project — a new execution model gradually appearing in Python 3.13 and later versions. Its main goal is to remove the dependency of multithreaded applications on the GIL and open the path to true parallelism within a single process.
If the project achieves its goals, it will represent the largest change in Python’s concurrency model in the language’s history.
Why Free Threading is so important
For many years, developers were forced to accept one fundamental trade-off.
If an application works heavily with networking, files, or databases, threads or asynchronous programming can be used.
If the workload is CPU-intensive, processes must be used instead.
The reason is simple: in standard CPython, only one thread can execute Python code at a time.
Even on a server with dozens of cores, multiple threads effectively compete for execution of bytecode.
That is why the standard solutions for CPU-bound workloads became:
multiprocessing;ProcessPoolExecutor;- distributed task queues;
- separate worker processes.
Free Threading aims to remove this limitation at the interpreter level.
What changes without the GIL
In the classic model, thread execution looks like this:
CPU Core 1
│
▼
GIL
▲
│
CPU Core 2
│
CPU Core 3
│
CPU Core 4
Despite having multiple cores, Python execution passes through a single synchronization point.
In the Free Threading model, the situation changes fundamentally:
Core 1 → Thread 1
Core 2 → Thread 2
Core 3 → Thread 3
Core 4 → Thread 4
Now multiple threads can execute Python code simultaneously on different CPU cores.
This enables true multithreaded parallelism within a single process.
For developers, this opens an attractive possibility:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=8) as executor:
...
Such code could potentially utilize all eight CPU cores efficiently even for computational workloads.
What previously required ProcessPoolExecutor may in the future be handled by a standard thread pool.
Why removing the GIL is so difficult
At first glance, the solution seems obvious: simply remove the lock.
In practice, the GIL has served an important role in CPython for many years.
It significantly simplified:
- memory management;
- reference counting;
- object synchronization;
- internal interpreter structures.
After removing the global lock, these mechanisms must be replaced with more complex concurrency controls.
Now the interpreter must guarantee correct object behavior even when dozens of threads access it simultaneously.
In essence, Free Threading is not the removal of a single feature, but a deep redesign of CPython’s internal architecture.
The cost of the new approach
Any synchronization has a cost.
When the GIL disappears, other protection mechanisms take its place.
In early development stages, this led to a predictable outcome: some workloads became slower than in classic CPython.
The reason is straightforward.
If an application does not use multithreading at all, the new synchronization mechanisms introduce overhead without providing any benefit.
That is why early Free Threading versions cannot be considered a direct replacement for standard CPython.
At this stage, it is better described as a new execution model that is still evolving and being optimized.
What this means for existing projects
Many developers expect that removing the GIL will automatically make all applications faster.
In reality, things are more complex.
First, most business applications are not CPU-bound, but I/O-bound:
- databases;
- networking;
- file systems;
- external APIs.
For such systems, performance gains may be minimal.
Second, many projects will require thread-safety audits.
Previously, the GIL implicitly protected some operations from concurrent execution.
After moving to true parallelism, such assumptions may no longer hold.
This means more careful work with:
- locks;
- atomic operations;
- thread-safe data structures;
- shared state.
In other words, removing the GIL does not eliminate concurrency problems.
It simply shifts responsibility for part of them to the developer.
Impact on multiprocessing
One of the most interesting consequences of Free Threading may be a shift in the role of multiprocessing.
Today, processes are often used primarily as a workaround for the GIL limitation.
With true multithreaded parallelism, some of these use cases may move back to threads.
This would eliminate several well-known drawbacks of multiprocessing:
- high process startup cost;
- memory duplication;
- data serialization via pickle;
- complex inter-process communication.
However, processes will not disappear.
They will still be useful for:
- computation isolation;
- fault tolerance;
- distributed systems;
- memory-intensive workloads;
- running independent services.
So rather than eliminating one approach, we are likely to see a redistribution of roles between threads and processes.
What engineers should do now
Although Free Threading is still under active development, preparation can begin today.
It is useful to:
- follow the development of CPython 3.13+;
- test new versions in existing projects;
- analyze thread safety of code;
- minimize hidden dependencies on GIL behavior;
- regularly perform load testing.
It is especially important to remember that many third-party libraries are still adapting to the new execution model.
Therefore, adopting Free Threading will require not only updating the interpreter, but also validating the entire dependency ecosystem.
Will Free Threading change Python?
Most likely — yes.
However, this will not be an immediate revolution.
Rather, it is a long-term evolution of the platform that will gradually change established architectural practices.
Multithreading will become a much more attractive tool for computational workloads.
The role of multiprocessing will partially change.
New approaches to building high-performance systems will emerge.
At the same time, the fundamental principles of concurrency will remain the same: state management, synchronization, and understanding workload characteristics will still determine architecture quality.
Conclusion
Free Threading is one of the most ambitious changes in CPython’s history.
It removes a limitation that has shaped Python application design for decades and opens the possibility of fully utilizing multicore processors within a single process.
However, new capabilities come with new requirements: more complex synchronization, thread-safety considerations, and careful performance analysis.
For engineers, this means one thing: concurrency in Python is entering a new era, and understanding its principles is becoming an even more essential skill than before.