Multithreading in Python

November 30, 2018

Often we build applications which might require several tasks to run simultaneously within the same application. This is where the concept of multithreading in python comes into play. This post provides a comprehensive explanation of using Multithreading in Python aka Threading in Python.

Introduction

Multithreading in Python or Threading in python is a concept by which mutliple threads are launched in the same process to achieve parallelism and multitasking within the same application. Executing different threads are equivalent to executing different programs or different functions within the same process.

This post will cover how threading in python works with the presence of GIL or Global Interpreter Lock. Also, it will illustrate how a pool of threads in python can be created using a python threadpoolexecutor or python threadpool.

Table of Contents

  • What is Multithreading or Threading
  • When to use Multithreading in Python
  • Threading Example in Python
  • Global Interpreter Lock or GIL in Python
    • How does threading work in Python?
  • What is Join in Threading
  • Python Multithreading - Thread Pool
    • Using ThreadPoolExecutor in Python
    • Using ThreadPool in Python
  • Synchronization between threads - Thread Lock
  • When to not use Python Multithreading
  • Conclusion

What is Multithreading(Threading) ?

Python Multithreading can be simply understood as executing multiple threads simultaneously within the same process. These threads share the same memory space as the process.
For example, a GUI such as Pycharm or a Jupyter Notebook keeps autosaving your code as and when you make changes, which clearly illustrates multiple tasks being performed within the same process.

image.png

Python Threading is generally preferred for lightweight processes since multiple tasks are run within the same process and uses the memory allocated for that process (i.e.) uses the memory space of that process.

Multithreading in python shouldn’t be confused with multiprocessing because multiprocessing is where two or more processes run in a single application without having a shared state within them because they run as different independent processes.

To read more about multiprocessing in python, please click here

When to use Multithreading in Python ?

Multithreading in python can be used for multiple use-cases, some of which are mentioned below,

  • Threads are most suitable for lightweight tasks.
  • When you have multiple tasks which are more I/O bound.
  • Creating a lag free application with a backend database connection.
  • When you want to make multiple calls to an API.
  • When you want to download/upload data from/to a server.
  • When you have objects being shared across the same application.

Important Note: It is highly recommended to use multithreading in python for I/O bound tasks alone due to the presence of GIL in CPython which is explained in detail below.

Threading Example in Python

To create a thread in python, you simply call the threading.Thread method as shown below,

Below example illustrates how to create a thread in python and how a function can be called inside a thread,

import threading

def new_function(a, b, kw2=10, kw1=None)
    print("Hello")

# Create a thread

new_thread = threading.Thread(target=function_name, args=(arg1, arg2), kwargs={'kw1': 1, 'kw2': '2'})

To start the above thread, run the below command,

new_thread.start()

Now that we have understood how to create and start a thread, let’s go ahead with another simple example of an application where you connect to different URLs using the requests module by launching 2 threads in python.

Note: This is just a just a simple example for the purpose of demonstrating threading in python.

import threading
import requests

def connect_to_urls(urls):
    for url in urls:
        r = requests.get(url)
        print(f"{url} - Status: {r.status_code}")

if __name__ == '__main__':
    
    url_list = ['https://google.com', 'https://youtube.com', 'https://wikipedia.com', 'https://medium.com']
    print(f"URL List: {url_list}")
    
    t1 = threading.Thread(target=connect_to_urls, args=(url_list[:2],)) # Create Thread 1
    t2 = threading.Thread(target=connect_to_urls, args=(url_list[2:],)) # Create Thread 2

    t1.start()  # Thread 1 starts here
    t2.start()  # Thread 2 starts here
URL List: ['https://google.com', 'https://youtube.com', 'https://wikipedia.com', 'https://medium.com']
https://wikipedia.com - Status: 200
https://google.com - Status: 200
https://medium.com - Status: 200
https://youtube.com - Status: 200

Global Interpreter Lock or GIL in Python

Before we get started with the actual threading modules in python, let’s first understand what GIL in python is and how it plays a role in python multithreading.

Global Interpreter Lock, otherwise known as GIL in Python is a mutex (or lock) which ensures only a single thread executes Python’s bytecode at any instant. It ensures thread safety to python’s objects and enables a single threaded access to the python’s interpreter.

GIL is an implementation of CPython, because of which true multithreading cannot be achieved in Python. Hence, if CPU bounds tasks are multi-threaded it can lead to increased execution times, which is why it is recommended to use multithreading in python for I/O bound tasks alone.

When a thread is running, it gets hold of the GIL while performing it’s task(s). This GIL is released during I/O operations which involve some waiting time such as read, write, send, download etc.

