w3resource

Python Multithreading: Working with Threads

Introduction to Python Multithreading

Multithreading in Python allows you to run multiple threads (smaller units of a process) concurrently, enabling parallel execution of tasks and improving the performance of your program, especially for I/O-bound tasks. Python’s threading module provides a way to create and manage threads.

Threads are separate flows of execution that share the same memory space but can run independently. They are ideal for tasks that involve waiting (like I/O operations) rather than CPU-bound operations due to Python's Global Interpreter Lock (GIL).

Creating and Starting a Thread:

The simplest way to create a thread is by instantiating the Thread class from the threading module.

Example 1: Creating and Starting a Thread

This example demonstrates how to create and start a simple thread that executes a function.

Code:

import threading
import time

# Define a simple function that will be executed in a thread
def print_numbers():
    for i in range(5):
        print(f"Number: {i}")
        time.sleep(1)  # Simulate a time-consuming task

# Create a thread that runs the print_numbers function
thread = threading.Thread(target=print_numbers)

# Start the thread
thread.start()

# Main thread continues to run while the new thread executes
print("Thread has started.") 

Output:

Number: 0
Thread has started.
Number: 1
Number: 2
Number: 3
Number: 4

Explanation:

A thread is created by passing the target function ('print_numbers') to the 'Thread' class. The 'start()' method begins the thread's execution, running the 'print_numbers' function concurrently with the main program. The main thread prints a message and continues to run independently of the new thread.

Using Join to wait for Threads to complete:

The join() method can be used to wait for a thread to finish before continuing with the rest of the program.

Example 2: Waiting for a Thread to Complete

This example shows how to use the join() method to wait for a thread to complete before proceeding.

Code:

import threading
import time

# Define a function that simulates a task
def print_squares():
    for i in range(1, 4):
        print(f"Square of {i}: {i ** 2}")
        time.sleep(1)

# Create a thread that runs the print_squares function
thread = threading.Thread(target=print_squares)

# Start the thread
thread.start()

# Wait for the thread to complete
thread.join()

# This line will execute after the thread finishes
print("Thread has completed.")

Output:

Square of 1: 1
Square of 2: 4
Square of 3: 9
Thread has completed.

Explanation:

After starting the thread with 'start()', the 'join()' method is called. This blocks the main thread until the thread running 'print_squares' finishes its execution. This is useful when you need to ensure that a thread has completed its work before moving on in the main program.

Passing Arguments to Threads:

We can pass arguments to the target function of a thread using the args parameter.

Example 3: Passing Arguments to a Thread

This example demonstrates how to pass arguments to a function running in a thread.

Code:

import threading

# Define a function that takes an argument
def greet(name):
    print(f"Hello, {name}!")

# Create a thread that runs the greet function with an argument
thread = threading.Thread(target=greet, args=("Zrinka",))

# Start the thread
thread.start()

# Wait for the thread to complete
thread.join()

Output:

Hello, Zrinka!

Explanation:

The 'rgs' parameter of the "Thread" class allows passing arguments to the target function. In this example, the string "Alice" is passed to the 'greet' function, which is then executed by the thread.

Using Locks to Prevent Race Conditions:

In multithreaded programs, race conditions occur when multiple threads access shared resources simultaneously, leading to inconsistent results. Locks can be used to ensure that only one thread accesses a critical section at a time.

Example 4: Using Locks to Prevent Race Conditions

This example shows how to use locks to prevent race conditions when multiple threads modify a shared resource.

Code:

import threading

# Shared resource
counter = 0

# Create a lock
lock = threading.Lock()

# Define a function that increments a shared counter
def increment_counter():
    global counter
    for _ in range(1000):
        # Acquire the lock before modifying the shared resource
        lock.acquire()
        counter += 1
        # Release the lock after modification
        lock.release()

# Create multiple threads that run the increment_counter function
threads = [threading.Thread(target=increment_counter) for _ in range(5)]

# Start all threads
for thread in threads:
    thread.start()

# Wait for all threads to complete
for thread in threads:
    thread.join()

# Print the final value of the counter
print(f"Final counter value: {counter}")  # Expected output: 5000

Output:

Final counter value: 5000

Explanation:

