July 14, 2022

Multithreading in Python: The Ultimate Guide (with Coding Examples)

In this tutorial, we'll show you how to achieve parallelism in your code by using multithreading techniques in Python.

"Parallelism," "multithreading"— what do these terms mean, and how do they relate? We'll answer all your questions in this tutorial, including the following:

  • What's concurrency?
  • What's the difference between concurrency and parallelism?
  • What's the difference between processes and threads?
  • What's multithreading in Python?
  • What's the Global Interpreter Lock?

We assume here that you know the fundamentals of Python, including basic structures and functions. If you're unfamiliar with these (or eager to brush up), try our Python Basics for Data Analysis – Dataquest.

What Is Concurrency?

Before we jump into the multithreading details, let's compare concurrent programming to sequential programming.

In sequential computing, the components of a program are executed step-by-step to produce correct results; however, in concurrent computing, different program components are independent or semi-independent. Therefore, a processor can run components in different states independently and simultaneously. The main advantage of concurrency is in improving a program's runtime, which means that since the processor runs the independent tasks simultaneously, less time is necessary for the processor to run the entire program and accomplish the main task.

What's the Difference Between Concurrency and Parallelism?

Concurrency is a condition wherein two or more tasks can be initiated and completed in overlapping periods on a single processor and core. Parallelism is a condition wherein multiple tasks or distributed parts of a task run independently and simultaneously on multiple processors. So, parallelism isn't possible on machines with a single processor and a single core.

Imagine two queues of customers; concurrency means a single cashier serves customers by switching between two queues. Parallelism means two cashiers simultaneously serve the two queues of customers.

What's the Difference Between Processes and Threads?

A process is a program in execution with its own address space, memory, data stack, etc. The operating system allocates resources to the processes and manages the processes' execution by assigning the CPU time to the different executing processes, according to any scheduling strategy.

Threads are similar to processes. However, they execute within the same process and share the same context. Therefore, sharing information or communicating with the other threads is more accessible than if they were separate processes.

Multithreading in Python

Python virtual machine is not a thread-safe interpreter, meaning that the interpreter can execute only one thread at any given moment. This limitation is enforced by the Python Global Interpreter Lock (GIL), which essentially limits one Python thread to run at a time. In other words, GIL ensures that only one thread runs within the same process at the same time on a single processor.

Basically, threading may not speed up all tasks. The I/O-bound tasks that spend much of their time waiting for external events have a better chance of taking advantage of threading than CPU-bound tasks.


NOTE

Python comes with two built-in modules for implementing multithreading programs, including the thread, and threading modules. The thread and threading modules provide useful features for creating and managing threads. However, in this tutorial, we'll focus on the threading module, which is a much-improved, high-level module for implementing serious multithreading programs. Also, Python provides the Queue module, allowing us to create a queue data structure to exchange information across multiple threads safely.


Let's begin with a simple example to understand the benefits of using multithreading programming.

Suppose we have a list of integers, and we're going to calculate the square and cube of each of these numbers and then print them out on the screen.

This program includes two separate tasks (functions) as follows:

import time
def calc_square(numbers):
    for n in numbers:
        print(f'\n{n} ^ 2 = {n*n}')
        time.sleep(0.1)

def calc_cube(numbers):
    for n in numbers:
        print(f'\n{n} ^ 3 = {n*n*n}')
        time.sleep(0.1)

The code above implements two functions, calc_square() and calc_cube(). The calc_square() function calculates the square of each number in the list, and the calc_cube() calculates the cube of each number in the list. The time.sleep(0.1) statement in the body of the functions suspends the code execution for 0.1 seconds at the end of each iteration. We added this statement to the functions to make the CPU idle for a moment and simulate an I/O-bound task. In real-world scenarios, an I/O-bound task may wait for a peripheral device or a web service response.

numbers = [2, 3, 5, 8]
start = time.time()
calc_square(numbers)
calc_cube(numbers)
end = time.time()

print('Execution Time: {}'.format(end-start))

As you've seen, the sequential execution of the program takes almost one second. Now let's use the CPU idle time, using the multithreading technique, and reduce the total execution time. The multithreading technique reduces the runtime by allocating the CPU time to a task while the other tasks are waiting for I/O responses. Let's see how it works:

import threading

start = time.time()

square_thread = threading.Thread(target=calc_square, args=(numbers,))
cube_thread = threading.Thread(target=calc_cube, args=(numbers,))

square_thread.start()
cube_thread.start()

square_thread.join()
cube_thread.join()

end = time.time()

print('Execution Time: {}'.format(end-start))
    2 ^ 2 = 4

    2 ^ 3 = 8

    3 ^ 3 = 27

    3 ^ 2 = 9

    5 ^ 3 = 125
    5 ^ 2 = 25

    8 ^ 2 = 64
    8 ^ 3 = 512

    Execution Time: 0.4172379970550537

Fantastic, the execution time is less than half a second, and it's a considerable improvement, thanks to multithreading. Let's explore the code line by line.

First, we need to import the threading module, a high-level threading module with various useful features.

We use the Thread constructive method to create a thread instance. In this example, the Thread method takes two inputs, the function name (target) and its arguments (args), as a tuple. This function is what will be executed when a thread begins execution. When we instantiate the Thread class, the Thread constructive method will be invoked automatically, and it will create a new thread. But the new thread won't begin execution immediately, which is a valuable synchronization feature that lets us start the threads once all of them have been allocated.

To begin the thread execution, we need to call each thread instance's start method separately. So, these two lines execute the square and cube threads concurrently:

square_thread.start()
cube_thread.start()

The last thing we need to do is call the join method, which tells one thread to wait until the other thread's execution is complete:

square_thread.join()
cube_thread.join()

While the sleep method suspends the execution of the calc_square() function for 0.1 seconds, the calc_cube() function is executed and prints out the cube of a value in the list, then it goes into a sleep, and the calc_square() function will be executed. In other words, the operating system switches back and forth between the threads, running each one a little bit at a time, which leads to an improvement in the runtime of the entire process.

The threading Methods

The Python threading module provides some useful functions that help you manage our multithreading program efficiently:

**Method Name** **Description** Method’s Result
threading.active_count() returns the number of Threads currently alive 8
threading.current_thread() returns the current Thread, corresponding to the caller’s thread of control <_MainThread(MainThread, started 4303996288)>
threading.enumerate() returns a list of all Threads currently alive, including the main thread; terminated threads and threads that have not yet been started are excluded [<_MainThread(MainThread, started 4303996288)>,<Thread(Thread-4 (_thread_main), started daemon 6182760448)>,<Heartbeat(Thread-5, started daemon 6199586816)>,<Thread(Thread-6 (_watch_pipe_fd), started daemon 6217560064)>,<Thread(Thread-7 (_watch_pipe_fd), started daemon 6234386432)>, <ControlThread(Thread-3, started daemon 6251212800)>, <HistorySavingThread(IPythonHistorySavingThread, started 6268039168)>,<ParentPollerUnix(Thread-2, started daemon 6285438976)>]
threading.main_thread() returns the main thread <_MainThread(MainThread, started 4303996288)>

Conclusion

Multithreading is a broad concept in advanced programming to implement high-performance applications, and this tutorial touched on the basics of multithreading in Python. We discussed the basic terminology of concurrent and parallel computing and then implemented a basic multithreaded program for calculating the square and cube of a list of numbers. We will discuss a few more advanced multithreading techniques in the future.

Mehdi Lotfinejad

About the author

Mehdi Lotfinejad

Mehdi is a Senior Data Engineer and Team Lead at ADA. He is a professional trainer who loves writing data analytics tutorials.