Threads: A Conceptual Overview
A thread is a lightweight execution path within a program and represents the smallest unit of processing managed by an operating system scheduler. Threads share the resources of their parent process but maintain minimal independent resources essantial for execution.
Main Thread
When a Python program launches, the operating system creates a process. A primary thread of execution, known as the main thread, starts immediately. This thread is significant because it spawns child threads and must typically be the last to terminate to handle cleanup operations.
Child Threads
A child thread represents an alternate execution branch that runs concurrently with the main thread after initiation.
Sequential vs. Concurrent Execution Eaxmples
Sequential (Single-Threaded) Example:
import time
def display_message():
print("Message displayed")
time.sleep(1)
if __name__ == '__main__':
for _ in range(5):
display_message()
Execution time is approximately 5 seconds.
Concurrent (Multi-Threaded) Example:
import time
import threading
def display_message():
print("Message displayed")
time.sleep(1)
if __name__ == '__main__':
for _ in range(5):
thread_instance = threading.Thread(target=display_message)
thread_instance.start()
Execution time reduces to approximately 1 second.
Main Thread Waits for Child Thread Completion
The main thread remains active until all child threads have finished execution.
import time
import threading
def vocalize():
for count in range(3):
print(f"Vocalizing iteration {count}")
time.sleep(1)
def move():
for count in range(3):
print(f"Moving iteration {count}")
time.sleep(1)
if __name__ == '__main__':
print("Main thread begins execution")
vocal_thread = threading.Thread(target=vocalize)
move_thread = threading.Thread(target=move)
vocal_thread.start()
move_thread.start()
print("Main thread execution concludes")
Monitoring Active Thread Count
Use threading.enumerate() to obtain a list of all currently active thread objects and len() to count them.
import time
import threading
def vocalize():
for count in range(3):
print(f"Vocalizing iteration {count}")
time.sleep(1)
def move():
for count in range(3):
print(f"Moving iteration {count}")
time.sleep(1)
if __name__ == '__main__':
active_threads = threading.enumerate()
print(f"Initial thread count: {len(active_threads)}")
vocal_thread = threading.Thread(target=vocalize)
move_thread = threading.Thread(target=move)
vocal_thread.start()
move_thread.start()
active_threads = threading.enumerate()
print(f"Updated thread count: {len(active_threads)}")
Passing Arguments to Threads
Three methods exist for passing arguments to thread functions.
1. Tuple Arguments
threading.Thread(target=function_name, args=(arg1, arg2, ...))
import time
import threading
def process_values(x, y, z):
print(f"Values: x={x}, y={y}, z={z}")
time.sleep(1)
if __name__ == '__main__':
for _ in range(5):
thread_instance = threading.Thread(target=process_values, args=(10, 20, 30))
thread_instance.start()
2. Dictionary Arguments
threading.Thread(target=function_name, kwargs={"param1": value1, "param2": value2, ...})
import time
import threading
def process_values(x, y, z):
print(f"Values: x={x}, y={y}, z={z}")
time.sleep(1)
if __name__ == '__main__':
for _ in range(5):
thread_instance = threading.Thread(target=process_values, kwargs={"x": 10, "z": 20, "y": 30})
thread_instance.start()
3. Combined Tuple and Dictionary Arguments
threading.Thread(target=function_name, args=(arg1,), kwargs={"param2": value2, ...})
import time
import threading
def process_values(x, y, z):
print(f"Values: x={x}, y={y}, z={z}")
time.sleep(1)
if __name__ == '__main__':
for _ in range(5):
thread_instance = threading.Thread(target=process_values, args=(10,), kwargs={"z": 20, "y": 30})
thread_instance.start()
Daemon Threads
A daemon thread terminates automatically when the main thread exits. Configure by setting thread_instance.daemon = True before starting the thread (default is False).
import time
import threading
def repetitive_output(a, b, c):
for iteration in range(5):
print(f"Current output: {iteration}")
time.sleep(1)
if __name__ == '__main__':
thread_instance = threading.Thread(target=repetitive_output, args=(10,), kwargs={"c": 20, "b": 30})
thread_instance.daemon = True
thread_instance.start()
time.sleep(1)
print("Main thread terminating...")
exit()
Concurrency vs. Parallelism
Multi-Tasking Concept
Multi-tasking involves the operating system rapidly switching between tasks. Each task receives a small time slice (e.g., 0.01 seconds) before switching to the next. The high switching speed creates the illusion of simultaneous execution.
Definitions
- Concurrency: Occurs when the number of tasks exceeds CPU cores. The operating system scheduler gives the appearance of simultaneous execution through rapid task switching.
- Parallelism: Occurs when the number of tasks is less than or equal to CPU cores, enabling genuine simultaneous execution.
True parallelism requires a multi-core CPU. However, with many more tasks then cores, the operating system schedules tasks across all available cores.
Creating Custom Thread Classes
For better encapsulation, you can define custom thread classes by extending threading.Thread.
Implementation steps:
- Inherit from
threading.Thread - Override the
run()method - Instantiate the class and call
start()
import threading
import time
class CustomThread(threading.Thread):
def __init__(self, iterations):
super().__init__() # Required parent class initialization
self.iterations = iterations
def run(self):
for i in range(self.iterations):
print(f"Executing run method in custom thread: {i}")
time.sleep(0.5)
if __name__ == '__main__':
custom_thread = CustomThread(5)
custom_thread.start()
Global Variable Sharing Among Threads
Multiple threads within the same process share access to global variables.
import threading
import time
shared_counter = 0
def increment_counter():
global shared_counter
for _ in range(10):
shared_counter += 1
time.sleep(0.5)
print(f"Thread 1 counter: {shared_counter}")
def read_counter():
for _ in range(10):
time.sleep(0.5)
print(f"Thread 2 counter: {shared_counter}")
if __name__ == '__main__':
t1 = threading.Thread(target=increment_counter)
t2 = threading.Thread(target=read_counter)
t1.start()
t2.start()
while len(threading.enumerate()) > 1:
time.sleep(1)
print(f"Final counter value: {shared_counter}")
Issues with Shared Variables
Race conditions can occur when multiple threads modify a shared variable concurrently without proper synchronization.
import threading
shared_counter = 0
def worker_one():
global shared_counter
for _ in range(1000000):
shared_counter += 1
print(f"Worker one result: {shared_counter}")
def worker_two():
global shared_counter
for _ in range(1000000):
shared_counter += 1
print(f"Worker two result: {shared_counter}")
if __name__ == '__main__':
t1 = threading.Thread(target=worker_one)
t2 = threading.Thread(target=worker_two)
t1.start()
t2.start()
while len(threading.enumerate()) > 1:
time.sleep(1)
print(f"Main thread result: {shared_counter}")
Solution Using join()
The join() method forces sequential execution, converting concurrent operations to sequential ones.
import threading
shared_counter = 0
def worker_one():
global shared_counter
for _ in range(1000000):
shared_counter += 1
print(f"Worker one result: {shared_counter}")
def worker_two():
global shared_counter
for _ in range(1000000):
shared_counter += 1
print(f"Worker two result: {shared_counter}")
if __name__ == '__main__':
t1 = threading.Thread(target=worker_one)
t2 = threading.Thread(target=worker_two)
t1.start()
t1.join() # Wait for t1 to complete
t2.start()
while len(threading.enumerate()) > 1:
time.sleep(1)
print(f"Main thread result: {shared_counter}")
Synchronous vs. Asynchronous Operations
- Synchronous: Tasks execute in a specific sequence, where one must complete before another begins. Only one primary execution flow exists (e.g., a conversation where one speaker talks at a time).
- Asynchronous: Tasks execute independently without sequencing constraints, allowing multiple concurrent execution flows (e.g., sending multiple messages without waiting for responses).
Mutex Locks for Synchronization
A mutex (mutual exclusion) lock provides synchronization for shared resources. A lock has two states: locked and unlocked. When a thread locks a resource, other threads cannot modify it until it's unlocked.
import threading
import time
shared_counter = 0
lock = threading.Lock()
def increment_with_lock():
global shared_counter
lock.acquire()
for _ in range(1000000):
shared_counter += 1
lock.release()
print(f"Thread 1 final value: {shared_counter}")
def another_increment_with_lock():
global shared_counter
lock.acquire()
for _ in range(1000000):
shared_counter += 1
lock.release()
print(f"Thread 2 final value: {shared_counter}")
if __name__ == '__main__':
t1 = threading.Thread(target=increment_with_lock)
t2 = threading.Thread(target=another_increment_with_lock)
t1.start()
t2.start()
while len(threading.enumerate()) > 1:
time.sleep(1)
print(f"Final counter value: {shared_counter}")
Deadlocks
A deadlock occurs when two or more threads hold resources while waiting for resources held by other threads, creating a circular dependency.
Example of a potential deadlock:
import threading
def access_element(idx):
numbers = [1, 3, 5, 7, 9]
lock.acquire()
if idx >= len(numbers):
print(f"Index {idx} out of bounds")
return # Missing lock.release() causes deadlock
print(numbers[idx])
lock.release()
if __name__ == '__main__':
lock = threading.Lock()
for i in range(10):
t = threading.Thread(target=access_element, args=(i,))
t.start()
Solution: Ensure lock release before returning.
import threading
def access_element(idx):
numbers = [1, 3, 5, 7, 9]
lock.acquire()
if idx >= len(numbers):
print(f"Index {idx} out of bounds")
lock.release() # Release lock before returning
return
print(numbers[idx])
lock.release()
if __name__ == '__main__':
lock = threading.Lock()
for i in range(10):
t = threading.Thread(target=access_element, args=(i,))
t.start()