How does threading work in Python

CPython has the Global Interpreter Lock (GIL), which hinders multithreading in Python because at any moment only a single thread can acquire the GIL to work on Python’s bytecode. So now the question arises as to how concurrency is achieved in Python via threads.

Context switching takes place between the threads to enable some sort of concurrency. For example, imagine a requirement wherein you would have to run 10 different queries in a database independently. Each SQL query would take some time to complete it’s execution, so this wait time (I/O) is when thread switching takes place to enable concurrency.

To understand how threading in python works, in more detail, we have to take into consideration the type of tasks being run, namely, I/O Bound Tasks and CPU Bound Tasks

  • I/O Bound Tasks

I/O bound tasks are tasks which involve some waiting time or delay for the operations to complete. Some examples of such tasks include, reading or writing data to a database/disk, downloading/uploading data etc.

For such tasks, whenever there is a wait time involved, GIL is released by the current thread and the next thread on queue will acquire the GIL and begin it’s execution. Let’s understand this better with an example.

Below is a pictorial representation of how threading works in python.

image.png

For our example, let’s consider 2 threads and a function which includes a 1 second sleep.

import threading
import time
import sys

def display_time(thread):
    """
    Display the time after a 1 second delay
    """
    print(f"Thread {thread} - Beginning to sleep at {time.time()}")
    time.sleep(1)
    print(f"Thread {thread} - Sleep Complete at {time.time()}")
    

if __name__ == '__main__':
    
    t1 = threading.Thread(target=display_time, args=[1])
    t2 = threading.Thread(target=display_time, args=[2])
    start = time.time()
    t1.start()
    t2.start()

    t1.join()
    t2.join()
    
    print(f"We are at the end of the program. Time Taken: {time.time() - start}")
Thread 1 - Beginning to sleep at 1590616707.353773
Thread 2 - Beginning to sleep at 1590616707.3544252
Thread 1 - Sleep Complete at 1590616708.354223
Thread 2 - Sleep Complete at 1590616708.359642
We are at the end of the program. Time Taken: 1.006382942199707
  • CPU Bound Tasks

A task is CPU bound if the time it takes for its completion depends on the speed of the CPU. Some examples of CPU bound tasks include number crunching tasks, mathematical operations, calculations etc.

For such tasks, since there is no wait time involved here, thread switching takes place every 0.005 seconds which is determined by the sys.getswitchinterval. This can also be modified manually using sys.setswitchinterval. You can read more about it here.

Note: This type of thread switching after a specific time interval is defined from Python 3.2 onwards.

Let’s understand this better with an example. Here we will run a function which will decrement the value of counter until it reaches zero. We will be running the same function once again after increasing thread switch interval and see the results.

Important Note: It is not recommended to use multithreading in python for CPU bound applications as it might lead to performance degradations.

import threading
import time
import sys


def decrement(t):
    """
    Increment val 100000 times
    """
    start = time.time()
    print(f"Thread {t} in decrement at {start}")
    counter = 100000000
    while counter > 0:
        counter -= 1
    end = time.time()
    print(f"Thread {t} ended at {end}, Time Taken: {end - start}")  
    

def run_threads():
    t1 = threading.Thread(target=decrement, args=(1,))
    t2 = threading.Thread(target=decrement, args=(2,))
    
    start = time.time()
    t1.start()
    t2.start()
    
    t1.join()
    t2.join()

    print(f"We are at the end of the program. Total Time Taken: {time.time() - start}")

    
if __name__ == '__main__':
    print(f"Switch Interval is {sys.getswitchinterval()} seconds")
    run_threads()
    
    sys.setswitchinterval(10)
    print(f"\nSwitch Interval set to {sys.getswitchinterval()} seconds")

    run_threads()
    sys.setswitchinterval(0.005) # Setting it back to it's original value
Switch Interval is 0.005 seconds
Thread 1 in decrement at 1590616384.611721
Thread 2 in decrement at 1590616384.617289
Thread 2 ended at 1590616396.703609, Time Taken: 12.086319923400879
Thread 1 ended at 1590616396.8213172, Time Taken: 12.209596157073975
We are at the end of the program. Total Time Taken: 12.210150241851807

Switch Interval set to 10.0 seconds
Thread 1 in decrement at 1590616396.822057
Thread 1 ended at 1590616402.9130921, Time Taken: 6.0910351276397705
Thread 2 in decrement at 1590616402.913912
Thread 2 ended at 1590616408.976337, Time Taken: 6.062424898147583
We are at the end of the program. Total Time Taken: 12.155883073806763

