Logo

Mastering Threads in Python: Enhancing Performance without Complexity

Default Alt Text

Table of Contents

Understanding the role of threads in Python

To start off, we will provide a basic introduction to the threading module in Python. This module is a built-in module in Python. It allows us to handle the threading discipline of a program. Let’s demonstrate some aspects of threading in Python with this code:

import threading

def print_worker_name():
    print(f"Worker Name: {threading.current_thread().name}")

def main():
    print_worker_name()

    thread = threading.Thread(target=print_worker_name)
    thread.start()

if __name__ == "__main__":
    main()

In the script above, first we import the threading module. Then we define a function `print_worker_name` that simply prints the name of the current thread it is being executed in. The main function then runs this method. The first time, it runs in the main thread.

Then, a new thread call ‘thread’ is created and the same `print_worker_name` function is set as the target. By calling `thread.start()`, the `print_worker_name` function starts executing in the new thread as well.

This example code is the most basic one but threads can perform much more complex tasks as well, as we will see later in this blog. Threading simplifies executing multiple tasks concurrently, which can potentially save a significant amount of time while performing heavy tasks or IO operations.

Importance of multithreading in Python

Multithreading in Python plays a critical role in enhancing the performance of I/O-bound operations. It allows concurrent execution of tasks, effectively improving the throughput of processes that involve data fetching from remote servers or reading and writing to disk. As tasks are often waiting for I/O operations, multithreading permits other tasks to utilize this idle time, leading to efficient use of resources. Moreover, its ease of use with Python’s ‘threading’ module, coupled with synchronization mechanisms like locks and semaphores, equip developers with a powerful tool to handle complex, data-intensive applications. The role of multithreading becomes even more significant in the context of cloud services, where managing large datasets and concurrent requests are daily tasks. Embracing multithreading in Python can significantly boost the performance of cloud applications by ensuring that resources are constantly engaged, helping developers to build faster, more responsive applications.

Python’s Global Interpreter Lock and its impact on threading

Python’s Global Interpreter Lock, or GIL, is a mechanism that prevents multiple native threads from executing Python bytecodes simultaneously. While it is in place to prevent inconsistent states in interpreter details, it unfortunately introduces limitations when it comes to multithreading. In an ideal multithreading scenario, each thread would be able to run independently on a different CPU core. But due to GIL, this doesn’t happen in Python, creating a situation where even if you use multiple threads in a CPU-bound program, you won’t achieve higher throughput, because the GIL will cause your threads to run on a single core, one after the other, rather than truly parallel.

Getting Started with Python Threading

An overview of Python threading module

The following Python code provides a simple demonstration of creating and managing a user-defined thread using Python’s threading module. In this code snippet, we are defining a function named ‘greet’ which accepts an argument, and this function is later used to create and start a thread.

import threading


def greet(name):
    print(f'Hello, {name}!')


thread = threading.Thread(target=greet, args=('Alice',))


thread.start()


thread.join()

In the code above, a thread is created with the ‘greet’ function as the target to be executed in the new thread. An argument ‘Alice’ is passed to the ‘greet’ function using ‘args’. When ‘start’ method is called, the thread execution begins, and when ‘join’ method is used, it allows the main program to wait for this thread to complete before proceeding further.

Please note that the output of this program will be ‘Hello, Alice!’, indicating that the ‘greet’ function executed in a separate thread. The simplicity and flexibility of Python’s threading module make it incredibly valuable for creating and managing threads quickly and efficiently. Bookkeeping considerations, such as thread cleanup, are managed by the module, freeing developers to focus on business logic.

Python’s thread lifecycle

Our next step in mastering Python threading is to comprehend the lifecycle of a thread. When we start a thread in Python, it doesn’t instantly begin executing. Instead, it enters the runnable state, where it waits for the CPU to be available. After execution, a thread enters the terminated state from which it cannot return. There’s also a blocked state where the thread waits for some event, like I/O operations.

Here is Python code that illustrates a thread’s start, execution, and termination phases:

import threading
import time

class SampleThread(threading.Thread):
    def __init__(self, name):
        threading.Thread.__init__(self)
        self.name = name

    def run(self):
        print(f"- Thread {self.name} started.")
        time.sleep(3)
        print(f"- Thread {self.name} finished execution.")

thread1 = SampleThread("Thread-1")
thread2 = SampleThread("Thread-2")

thread1.start()
thread2.start()

thread1.join()
thread2.join()

