Back to Course

Intermediate Python

0% Complete
0/0 Steps
Lesson 24 of 33
In Progress

Synchronizing Threads

Thread synchronization is a technique that allows multiple threads to access shared resources in a controlled and synchronized manner. This is important because without synchronization, multiple threads can access shared resources simultaneously, leading to race conditions and other unpredictable behavior.

Lock Class

In Python, there are several mechanisms for synchronizing threads. One of the most commonly used mechanisms is the Lock class, which provides a way to acquire and release a lock that can be used to protect critical sections of code.

To use a Lock, you can create an instance of the Lock class and then call the acquire() method to acquire the lock. Once you have the lock, you can execute the critical section of code, and then call the release() method to release the lock.

Here’s an example of using a Lock to synchronize access to a shared resource:

import threading

lock = threading.Lock()

def process_data():
    # Acquire the lock
    lock.acquire()
    try:
        # Critical section of code goes here
        print('Processing data...')
    finally:
        # Release the lock
        lock.release()

# Start two threads
thread1 = threading.Thread(target=process_data)
thread2 = threading.Thread(target=process_data)
thread1.start()
thread2.start()

In the example above, the process_data() function is called by two separate threads. When a thread calls the acquire() method, it will block until the lock is available. This ensures that only one thread can execute the critical section of code at a time.

Semaphore Class

Another mechanism for synchronizing threads is the Semaphore class, which provides a way to limit the number of threads that can access a shared resource concurrently. A semaphore is initialized with a certain number of permits, and each call to the acquire() method reduces the number of available permits by 1. When the number of available permits reaches 0, any subsequent calls to acquire() will block until a permit becomes available.

Here’s an example of using a Semaphore to limit the number of threads that can access a shared resource concurrently:

import threading

semaphore = threading.Semaphore(2)

def process_data():
    # Acquire a permit
    semaphore.acquire()
    try:
        # Critical section of code goes here
        print('Processing data...')
    finally:
        # Release the permit
        semaphore.release()

# Start four threads
thread1 = threading.Thread(target=process_data)
thread2 = threading.Thread(target=process_data)
thread3 = threading.Thread(target=process_data)
thread4 = threading.Thread(target=process_data)
thread1.start()
thread2.start()
thread3.start()
thread4.start()

In the example above, the Semaphore is initialized with a value of 2, which means that only two threads can access the critical section of code concurrently. When the third thread calls the acquire() method, it will block until one of the other threads releases a permit by calling the release() method.

Conclusion

There are other mechanisms for synchronizing threads in Python, such as the Event class, which provides a way to signal between threads, and the Queue class, which provides a thread-safe way to communicate between threads using a queue data structure.

It’s important to note that while thread synchronization can help prevent race conditions and other unpredictable behavior, it can also introduce performance overhead and complexity to your code. Therefore, it’s important to use thread synchronization judiciously and only when it’s necessary to ensure the correct operation of your program.

Exercises

To review these concepts, we will go through a series of exercises designed to test your understanding and apply what you have learned.

Write a program that creates a shared resource and uses a Lock to synchronize access to the resource. The program should start two threads, and each thread should increment the shared resource 100 times. At the end, the program should print the final value of the shared resource.

import threading

# Create a shared resource
counter = 0

# Create a lock
lock = threading.Lock()

def increment_counter():
    global counter
    for i in range(100):
        # Acquire the lock
        lock.acquire()
        try:
            # Increment the counter
            counter += 1
        finally:
            # Release the lock
            lock.release()

# Start two threads
thread1 = threading.Thread(target=increment_counter)
thread2 = threading.Thread(target=increment_counter)
thread1.start()
thread2.start()

# Wait for the threads to finish
thread1.join()
thread2.join()

# Print the final value of the counter
print(counter)

Write a program that creates a shared resource and uses a Semaphore to limit the number of threads that can access the resource concurrently. The program should start four threads, and each thread should increment the shared resource 100 times. At the end, the program should print the final value of the shared resource.

import threading

# Create a shared resource
counter = 0