What is join in Threading ?

Before we define what join in threading is, let’s analyse our problem statement.

There is an application running with 2 threads in parallel. Thread 1 completes 30 seconds faster than thread 2.

So, upon completion of the first thread, would the program exit or wait for 2nd thread to complete ?

Well, if you guessed the program would terminate, then you are right. This is where the magic of join method of a thread comes into play.

Join basically makes the program to wait for the thread to finish. So, an additional join after starting the threads would make the application wait to successfully complete all the threads in it.

Let’s make more sense of join with the below 2 examples,

Example 1 : Threading in python without Join

Let’s create a program to display the time thrice with a 0.5 second delay and have some code after the threads.

import threading
import time
import sys


def display_time(val):
    """
    Display the time 3 times 
    with a 0.5 second delay
    """
    for i in range(3):
        time.sleep(0.5)
        print("Process:{0} Time is {1}".format(val, time.time()))


if __name__ == '__main__':
    
    t1 = threading.Thread(target=display_time, args=(1,))
    t2 = threading.Thread(target=display_time, args=(2,))

    t1.start()
    t2.start()
    
    print("Threading Complete. We are at the end of the program.")
Threading Complete. We are at the end of the program.
Process:1 Time is 1543436647.7216341
Process:2 Time is 1543436647.722194
Process:1 Time is 1543436648.2265742
Process:2 Time is 1543436648.227299
Process:1 Time is 1543436648.729373
Process:2 Time is 1543436648.731555


It is evident from the above example that even before the 2 threads complete their execution, the line of code present after starting the 2 threads is being executed.

So, how do you wait for the threads to complete before you continue with the execution of the rest of the program ?

This is where join comes in as the perfect solution. Now let’s analyze the same example with join.

Here the program waits for the threads to complete before arriving to the end.

Example 2 : Threading in python with Join

import threading
import time
import sys


def display_time(val):
    """
    Display the time 3 times 
    with a 0.5 second dh elay
    """
    for i in range(3):
        time.sleep(0.5)
        print("Process:{0} Time is {1}".format(val, time.time()))


if __name__ == '__main__':
    
    t1 = threading.Thread(target=display_time, args=(1,))
    t2 = threading.Thread(target=display_time, args=(2,))

    t1.start()
    t2.start()
    
    t1.join()
    t2.join()
    
    print("Threading Complete. We are at the end of the program.")
Process:1 Time is 1543436975.869845
Process:2 Time is 1543436975.8704278
Process:2 Time is 1543436976.37433
Process:1 Time is 1543436976.37479
Process:2 Time is 1543436976.87863
Process:1 Time is 1543436976.878934
Threading Complete. We are at the end of the program.

Python Multithreading - Thread Pool

You can also start a pool of threads in python to run your tasks concurrently.

This can be achieved by using the ThreadPoolExecutor in python which is part of concurrent.futures or by using ThreadPool which is part of the multiprocessing module.

Below are 2 examples which illustrate the usage of both.

Example 1 - Using ThreadPoolExecutor in Python

We can import the ThreadPoolExecutor class from the concurrent.futures module to start a pool of worker threads.

For our example, we will download a random list of images using the requests module in Python.

Next we will initialize a ThreadPoolExecutor to start a pool of threads. The number of threads can be specified using the max_workers argument. If unspecified it will take the default value depending upon the version. In Python version 3.8, the default value of max_workers is min(32, os.cpu_count() + 4). For our example, we will use 5 threads.

In addition to this, we will also be comparing the time taken by perfoming a sequential execution of the same.

import time
from concurrent.futures import ThreadPoolExecutor
import requests


def save_image(url):
    """
    Method to download an image and save it as a local image file
    """
    r = requests.get(url)
    file_name = url.split("/")[-1]
    with open(file_name, 'wb') as f:
        f.write(r.content)
    print(f"{file_name} - Downloaded")
    return file_name

image_url_list = ['https://cdn.pixabay.com/photo/2020/05/20/06/47/rome-5195046__480.jpg', 
                  'https://cdn.pixabay.com/photo/2020/05/15/18/10/forth-bridge-5174535__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/05/18/22/17/travel-5188598__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/05/11/13/40/masonry-5158303__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/05/17/12/56/mykonos-5181484__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/02/11/07/55/robots-4838671__480.png',
                  'https://cdn.pixabay.com/photo/2020/05/12/16/43/mallard-5163882__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/04/27/12/02/strawberries-5099527__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/05/04/11/19/smile-5128742__480.jpg',
                  'https://cdn.pixabay.com/photo/2020/05/17/07/00/butterfly-5180349__480.jpg'
                 ]