In above code, we define a `SampleThread` class that extends Python’s `Thread` class and overrides its `run` method. When `start` is called on a thread object, it enters the runnable state and the `run` method is invoked. At completion, the thread moves into the terminated state. Calling `join` on a thread can make the main thread wait until the called one finishes execution. In the code execution output, we’ll notice that ‘Thread-1’ and ‘Thread-2’ start almost simultaneously proving concurrent execution. This is the basic lifecycle of a thread in Python – from start, through execution, to termination.

Note that in actual applications, the complexity of managing threads depends on the intricacies of the task at hand and the optimal performance strategy employed.

Exploring Thread Synchronization Techniques

Locks in Python

In a multithreaded environment, threads often need to access a shared resource. However, this can lead to ‘race conditions’ where the performance and outcome depend on the exact timing of the threads’ execution. This is where ‘Locks’ become essential. ‘Locks’ are basic synchronization primitives that prevent multiple threads from accessing the same code section simultaneously. Here’s an example demonstrating how to use locks in Python to avoid race conditions.

import threading

class SharedResource:
    def __init__(self):
        self.resource = 0
        self.lock = threading.Lock()

    def increment_resource(self):
        with self.lock:
            initial_resource = self.resource
            initial_resource += 1
            self.resource = initial_resource

shared_resource = SharedResource()

def task(shared_resource):
    for _ in range(100000):
        shared_resource.increment_resource()

