How python's Multithreading differs from other languages
Tags: python, programming, learning
Published: 2023-11-13 05:14:39

Python, like most programming languages, features multithreading. However, unlike many languages, Python's multithreading isn't capable of fully harnessing the potential of the system it operates in. In this post, I will explain why this is the case and how the Global Interpreter Lock (GIL) plays a role in this limitation.

Before we delve deeper into why Python's multithreading has limitations, let's get a couple of key concepts out of the way. This will help us have a better understanding of multithreading in Python.

## What is a process?

![CPU process](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o9kvfwvxdszj32lkx4rt.png)

As programmers, when we write programs and execute them, they become processes running on our CPU. A process has access to its own memory and is isolated from other running processes. At any given time, only one process can be running on a CPU. The image above represents a CPU with four running processes.

So, you might be wondering: if only one process can run at a time, how is it possible for most computers to handle multiple tasks simultaneously? Well, computers have the ability to multitask, and they employ a clever trick that fools us humans into believing they are doing multiple things at the same time. This trick is known as [context switching](https://www.techtarget.com/whatis/definition/context-switch). This technique involves the operating system quickly switching between the currently running processes so fast that we humans perceive it as happening at the same time. 

A process can be single-threaded or multithreaded. All the processes running in our image above are single-threaded.

## Threads explained

![Python threads meets meta](https://media.giphy.com/media/A09gXByJsw4paJmV9A/giphy-downsized.gif)

A process usually performs one task at a time. For instance, consider the program below, which calculates the square of natural numbers from 1 to 100.

```python
def calculate_square():
    for i in range(1, 101):
        square = i ** 2
        print(f"The square of {i} is: {square}")

if __name__ == "__main__":
    calculate_square()
```

When we execute this program, it becomes a process that is performing one task, so we say it is single-threaded. What if we also want to calculate the cube of the numbers at the same time?

Well, that is when we employ multithreading. It allows one process to perform multiple tasks. The program below uses Python's [threading library](https://docs.python.org/3/library/threading.html) to calculate both the square and cube of the numbers.

```python
import threading

def calculate_square():
    for i in range(1, 101):
        square = i ** 2
        print(f"The square of {i} is: {square}")

def calculate_cube():
    for i in range(1, 101):
        cube = i ** 3
        print(f"The cube of {i} is: {cube}")

if __name__ == "__main__":
    square_thread = threading.Thread(target=calculate_square)
    cube_thread = threading.Thread(target=calculate_cube)

    square_thread.start()
    cube_thread.start()

    # Wait for both threads to complete
    square_thread.join()
    cube_thread.join()
```

In the picture below, we can see five running processes, but the green process occurs twice. This shows that it is utilizing threading.

![Multithreaded process](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7ib6bku62o2d2uvana1y.png)

One thread is performing the square task, and the other is performing the cube task. Both threads that belong to the green process can share memory and other resources.

As mentioned before, only one process can run on the CPU. Even if the green process is multi-threaded, only one of its threads can run at a time, and it is left for the operating system to select which one.

You are probably thinking it would be so cool if two threads could run at the same time. Well, that is possible if the CPU has multiple cores.

## Making threads run in parallel

Modern computers have multiple cores, and each core can run its own individual process. So, a CPU with two cores can handle two tasks simultaneously. In theory, this means our multithreaded program, as written above, can run on two separate cores simultaneously. This is called **parallelism**, and it is one of the benefits that come with multithreading.

![Parallelism](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/adwxyn0vj21ycrjs40j2.png)

Now, our CPU has two cores, and our green process still has two threads, with each thread on a different core. One core will be performing the square task, and the other will be performing the cube task, all at the same time.

This is awesome; our program is now more efficient. Well, not quite. If you wrote the program in a language like Java or C++, that would be the case, but not in Python. Threads in Python can't run in parallel because of the **GIL**.

## What exactly is the GIL?

It is the **Global Interpreter Lock**. Basically, what the GIL does is simple; it tells any running Python process that only one of its threads can run at a time, even if it is on a multi-core CPU.

![The GIL Python](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iio49tsmv2c8yh4xrdrk.png)

This is how our multithreaded program will actually work in Python. When one of our green process's threads is running, even if the other thread has the opportunity to run on a different core, it would have to wait for the running thread.
 
This is because the running thread basically grabs the interpreter and locks it, making it impossible for the other thread in the process to have access to the interpreter. The other thread just has to wait for its chance so it can get the interpreter and lock it.

## Why the GIL?

Python has the GIL because it was not designed to be [thread safe](https://en.wikipedia.org/wiki/Thread_safety). Multithreading is a very complex feat to achieve. Consider this scenario: you have two threads, `thread1` and `thread2`, each running on separate cores. They both have access to a list, `my_list = [1, 2, 3]`.

If `thread1` grabs the list and empties it `my_list = []`, then `thread2` tries to access the first index of an empty list, thinking it still has values within it, that will be a problem. This is an example of a [race condition](https://www.techtarget.com/searchstorage/definition/race-condition).

The Python list and other objects in Python weren't designed to be used in a multithreaded environment; that's what it means not to be thread-safe. That is why the Python team decided to lock everything so errors like these won't occur.

## How to escape the GIL

There are several ways of bypassing the GIL. First of all, the GIL is only present in the C implementation of Python, [CPython](https://en.wikipedia.org/wiki/CPython). Other implementations of Python like [Jython](https://www.jython.org/), [IronPython](https://ironpython.net/), and [PyPy](https://www.pypy.org/) don't have the GIL. Additionally, Python provides the [multiprocessing library](https://docs.python.org/3/library/multiprocessing.html#module-multiprocessing), which allows for parallelism in your Python program.

Also [PEP 703](https://peps.python.org/pep-0703/) proposes making the GIL optional in CPython during build time.

## Is multithreading useless in python

So, what's the point of having multithreading in Python, you might ask. Multithreading isn't completely useless in Python because it is useful when working on [I/O-bound tasks](https://en.wikipedia.org/wiki/I/O_bound). Imagine if you have two threads: one is performing a massive computation, and another is waiting for the user's input. The thread waiting for the user's input can yield control to the computation thread while the user is providing their input. The same applies when a thread is trying to access the file system, a remote database, or a network. This is where threading shines in Python.