start = time.time()

for img_url in image_url_list:
    save_image(img_url)
# _ = list(map(save_image, image_url_list))   # Alternative to above 2 statements

print(f"\nSequential Execution, Time Taken: {time.time() - start}\n")

start = time.time()

with ThreadPoolExecutor(max_workers=5) as executors:
    executors.map(save_image, image_url_list)  # Each item from the list is passed as arg to the function

print(f"\nParallel Execution, Time Taken: {time.time() - start}")
rome-5195046__480.jpg - Downloaded
forth-bridge-5174535__480.jpg - Downloaded
travel-5188598__480.jpg - Downloaded
masonry-5158303__480.jpg - Downloaded
mykonos-5181484__480.jpg - Downloaded
robots-4838671__480.png - Downloaded
mallard-5163882__480.jpg - Downloaded
strawberries-5099527__480.jpg - Downloaded
smile-5128742__480.jpg - Downloaded
butterfly-5180349__480.jpg - Downloaded

Sequential Execution, Time Taken: 1.1698040962219238

forth-bridge-5174535__480.jpg - Downloaded
mykonos-5181484__480.jpg - Downloaded
masonry-5158303__480.jpg - Downloaded
travel-5188598__480.jpg - Downloaded
rome-5195046__480.jpg - Downloaded
robots-4838671__480.png - Downloaded
mallard-5163882__480.jpg - Downloaded
strawberries-5099527__480.jpg - Downloaded
butterfly-5180349__480.jpg - Downloaded
smile-5128742__480.jpg - Downloaded

Parallel Execution, Time Taken: 0.2731809616088867

It is clearly evident from the above results that by utilizing threading in python, the overall execution time was reduced by a considerable amount.

Example 2 - Using ThreadPool in Python

The following example demonstrates how to use ThreadPool in Python to start a pool of threads.

Once we import this from the multiprocessing module, we will be using it’s map method to map each image url to the function and download them in parallel.

This is very similar to the Pool class and its methods are more or less the same. To read more about it, please click here.

import time
from multiprocessing.pool import ThreadPool

start = time.time()

threads = ThreadPool(5) # Initialize the desired number of threads
threads.map(save_image, image_url_list)

print(f"\nParallel Execution 2, Time Taken: {time.time() - start}")
travel-5188598__480.jpg - Downloaded
mykonos-5181484__480.jpg - Downloaded
rome-5195046__480.jpg - Downloaded
masonry-5158303__480.jpg - Downloaded
forth-bridge-5174535__480.jpg - Downloaded
robots-4838671__480.png - Downloaded
butterfly-5180349__480.jpg - Downloaded
strawberries-5099527__480.jpg - Downloaded
smile-5128742__480.jpg - Downloaded
mallard-5163882__480.jpg - Downloaded

Parallel Execution 2, Time Taken: 0.28809595108032227

Synchronization between threads - Thread Lock

Thread lock in Python helps establish a synchronization between threads and avoid race conditions. For example, what if you wish to share data among the running threads. This is the most useful part of the threading module showing how data can be shared across 2 or more threads in a synchronized way.

What happens if two or more threads try to make changes to a shared object at the same time ?
This would result in unexpected and asynchronous results. Thread locks help to combat this issue.

Thread lock in python is designed in such a way that at any instant only one thread can make changes to a shared object.

This locking mechanism ensures that a clean synchronization is established between the threads thereby avoiding unexpected results due to this simultaneous execution.

Practical Use Case: For example, sharing objects would be very useful in a case where there is a frontend UI to display a table’s data and this table’s data is manipulated from 2 data sources being refreshed periodically for every 5 minutes. So, if there’s a delay in any of these 2 data refreshes, and if both threads try to manipulate the same object at the same time, it might lead to inconsistent results.

Example 1 : Threading without Lock

Let’s create a scenario wherein a race condition is created, yielding inconsistent results.