A lock is created using 'threading.Lock()'. Before modifying the shared resource ('counter'), each thread acquires the lock with 'lock.acquire()'. After updating the counter, the lock is released with 'lock.release()'. This ensures that only one thread can modify the counter at a time, preventing race conditions and ensuring consistent results.

Daemon Threads:

Daemon threads run in the background and do not prevent the program from exiting. They are useful for background tasks that should not block program termination.

Example 5: Creating a Daemon Thread

This example demonstrates how to create a daemon thread that runs in the background.

Code:

import threading
import time
# Define a function that runs indefinitely
def background_task():
    while True:
        print("Running background task...")
        time.sleep(2)
# Create a daemon thread that runs the background_task function
thread = threading.Thread(target=background_task, daemon=True)
# Start the daemon thread
thread.start()
# Main thread sleeps for a few seconds
time.sleep(5)
# The program will exit here, and the daemon thread will stop
print("Main thread is exiting.")

Output:

Running background task...
Running background task...
Running background task...
Main thread is exiting.

Explanation:

The 'daemon=True' argument makes the thread a daemon thread, meaning it will run in the background and will not block the program from exiting. The main thread sleeps for 5 seconds, during which the daemon thread runs its infinite loop. Once the main thread exits, the daemon thread also stops, demonstrating how daemon threads are used for background tasks.

Thread Synchronization with Condition Objects:

Condition objects allow threads to wait for certain conditions to be met before continuing execution, facilitating synchronized communication between threads.

Example 6: Using Condition Objects for Thread Synchronization

This example shows how to use condition objects to synchronize the execution of producer and consumer threads.

Code:

import threading

# Create a condition object
condition = threading.Condition()

# Shared resource
data_ready = False

# Function to produce data
def producer():
    global data_ready
    with condition:
        print("Producing data...")
        data_ready = True
        condition.notify()  # Notify the consumer that data is ready

# Function to consume data
def consumer():
    global data_ready
    with condition:
        condition.wait()  # Wait for data to be ready
        if data_ready:
            print("Consuming data...")

# Create threads for producer and consumer
producer_thread = threading.Thread(target=producer)
consumer_thread = threading.Thread(target=consumer)

# Start the consumer first
consumer_thread.start()
producer_thread.start()

# Wait for both threads to complete
producer_thread.join()
consumer_thread.join()

Output:

Producing data...
Consuming data...

Explanation:

A 'Condition' object allows threads to wait for some condition to be met. In this example, the consumer thread waits until the producer sets 'data_ready' to 'True' and calls 'condition.notify()'. The producer and consumer threads are synchronized using the condition object, ensuring that the consumer only proceeds after the producer has prepared the data.

Thread Pools with concurrent.futures :

Using thread pools is a higher-level and more efficient way to manage a group of threads for performing tasks concurrently.

Example 7: Using Condition Objects for Thread Synchronization

This example demonstrates how to use thread pools with 'ThreadPoolExecutor' to manage multiple threads efficiently.

Code:

from concurrent.futures import ThreadPoolExecutor
import time

# Define a function that simulates a task
def fetch_data(w3r):
    print(f"Fetching data from {w3r}...")
    time.sleep(2)  # Simulate a delay
    return f"Data from {w3r}"

# List of websites to fetch data from
websites = ['w3r1.com', 'w3r2.com', 'w3r3.com']

# Use ThreadPoolExecutor to manage threads
with ThreadPoolExecutor(max_workers=3) as executor:
    # Map the fetch_data function to the list of websites
    results = executor.map(fetch_data, websites)

# Print the results
for result in results:
    print(result)

Output:

Fetching data from w3r1.com...
Fetching data from w3r2.com...
Fetching data from w3r3.com...
Data from w3r1.com
Data from w3r2.com
Data from w3r3.com

Explanation:

'ThreadPoolExecutor' provides a high-level interface for managing a pool of threads. It allows you to specify the number of worker threads and map tasks (like fetching data from multiple sites) to these threads. This approach simplifies thread management, optimizes resource usage, and improves the performance of concurrent operations.



Become a Patron!

Follow us on Facebook and Twitter for latest update.

It will be nice if you may share this link in any developer community or anywhere else, from where other developers may find this content. Thanks.

https://w3resource.com/python/python-multithreading-with-examples.php