threads = []
for _ in range(5):
    thread = threading.Thread(target=task, args=(shared_resource,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

print("Expected value :", 5 * 100000)
print("Actual value   :", shared_resource.resource)

This code initiates a shared resource, attaches a lock to it, and spins up 5 threads. Each thread attempts to increment this resource 100,000 times. If the lock weren’t in place, some increments could be lost due to race conditions between the threads – leading to the final resource count being less than the expected 500,000. However, as we use a lock around the increment operation, our final count is precisely what we expect, thus effectively demonstrating using locks to avoid race conditions in Python threads.

Semaphores in Python

import threading
import time
import random


shared_resource_with_semaphore = 0

semaphore = threading.Semaphore(10)

def semaphore_worker():
    global shared_resource_with_semaphore
    # Request access to the shared resource
    semaphore.acquire()
    x = shared_resource_with_semaphore
    # Simulate some delay which may be due to any process
    time.sleep(random.randint(1, 3))
    shared_resource_with_semaphore = x + 1
    # Release access to the shared resource
    semaphore.release()


threads = []

for i in range(0, 100):
    # Create new threads
    t = threading.Thread(target=semaphore_worker)
    threads.append(t)
    t.start()


for t in threads:
    t.join()


print(f"Value of shared resource with semaphore: {shared_resource_with_semaphore}")

The code sample above starts by importing the necessary libraries and setting up shared resources and a semaphore. The number of threads that can access the semaphore concurrently is set at 10. A function `semaphore_worker`, is defined to manage the access to the shared resource. This function is then used to create 100 threads that will attempt to access this shared resource over their lifecycle. The semaphore with a limit of 10 ensures orderly access to the shared resource, preventing simultaneous access that can lead to inconsistencies or corruption. When running the code, even though we have a large number of threads, the access to the shared resource remains orderly and controlled.

Condition variables

Let’s introduce a fundamental aspect of Python thread synchronization – the wait and notify methods. Calling the wait() on a condition puts the thread into a waiting state until another thread calls notify() on the same condition. Let’s use Python’s threading and time modules to display this interaction.

import threading
import time


cond = threading.Condition()


def thread_1_behavior():
    with cond:
        print("Thread 1 is running and about to wait.")
        cond.wait()
        print("Thread 1 has been notified and is now running again.")


def thread_2_behavior():
    with cond:
        print("Thread 2 is running and about to notify.")
        time.sleep(2) # Simulate time taken for task completion
        cond.notify()
        print("Thread 2 has notified Thread 1.")


thread_1 = threading.Thread(target=thread_1_behavior)
thread_2 = threading.Thread(target=thread_2_behavior)

thread_1.start()
thread_2.start()


thread_1.join()
thread_2.join()

This code generates two threads: Thread 1 and Thread 2. Thread 1 starts and enters a waiting state through the use of cond.wait(), while Thread 2 starts, pauses for 2 seconds to simulate task completion, and then uses cond.notify() to alert Thread 1. This exemplifies the interaction between two threads using the wait and notify methods in Python threading module.

Improving Performance with Multithreading

Thread pools

Python’s concurrent.futures module provides a high-level interface for asynchronously executing callables. It allows the creation of multiple threads with ThreadPoolExecutor. Here is an example of its usage:

from concurrent.futures import ThreadPoolExecutor
import time

def task(n):
    time.sleep(n)
    return n

def main():
    with ThreadPoolExecutor(max_workers=4) as executor:
        futures = [executor.submit(task, n) for n in range(5)]
        for future in concurrent.futures.as_completed(futures):
            print('Task returns: {}'.format(future.result()))

if __name__ == "__main__":
    main()

The above code defines a simple function `task` that sleeps for `n` seconds and then returns `n`. The `main()` function creates a ThreadPoolExecutor with four worker threads. It then submits five tasks to this executor. Each task is the `task` function with one of the numbers from 0 to 4 as argument.

The `executor.submit(…)` function returns a Future object. A Future represents a computation that hasn’t necessarily completed yet. The `futures` list contains these Future objects, one for each task.

The `concurrent.futures.as_completed(futures)` function takes an iterable of Future objects, and yields those that are completed. Thus, the loop `for future in concurrent.futures.as_completed(futures):` will print the return value of each task as soon as it finishes.

The output will not be in the order the tasks were started, but in the order they finish. This is because each task takes a different amount of time (sleeps for a different number of seconds). This shows how Python multi-threading can enhance performance without adding complexity.

Python’s GIL and impact on multithreading

In this section, we will demonstrate Python’s Global Interpreter Lock (GIL) limitations by executing a CPU bound task using multithreading. CPU-bound tasks are those that are limited by the CPU performance and usually involve mathematical computations.

import time
import threading


def cpu_bound_task(n):
    while n > 0:
        n -= 1
        
def main():
    #Number of threads
    num_threads = 5
    
    #Task - Decrement 10^8 till it becomes 0
    task = 10**8
    
    threads = []
    
    #Split task equally among threads
    task_per_thread = task // num_threads
    
    start_time = time.perf_counter()

    for i in range(num_threads):
        thread = threading.Thread(target=cpu_bound_task, args=(task_per_thread,))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.perf_counter()

    print(f"Time taken to finish the task with {num_threads} threads: {end_time - start_time} seconds")

if __name__ == "__main__":
    main()

Python’s Global Interpreter Lock (GIL) ensures only one thread executes Python bytecodes at a time in a single process. So, for CPU-bound tasks like the one above, the multithreaded program does not execute threads in true parallelism. Due to the GIL, the threads are being executed one by one which causes the overall execution time to be approximately the same, regardless of the number of threads. This is one important domain where Python’s threading can demonstrate unequivocal restrictions.

Overcoming GIL with multiprocessing

In the following Python code block, we’ll demonstrate how multiprocessing works. Multiprocessing can help to bypass the Global Interpreter Lock (GIL) by creating separate Python interpreter processes, each with their own GIL, to run your computations. This solution excels where the computational task is CPU-bound.

import multiprocessing
import time

def slow_operation(n):
    time.sleep(n)
    return n*n

if __name__ == '__main__':
    with multiprocessing.Pool(processes=4) as pool:
        results = [pool.apply_async(slow_operation, args=(x,)) for x in range(3)]
        output = [p.get() for p in results]
    print(output)

In this code, we’re using Python’s `multiprocessing` library to create a process pool with 4 processes. We then asynchronously apply our CPU-bound function, `slow_operation`, to the arguments ranging from 0 to 2. The results are retrieved with the `get()` method and printed to the console. This approach leverages multiple processor cores to execute computations in parallel, thus reducing execution time for CPU-intensive tasks.

Conclusion

In summary, mastering threads in Python accelerates performance capabilities to new heights. Leveraging multithreading judiciously not only helps manage multiple tasks simultaneously but also achieves huge strides in processing speed, integral for high-performing cloud-based solutions. Although Python’s Global Interpreter Lock might initially pose as a challenge, a dig deeper unravels possible workarounds like using multiprocessing. Conclusively, with an in-depth understanding of thread synchronization techniques, Python developers can exploit the power of parallelism without adding unmanageable complexity, thereby augmenting system performance. The future of Python threading is promising, and its importance in cloud computing cannot be overstated.

Share This Post

More To Explore

Default Alt Text
AWS

Integrating Python with AWS DynamoDB for NoSQL Database Solutions

This blog provides a comprehensive guide on leveraging Python for interaction with AWS DynamoDB to manage NoSQL databases. It offers a step-by-step approach to installation, configuration, database operation such as data insertion, retrieval, update, and deletion using Python’s SDK Boto3.

Default Alt Text
Computer Vision

Automated Image Enhancement with Python: Libraries and Techniques

Explore the power of Python’s key libraries like Pillow, OpenCV, and SciKit Image for automated image enhancement. Dive into vital techniques such as histogram equalization, image segmentation, and noise reduction, all demonstrated through detailed case studies.

Do You Want To Boost Your Business?

drop us a line and keep in touch