Mastering Python Threading: A Symphony of Concurrency

Mastering Python Threading: A Symphony of Concurrency

Welcome to the grand stage of Python threading, where we unravel the secrets of concurrent programming. In this performance, we will cover the threading module, share data gracefully, dance with locks to prevent race conditions, explore daemon threads, and orchestrate a harmonious experience with queues for thread-safe data processing.

Act I: Creating and Running Threads

In the opening act, we introduce the Thread class, the protagonist of our concurrent tale. Let's create and run multiple threads to unleash the power of parallelism.

from threading import Thread

def square_numbers():
    for i in range(1000):
        result = i * i

if __name__ == "__main__":        
    threads = []
    num_threads = 10

    for i in range(num_threads):
        thread = Thread(target=square_numbers)
        threads.append(thread)

    for thread in threads:
        thread.start()

    for thread in threads:
        thread.join()

Here, we've orchestrated a symphony of ten threads, each performing the noble task of squaring numbers. The start() method sets them in motion, and join() ensures the main thread waits for their triumphant completion.

Act II: Data Sharing Between Threads

Threads coexisting in the same memory space beckon us to explore data sharing. In our next act, we stage a captivating scenario where two threads gracefully interact with a shared global variable.

from threading import Thread
import time

database_value = 0

def increase():
    global database_value

    local_copy = database_value
    local_copy += 1
    time.sleep(0.1)
    database_value = local_copy

if __name__ == "__main__":
    print('Start value: ', database_value)

    t1 = Thread(target=increase)
    t2 = Thread(target=increase)

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print('End value:', database_value)

    print('end main')

As our threads gracefully increase the database value, we encounter a twist—a race condition. Two threads vying for the same resource lead to unexpected results. Fear not, for our hero, the Lock, steps in to bring order to this chaos.

Act III: The Lock: Guardian of Data Integrity

The plot thickens as we face the villainous race condition. In this act, the Lock emerges as our hero, preventing threads from stepping on each other's toes.

from threading import Thread, Lock
import time

database_value = 0

def increase(lock):
    global database_value 
    lock.acquire()
    local_copy = database_value
    local_copy += 1
    time.sleep(0.1)
    database_value = local_copy

    lock.release()

if __name__ == "__main__":
    lock = Lock()
    print('Start value: ', database_value)
    t1 = Thread(target=increase, args=(lock,))
    t2 = Thread(target=increase, args=(lock,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print('End value:', database_value)

    print('end main')

The Lock swoops in to save the day, ensuring that the critical code section is protected. Threads politely wait their turn, eliminating the race condition and producing the expected result.

Act IV: Daemon Threads: A Mysterious Presence

In this act, we unravel the mystery of daemon threads—background performers that gracefully exit when the main show concludes.

from threading import Thread, Lock, current_thread
from queue import Queue

def worker(q, lock):
    while True:
        value = q.get()  
        # do stuff...
        with lock:
            # prevent printing at the same time with this lock
            print(f"in {current_thread().name} got {value}")

        # For each get(), a subsequent call to task_done() tells the queue
        # that the processing on this item is complete.
        # If all tasks are done, q.join() can unblock
        q.task_done()

if __name__ == '__main__':
    q = Queue()
    num_threads = 10
    lock = Lock()

    for i in range(num_threads):
        t = Thread(name=f"Thread{i+1}", target=worker, args=(q, lock))
        t.daemon = True  # dies when the main thread dies
        t.start()

    # fill the queue with items
    for x in range(20):
        q.put(x)

    q.join()  # Blocks until all items in the queue have been gotten and processed.

    print('main done')

Daemon threads cast a mysterious aura as they gracefully exit when the main performance concludes. They are the silent guardians, handling background tasks without stealing the limelight.

Grand Finale: Queues for Thread-Safe Harmony

In our grand finale, we introduce the Queue—the conductor orchestrating thread-safe data exchanges. Queues shine in both multithreaded and multiprocessing environments, ensuring a seamless flow of data.

from queue import Queue

# create queue
q = Queue()

# add elements
q.put(1)  # 1
q.put(2)  # 2 1
q.put(3)  # 3 2 1

# now q looks like this:
# back --> 3 2 1 --> front

# get and remove the first element
first = q.get()  # --> 1
print(first)

# q looks like this:
# back --> 3 2 --> front

In the multithreading spectacle, the queue takes center stage, guiding threads through a synchronized dance of data exchange. Operations on the queue are thread-safe, ensuring a flawless performance.

Our journey through the realms of Python threading concludes, leaving you equipped to compose your own symphony of concurrent elegance. May your threads dance in harmony, and your data flow seamlessly in the grand theater of parallelism.