# Create a semaphore with a limit of 2 permits
semaphore = threading.Semaphore(2)

def increment_counter():
    global counter
    for i in range(100):
        # Acquire a permit
        semaphore.acquire()
        try:
            # Increment the counter
            counter += 1
        finally:
            # Release the permit
            semaphore.release()

# Start four threads
thread1 = threading.Thread(target=increment_counter)
thread2 = threading.Thread(target=increment_counter)
thread3 = threading.Thread(target=increment_counter)
thread4 = threading.Thread(target=increment_counter)
thread1.start()
thread2.start()
thread3.start()
thread4.start()

# Wait for the threads to finish
thread1.join()
thread2.join()
thread3.join()
thread4.join()

# Print the final value of the counter
print(counter)

Write a program that creates a shared resource and uses a Condition to synchronize access to the resource. The program should start two threads, and each thread should increment the shared resource 100 times. One thread should be allowed to run only when the shared resource is even, and the other thread should be allowed to run only when the shared resource is odd. At the end, the program should print the final value of the shared resource.

import threading

# Create a shared resource
counter = 0

# Create a condition
condition = threading.Condition()

def increment_counter_even():
    global counter
    for i in range(100):
        # Acquire the condition
        with condition:
            # Wait until the counter is even
            while counter % 2 != 0:
                condition.wait()
            # Increment the counter
            counter += 1
            # Notify other threads that the counter has been incremented
            condition.notify()

def increment_counter_odd():
    global counter
    for i in range(100):
        # Acquire the condition
        with condition:
            # Wait until the counter is odd
            while counter % 2 != 1:
                condition.wait()
            # Increment the counter
            counter += 1
            # Notify other threads that the counter has been incremented
            condition.notify()

# Start two threads
thread1 = threading.Thread(target=increment_counter_even)
thread2 = threading.Thread(target=increment_counter_odd)
thread1.start()
thread2.start()

# Wait for the threads to finish
thread1.join()
thread2.join()

# Print the final value of the counter
print(counter)

Write a program that creates a shared resource and uses a Semaphore to synchronize access to the resource. The program should start two threads, and each thread should increment the shared resource 100 times. One thread should be allowed to run only when the shared resource is even, and the other thread should be allowed to run only when the shared resource is odd. At the end, the program should print the final value of the shared resource.

import threading

# Create a shared resource
counter = 0

# Create a semaphore
semaphore = threading.Semaphore()

def increment_counter_even():
    global counter
    for i in range(100):
        # Acquire the semaphore
        semaphore.acquire()
        # Increment the counter
        counter += 1
        # Release the semaphore
        semaphore.release()

def increment_counter_odd():
    global counter
    for i in range(100):
        # Acquire the semaphore
        semaphore.acquire()
        # Increment the counter
        counter += 1
        # Release the semaphore
        semaphore.release()

# Start two threads
thread1 = threading.Thread(target=increment_counter_even)
thread2 = threading.Thread(target=increment_counter_odd)
thread1.start()
thread2.start()

# Wait for the threads to finish
thread1.join()
thread2.join()

# Print the final value of the counter
print(counter)

Write a program that creates a shared resource and uses a Queue to communicate between threads. The program should start two threads, and one thread should increment the shared resource 100 times, while the other thread should print the value of the shared resource after each increment. At the end, the program should print the final value of the shared resource.

import threading
import queue

# Create a shared resource
counter = 0

# Create a queue
q = queue.Queue()

def increment_counter():
    global counter
    for i in range(100):
        # Increment the counter
        counter += 1
        # Put the new value of the counter in the queue
        q.put(counter)

def print_counter():
    global counter
    for i in range(100):
        # Get the value of the counter from the queue
        counter = q.get()
        # Print the value of the counter
        print(counter)

# Start two threads
thread1 = threading.Thread(target=increment_counter)
thread2 = threading.Thread(target=print_counter)
thread1.start()
thread2.start()

# Wait for the threads to finish
thread1.join()
thread2.join()

# Print the final value of the counter
print(counter)