Multithreading allows a process to run multiple threads at the same time, and the threads share the same memory and resources (see diagrams 2 and 4).
However, Python's global interpreter lock (GIL) limits the effectiveness of multithreading for CPU-bound tasks.
Python Global Interpreter Lock (GIL)
The GIL is a lock that allows only one thread to maintain control of the Python interpreter at any time, meaning that only one thread can execute Python bytecode at a time.
GIL was introduced to simplify memory management in Python, since many internal operations, such as object creation, are not thread-safe by default. Without a GIL, multiple threads attempting to access shared resources will require complex locks or synchronization mechanisms to prevent race conditions and data corruption.
When is GIL a bottleneck?
- For single-threaded programs, the GIL is irrelevant since the thread has exclusive access to the Python interpreter.
- For I/O-bound multithreaded programs, the GIL is less problematic since threads release the GIL when waiting for I/O operations.
- For CPU-bound multithreaded operations, GIL becomes a major bottleneck. Multiple threads competing for the GIL must take turns executing the Python bytecode.
An interesting case worth mentioning is the use of time.sleep
which Python effectively treats as an I/O operation. He time.sleep
The function is not CPU bound because it does not involve active computation or execution of Python bytecode during the sleep period. Instead, the responsibility of tracking elapsed time is delegated to the operating system. During this time, the thread releases the GIL, allowing other threads to run and use the interpreter.
Multiprocessing allows a system to run multiple processes in parallel, each with its own memory, GIL, and resources. Within each process, there may be one or more threads (see diagrams 3 and 4).
Multiprocessing overcomes the limitations of the GIL. This makes it suitable for CPU-bound tasks that require a large amount of computation.
However, multiprocessing consumes more resources due to separate memory and process overhead.
Unlike threads or processes, asyncio uses a single thread to handle multiple tasks.
When writing asynchronous code with the asyncio
library, you will use the async/await
keywords to manage tasks.
Key concepts
- Coroutines: These are functions defined with
async def
. They are the core of asyncio and represent tasks that can be paused and resumed later. - Event loop: Manage the execution of tasks.
- Tasks: Wrappers around coroutines. When you want a routine to start running, you convert it to a task, for example. wearing
asyncio.create_task()
await
: Pauses the execution of a coroutine, returning control to the event loop.
how it works
Asyncio runs an event loop that schedules tasks. Tasks voluntarily “pause” when they wait for something, such as a network response or reading a file. While the task is paused, the event loop switches to another task, ensuring that no time is wasted waiting.
This makes asyncio ideal for scenarios involving many small tasks that spend a lot of time waitinglike handling thousands of web requests or managing database queries. Since everything runs in a single thread, asyncio avoids the overhead and complexity of thread switching.
The key difference between asyncio and multithreading lies in how they handle waiting tasks.
- Multithreading depends on the operating system to switch between threads when a thread is waiting (preventive context change).
When a thread is waiting, the operating system automatically switches to another thread. - Asyncio uses a single thread and relies on tasks to “cooperate” by pausing when they need to wait (cooperative multitasking).
2 ways to write asynchronous code:
method 1: await coroutine
when you directly await
a coroutine, the execution of the current routine breaks to the await
statement until the expected routine finishes. The tasks are executed. sequentially inside the current coroutine.
Use this approach when you need the result of the coroutine. immediately to continue with the next steps.
Although this may seem like synchronous code, it is not. In synchronous code, the entire program would hang during a pause.
With asyncio, only the current routine is stopped, while the rest of the program can continue running. This makes asyncio non-blocking at the program level.
Example:
The event loop pauses the current routine until fetch_data
is complete.
async def fetch_data():
print("Fetching data...")
await asyncio.sleep(1) # Simulate a network call
print("Data fetched")
return "data"async def main():
result = await fetch_data() # Current coroutine pauses here
print(f"Result: {result}")
asyncio.run(main())
method 2: asyncio.create_task(coroutine)
The coroutine is scheduled to run simultaneously in the background. Unlike await
the current routine continues executing immediately without waiting for the scheduled task to finish.
The scheduled routine starts executing as soon as the event loop finds an opportunity.without waiting for an explicit response await
.
No new threads are created; instead, the coroutine runs within the same thread as the event loop, which manages when each task gets execution time.
This approach allows for concurrency within the program, allowing multiple tasks to overlap in their execution efficiently. Later you will need await
the task to obtain its result and ensure that it is done.
Use this approach when you want to run tasks simultaneously and don't need the results right away.
Example:
when the line asyncio.create_task()
is reached, the coroutine fetch_data()
is scheduled to start working immediately when the event loop is available. This can even happen before you explicitly await
the task. However, in the first await
method, the coroutine only starts executing when the await
the declaration is reached.
In general, this makes the program more efficient by overlapping the execution of multiple tasks.
async def fetch_data():
# Simulate a network call
await asyncio.sleep(1)
return "data"async def main():
# Schedule fetch_data
task = asyncio.create_task(fetch_data())
# Simulate doing other work
await asyncio.sleep(5)
# Now, await task to get the result
result = await task
print(result)
asyncio.run(main())
Other important points
- You can mix synchronous and asynchronous code.
Since the synchronous code is blocking, it can be offloaded to a separate thread usingasyncio.to_thread()
. This makes your program effectively multithreaded.
In the following example, the asyncio event loop is executed in the main thread, while a separate background thread is used to execute thesync_task
.
import asyncio
import timedef sync_task():
time.sleep(2)
return "Completed"
async def main():
result = await asyncio.to_thread(sync_task)
print(result)
asyncio.run(main())
- You should offload compute-intensive CPU-bound tasks to a separate process.
This flow is a good way to decide when to use what.
- Multiprocessing
– Best for compute-intensive CPU-bound tasks.
– When you need to bypass the GIL: each process has its own Python interpreter, allowing true parallelism. - multi-threading
– Best for fast I/O-bound tasks, as context switching frequency is reduced and the Python interpreter sticks to a single thread for longer.
– Not ideal for CPU-bound tasks due to GIL. - assent
– Ideal for slow I/O-bound tasks, such as long network requests or database queries, because it efficiently handles waiting, making it scalable.
– Not suitable for CPU-bound tasks without offloading work to other processes.
That's all friends. There is a lot more to cover on this topic, but I hope I have introduced you to the various concepts and when to use each method.
Thanks for reading! I write regularly about Python, software development, and the projects I build, so follow me so you don't miss out. See you in the next article 🙂