TNS
VOXPOP
As a JavaScript developer, what non-React tools do you use most often?
Angular
0%
Astro
0%
Svelte
0%
Vue.js
0%
Other
0%
I only use React
0%
I don't use JavaScript
0%
NEW! Try Stackie AI
Programming Languages / Python / Software Development

Circumventing Python’s GIL With Asyncio

This tutorial shows how to use asyncio to work around the limitations of Python’s global interpreter lock (GIL) to achieve efficient concurrent programming.
Dec 3rd, 2024 9:00am by
Featued image for: Circumventing Python’s GIL With Asyncio
Featured image via Unsplash.

Python’s global interpreter lock (GIL) plays a crucial role in managing memory safely by limiting execution to a single thread. It’s helpful because Python’s memory isn’t thread-safe, meaning that two threads can’t access the same memory location simultaneously without risking data corruption. The GIL acts as a lock, ensuring that only one thread can execute at a time, which prevents the issue. The GIL takes it one step further than just data corruption and places the order of operations in the developer’s hands rather than leaving it to chance.

That’s great then, right? Sometimes yes and sometimes no. While this mechanism ensures safety, it also presents challenges in a modern, multi-CPU world. Python’s creation in 1991 predates the widespread use of multi-core processors and the massive data generation we deal with today. It also predates the rise of the “home computer room.” Indeed, 1991 was a very long time ago. Though unintended, the GIL creates bottlenecks for CPU-bound tasks (like complex calculations or big data processing) and causes delays in I/O-bound tasks (like server requests), where only one thread can operate at a time.

Given that altering the GIL would break backwards compatibility, developers have found workarounds. Two primary approaches are:

  • Concurrency: This allows tasks to seem like they are running simultaneously, even though they only use one CPU and run one task at a time. It does this through time-slicing, where the system switches between tasks, typically through asynchronous programming or multithreading. Concurrency is particularly useful for I/O-bound tasks, such as when an application waits for a network connection, because it lets other tasks run in the meantime without holding up the entire process.
  • Parallelism: Unlike concurrency, parallelism executes multiple tasks simultaneously, utilizing multiple CPU cores. This is ideal for computationally intensive tasks like image processing or mathematical simulations, as it allows the system to process multiple operations at once.

The focus on the remainder of this post will be on concurrency and how to efficiently manage I/O-bound tasks. In Python, the two main ways to achieve concurrency are via multithreading and `asyncio`. For more on multithreading and multiprocessing, check out this post.

What is `asyncio` and why use it over multithreading?

Asyncio, introduced in Python 3.4 and part of the standard library, provides tools for asynchronous programming using coroutines, event loops and tasks. Though both asyncio and multithreading enable concurrent execution by interleaving tasks, there are several advantages to using asyncio for I/O-bound tasks such as web scraping or asynchronous database queries:

  • Memory efficiency: Threads require their own stack, which can quickly add up in memory usage. Asyncio uses lightweight coroutines that don’t require separate stacks.
  • Fewer bugs: In multithreading, tasks share memory and need synchronization, which can lead to bugs. Since asyncio doesn’t run threads in parallel, it minimizes the risk of such issues. Additionally, asyncio includes structured debugging tools for tracing coroutine execution, making error handling easier.
  • Library compatibility: Many libraries, such as aiohttp and aiomysql, are built to work seamlessly with asyncio, providing efficient tools for handling asynchronous tasks.
  • Simplified syntax: The async/await syntax in asyncio makes asynchronous code more readable and intuitive compared to traditional callback-based systems or thread management.

An example of `asyncio` syntax:

Using Asyncio

At the core of `asyncio` are coroutines, which are special functions that can pause execution to let other tasks run, making them ideal for I/O-bound operations like database queries or API requests. Coroutines are scheduled as tasks using functions like `asyncio.create_task`, `asyncio.gather` and `asyncio.sleep`. For more on this, check out the docs.

In this example, the `asyncio.sleep(1)` call will pause a coroutine for one second, enabling other tasks to be processed during that time. The `asyncio.sleep` call is not bound to one second and can operate within other time frames.

You can also run multiple tasks concurrently using `asyncio.gather`, which lets you execute several coroutines in parallel, collecting their results when they finish. This makes tasks like web scraping or data fetching much more efficient, as multiple HTTP requests can be made at the same time rather than waiting for each one to finish before starting the next.

Data Fetching With Asyncio

Data fetching refers to retrieving data from external sources, such as APIs or databases. With `asyncio` this becomes even more efficient because it allows the program to make multiple requests concurrently, reducing the wait time for I/O operations. The `aiohttp` library, for example, enables asynchronous HTTP requests that can be used in combination with asyncio to speed up data retrieval.

Async Context Managers

Just as traditional context managers ensure resources are properly handled in synchronous code (e.g., opening and closing files), async context managers ensure that resources are managed properly in asynchronous tasks. They’re used with the `async with` syntax and ensure that resources like database connections are acquired and released without blocking the event loop.

Running Background Tasks

Running tasks in the background improves efficiency, especially for I/O-bound tasks. By using `asyncio.create_task`, you can schedule a task to run concurrently in the background, allowing the main program to continue executing without waiting for the task to finish. This is particularly useful when you’re handling multiple requests or processes that would otherwise block the rest of your program’s operations.

Conclusion

While Python’s GIL provides important memory safety, it also introduces challenges for handling multi-CPU tasks and I/O-bound operations. Developers can leverage concurrency through tools like `asyncio` to make I/O-bound operations such as database queries and web scraping more efficient. The GIL is here to stay but there are workarounds.

Group Created with Sketch.
TNS DAILY NEWSLETTER Receive a free roundup of the most recent TNS articles in your inbox each day.