Concurrency in Python is the ability to make progress on multiple tasks during overlapping time periods. When your program reads data from a file, makes an HTTP request, or waits for a database query, the CPU sits idle. Concurrency lets you use that idle time to work on something else, so your program finishes faster without running any single line of code faster than before.
Python gives you three distinct ways to achieve concurrency: threading, asynchronous programming with async and await, and multiprocessing. Each approach has different tradeoffs, and understanding those tradeoffs is essential before you add concurrency to a real program.
The three models address the same core problem from different angles. Threading lets you run multiple functions in parallel within a single Python process, with the operating system deciding when to switch between them. Asyncio lets you cooperatively multitask within a single thread, where each function explicitly yields control when it would otherwise wait for I/O.
Multiprocessing spawns entirely separate Python interpreter processes, each with its own memory and its own GIL, which sidesteps the threading limitations for CPU-heavy work. The next article explores the differences between threads, processes, and async in Python in more detail so you can choose the right tool for your workload.
A common beginner misconception is that concurrency always makes programs faster. Concurrency helps when your program spends significant time waiting. If your program is already CPU-bound and uses every core at full capacity, adding threading or asyncio will not help and may even slow things down due to coordination overhead.
The first step before reaching for concurrency is identifying whether your bottleneck is I/O, CPU, or something else entirely. If you have not yet profiled your program, the section on Python performance optimization covers the tools you need before adding concurrency.
Why Python needs different concurrency models
Most programming languages offer one primary concurrency mechanism. Python offers three because each one works around a different limitation of the language runtime. The central constraint is the Global Interpreter Lock, or GIL, which is a mutex that protects access to Python objects and prevents multiple threads from executing Python bytecode at the same time.
The GIL exists because Python's memory management, specifically reference counting, is not thread-safe by default. Without the GIL, two threads could decrement the same object's reference count simultaneously, corrupting memory.
The GIL does not prevent all concurrency. It only prevents multiple threads from running Python code at the exact same instant within a single process. When a thread performs I/O, it releases the GIL so other threads can run.
This is why threading works well for I/O-bound programs like web scrapers, API clients, and file processors. The threads take turns holding the GIL, and most of their time is spent waiting for external resources where the GIL is not needed.
For CPU-bound programs that do heavy computation in Python, the GIL is a real bottleneck. A single-threaded CPU-bound program can only use one core at a time, and adding threads will not spread the work across multiple cores because only one thread can hold the GIL at any moment. This is where multiprocessing comes in: each process has its own Python interpreter and its own GIL, so multiple processes can run Python code simultaneously on different CPU cores.
Asyncio takes yet another approach. It runs everything in a single thread and uses an event loop to switch between tasks cooperatively. Because there is only one thread, there is no GIL contention, and context switching is much cheaper than with OS threads.
The tradeoff is that every piece of code in an asyncio program must be written to cooperate with the event loop. A single blocking call can stall the entire program.
The three models at a glance
Threading
Threading creates multiple threads of execution within a single Python process. Each thread runs independently, and the operating system preemptively switches between them. You write threading code using the threading module from the standard library.
import threading
import time
def fetch_data(source_name, duration):
print(f"Fetching {source_name}...")
time.sleep(duration)
print(f"{source_name} done.")
start = time.perf_counter()
t1 = threading.Thread(target=fetch_data, args=("API", 2))
t2 = threading.Thread(target=fetch_data, args=("Database", 1))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Total: {time.perf_counter() - start:.2f}s")Running this script starts both downloads immediately instead of waiting for one to finish before starting the next, so the two print statements interleave right away instead of appearing one after another:
Fetching API...
Fetching Database...
Database done.
API done.
Total: 2.00sBoth fetch_data calls ran concurrently. Without threading, the second call would wait for the first to finish, and the total time would be 3 seconds. With threading, the total time equals the longest individual task, roughly 2 seconds.
Asyncio
Asyncio uses an event loop that runs in a single thread. You mark functions with async def and use await to yield control while waiting for I/O. The asyncio module orchestrates which task runs next.
import asyncio
import time
async def fetch_data(source_name, duration):
print(f"Fetching {source_name}...")
await asyncio.sleep(duration)
print(f"{source_name} done.")
async def main():
start = time.perf_counter()
await asyncio.gather(
fetch_data("API", 2),
fetch_data("Database", 1),
)
print(f"Total: {time.perf_counter() - start:.2f}s")
asyncio.run(main())The output is equivalent: both tasks run concurrently, and the total time is the longest single duration. The difference is that asyncio uses cooperative multitasking rather than preemptive OS scheduling, which gives you finer control over when tasks yield and avoids the overhead of thread creation and context switching.
Multiprocessing
Multiprocessing spawns separate OS processes, each with its own Python interpreter and memory space. Use the multiprocessing module when your work is CPU-bound and you need to use multiple cores.
import multiprocessing
import time
def compute_square(n):
time.sleep(1)
return n * n
if __name__ == "__main__":
start = time.perf_counter()
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(compute_square, [1, 2, 3, 4])
print(results)
print(f"Total: {time.perf_counter() - start:.2f}s")Each worker process computes one square and returns it to the pool, so results comes back in the same order as the input list even though the four computations ran in parallel:
[1, 4, 9, 16]
Total: 1.01sFour processes compute four squares in parallel, each on its own CPU core. The total time is roughly the time for one computation rather than four times that. If this program used threading, the GIL would prevent the computations from running in parallel, and the total time would be closer to 4 seconds.
How the GIL shapes every concurrency decision
The GIL is the single most important fact to internalize before choosing a concurrency approach in Python. When a thread acquires the GIL, it can execute Python bytecode. The interpreter releases and reacquires the GIL periodically so that other threads get a chance to run.
In CPython 3.2 and later, the GIL uses a timing-based mechanism: a thread holds the GIL for a short interval, typically 5 milliseconds by default, and then releases it so another thread can take over.
For I/O-bound work, the GIL is rarely a problem. When a thread calls socket.recv(), file.read(), time.sleep(), or any other blocking I/O operation, it releases the GIL before blocking and reacquires it when the operation completes. During that gap, other threads can run Python code freely.
This is why a threaded web scraper that spends most of its time waiting for HTTP responses can achieve near-linear speedups even on a single-core machine.
For CPU-bound work, the GIL is a hard limit on threading. If every thread is doing pure Python computation, only one thread runs at a time regardless of how many CPU cores you have. The thread switching overhead can even make the multithreaded version slower than the single-threaded version.
This is the scenario where multiprocessing is the correct answer. Each process has its own GIL and can run on a separate core without contention. The cost is that processes are heavier to create than threads, and sharing data between processes requires explicit serialization.
The GIL story is evolving. PEP 703 introduced the option to build CPython without the GIL, and CPython 3.13 shipped it as an experimental build. PEP 779 promoted the free-threaded build to officially supported status in CPython 3.14, though it still ships as a separate build (tagged 3.14t) rather than the default.
As of July 2026, no version has committed to making free-threading the default, so most production code should still assume the GIL is present unless you explicitly install and target a free-threaded build.
Choosing the right model for your workload
I/O-bound workloads: threading or asyncio
When your program spends most of its time waiting for network responses, disk reads, or database queries, both threading and asyncio can deliver significant speedups. The choice between them depends on your codebase and dependencies.
Use threading when your existing code is synchronous and you cannot or do not want to rewrite it with async and await. Threading works with any Python library, including those written in C that release the GIL during blocking calls. The concurrent.futures.ThreadPoolExecutor provides a high-level interface that is easier to use correctly than raw threading.Thread.
Use asyncio when you need to handle hundreds or thousands of concurrent connections efficiently. Asyncio avoids the memory and context-switching overhead of OS threads, which matters at very high concurrency levels. It also gives you finer control over task scheduling.
The tradeoff is that asyncio requires async-aware libraries: you cannot call a synchronous blocking function from an async task without wrapping it in an executor, and doing so incorrectly can stall your entire event loop.
CPU-bound workloads: multiprocessing
When your program does heavy computation in Python, such as image processing, numerical analysis, or data transformation, multiprocessing is the correct choice. Each process runs on a separate core, and the GIL does not limit parallelism between processes.
from concurrent.futures import ProcessPoolExecutor
import math
def is_prime(n):
if n < 2:
return False
for i in range(2, int(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True
numbers = [10_000_000 + i for i in range(100)]
if __name__ == "__main__":
with ProcessPoolExecutor() as executor:
results = list(executor.map(is_prime, numbers))The ProcessPoolExecutor distributes the work across available CPU cores. Each worker process calls is_prime independently, and the results are collected as they complete. This pattern works well when the work units are independent and large enough that the serialization overhead does not dominate.
Mixed workloads: combine models carefully
Some real programs have both I/O and CPU work. A web server might receive a request, query a database, then transform the result with CPU-heavy computation. In these cases, you can combine models, but do it deliberately.
A common pattern is to use asyncio for the I/O layer and offload CPU work to a process pool from within the event loop.
import asyncio
from concurrent.futures import ProcessPoolExecutor
process_pool = ProcessPoolExecutor()
def cpu_heavy_transform(data):
return sum(i * i for i in range(data))
async def handle_request(data):
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(process_pool, cpu_heavy_transform, data)
return resultThe run_in_executor method runs the CPU-bound function in a separate process while the event loop continues handling other requests. This pattern keeps the asyncio event loop responsive while still using multiple cores for computation. Create the process pool once and reuse it across requests, since spinning up a new one on every call adds unnecessary overhead and leaves old pools unclosed.
Concurrency pitfalls to avoid
Concurrency introduces whole categories of bugs that do not exist in sequential code. The most common pitfalls are race conditions, deadlocks, and shared-state corruption.
A race condition occurs when two threads or tasks access shared data and the result depends on the order of access. If thread A reads a counter, thread B increments it, and then thread A writes its stale value, the increment is lost. The threading module provides locks, semaphores, and other synchronization primitives to prevent this, and synchronizing Python threads with locks covers them in detail.
A deadlock occurs when two threads each hold a resource the other needs and neither can proceed. Thread A holds lock 1 and waits for lock 2 while thread B holds lock 2 and waits for lock 1. Both threads block forever.
Deadlocks are prevented by always acquiring locks in a consistent order and by using timeout mechanisms.
Shared-state corruption is subtler. Even a simple operation like count += 1 is not atomic in Python. It reads the value, increments it, and writes it back.
If two threads interleave these steps, one increment can overwrite the other. Use threading.Lock or an asyncio.Lock around any shared mutable state, or restructure your code so that threads communicate through thread-safe queues rather than shared variables.
What the rest of this section covers
This section builds from concepts to practical implementations. The next article compares threads, processes, and async side by side with concrete benchmarks so you can see the performance differences for yourself. After that, you will learn how Python threading works under the hood, including thread lifecycle, the GIL's behaviour during I/O, and daemon threads.
The section then moves into hands-on articles where you create and manage threads, synchronize them with locks, communicate through queues, and eventually build real concurrent programs.
If you are new to concurrency, start with the next article to solidify your understanding of the three models. If you already know which model you need, skip ahead to the relevant hands-on article. Every article in this section assumes you are comfortable with Python functions, error handling, and the standard library basics covered in earlier sections.
Rune AI
Key Insights
- Concurrency means making progress on multiple tasks during overlapping time, not necessarily at the same instant.
- Python offers three concurrency models: threading for I/O-bound work, asyncio for high-concurrency I/O, and multiprocessing for CPU-bound parallelism.
- The GIL prevents multiple threads from running Python bytecode simultaneously, but threads release the GIL during I/O operations.
- Choosing the right concurrency model depends on whether your bottleneck is I/O, CPU, or both.
- Starting with the simplest approach that solves your actual performance problem is always better than adding concurrency preemptively.
Frequently Asked Questions
What is concurrency in Python?
Does the Python GIL mean Python cannot do concurrency?
Should I use threading or asyncio for I/O-bound Python programs?
Conclusion
Python concurrency is not one size fits all. Threading gives you overlapping I/O with a familiar programming model. Asyncio gives you high-throughput I/O with cooperative multitasking. Multiprocessing gives you true parallelism for CPU-bound work. Learning when to apply each approach, and understanding how the GIL shapes those choices, is the difference between a program that barely uses your hardware and one that makes the most of it.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.