Our program would consist of 2 functions,

  • refresh_val() - increment val by 100000 times
  • main() - create 2 threads which call refresh_val simultaneously
  • We will call this main function 10 times in our code

    import threading
    
    val = 0 # global variable val
    
    def refresh_val():
        """
        Increment val 100000 times
        """
        global lock, val
        counter = 100000
        while counter > 0:
            val += 1
            counter -= 1
    
    
    def main():
        global val
        val = 0
        
        # creating threads
        t1 = threading.Thread(target=refresh_val)
        t2 = threading.Thread(target=refresh_val)
    
        # start threads
        t1.start()
        t2.start()
    
        # wait until threads complete
        t1.join()
        t2.join()
    
    
    if __name__ == "__main__":
        for i in range(1,11):
            main()
            print("Step {0}: val = {1}".format(i, val))
    Step 1: val = 200000
    Step 2: val = 191360
    Step 3: val = 200000
    Step 4: val = 200000
    Step 5: val = 200000
    Step 6: val = 199331
    Step 7: val = 200000
    Step 8: val = 200000
    Step 9: val = 157380
    Step 10: val = 200000
    


    Let’s perform the above same operation, with the locking mechanism present in threading.

    Here is where the threading module introduces 2 methods,

  • Acquire - Block until the lock is released
  • Release - Release
  • When a lock is acquired by a thread for a shared object, no other thread can make changes to this object at the same time. After a lock is acquired, if another thread attempts to access an object, it would have to wait until the lock is released.

    Methods to create a lock

    Method 1 :

    import threading
    
    lock = threading.Lock() # create a lock
    try:
        lock.acquire() # Block the lock
        # code goes here
    finally:
        lock.release() # Release the lock


    Method 2 :

    We can also create a python thread lock using the context manager.

    It is recommended to use this method in order to ensure both acquire and release methods are applied whenever a lock is created in python.

    import threading
    
    lock = threading.Lock() # create a lock
    with lock:
        # code goes here


    Example 2 : Threading with Lock in Python

    Let’s perform the above same operation, using lock in python to achieve synchronization between threads.

    As you can see below, the global variable is being incremented concurrently as expected without any inconsistency.

    import threading
    
    val = 0 # global variable val
    
    lock = threading.Lock() # create a lock
    
    def refresh_val():
        """
        Increment val 10000 times
        """
        global lock, val
        counter = 100000
        while counter > 0:
            lock.acquire() # Block the lock
            val += 1
            lock.release() # Release the lock
            counter -= 1
    
    
    def main():
        global val
        val = 0
        
        # creating threads
        t1 = threading.Thread(target=refresh_val)
        t2 = threading.Thread(target=refresh_val)
    
        # start threads
        t1.start()
        t2.start()
    
        # wait until threads complete
        t1.join()
        t2.join()
    
    
    if __name__ == "__main__":
        for i in range(1,11):
            main()
            print("Step {0}: val = {1}".format(i, val))
    Step 1: val = 200000
    Step 2: val = 200000
    Step 3: val = 200000
    Step 4: val = 200000
    Step 5: val = 200000
    Step 6: val = 200000
    Step 7: val = 200000
    Step 8: val = 200000
    Step 9: val = 200000
    Step 10: val = 200000
    

    When to not use Multithreading in Python?

    Python Multithreading is not recommended for below mentioned scenarios,

    • Not suitable for CPU intensive tasks. Example stated below.
    • Having multiple heavyweight threads can slow down your main process.
    • Individual threads are not killable.
    • Creating too many threads for a single application might make your code longer and process slower.

    The following example clearly indicates why threading in python isn’t suitable for even simple CPU bound tasks. Below is a number crunching task which is run using 2 threads and compared with a sequential execution of the same.

    import time
    from concurrent.futures import ThreadPoolExecutor
    
    
    def decrement():
        counter = 10000000
        while counter > 0:
            counter -= 1
    
    def execute_parallel():
        start_time = time.time()
        
        with ThreadPoolExecutor(max_workers=2) as executors:
            for _ in range(2):
                executors.submit(decrement) 
                
        print(f"Threaded Execution - Time Taken: {time.time() - start_time}")
        
    def execute_serially():
        start_time = time.time()
        
        for _ in range(2):
            decrement()
            
        print(f"Serial Execution - Time Taken: {time.time() - start_time}")
    
    if __name__ == "__main__":
        execute_parallel()
        execute_serially()
    Threaded Execution - Time Taken: 1.3582673072814941
    Serial Execution - Time Taken: 1.2527658939361572
    

    Conclusion

    We can summarise by our learning that Multithreading in Python can be used in cases where you would like to perform multiple I/O bound tasks within the same application accessing some shared objects.

    Multithreading in python is more recommended for I/O bound tasks because of the implementation of the Global Interpreter Lock(GIL).

    We have also learnt how we can start a pool of threads using ThreadPoolExecutor or ThreadPool class.

    To get rid of inconsistency during race conditions, the threading lock can be used.

    By now, you should be able to leverage multithreading in python based on your requirements.

    Comments and feedback are welcome. Cheers!

    comments powered by Disqus