in

Python Threading: A Comprehensive Guide

Threading in Python allows you to execute multiple threads concurrently within a process. The Python threading module provides a simple way to implement concurrency and parallelism in your code.

In this comprehensive guide, you‘ll learn:

  • What are processes and threads
  • Difference between processes and threads
  • Understanding concurrency and parallelism
  • How Python implements multithreading
  • Using the threading module in Python
  • Starting threads and thread arguments
  • Combining multiple threads
  • Controlling threads and handling errors
  • Thread synchronization with locks
  • Using queues for thread communication
  • Some common threading pitfalls to avoid

So let‘s get started!

Processes vs Threads

Before diving into Python threading, let‘s first understand the difference between a process and a thread.

What is a Process?

A process can be defined as an execution environment that consists of instructions, user-data, and system-data segments, as well as lots of other resources such as CPU, memory, file handles, sockets, etc.

Essentially, a process is an executing instance of an application. For example, when you run a Python interpreter, it spawns a Python process which reads in your code and executes it within the process‘s environment.

Processes are independent of each other and provide isolation and their own separate address space. Multiple processes can execute in parallel on modern multi-core CPUs.

What is a Thread?

A thread is a "lightweight" process that exists within a process. Multiple threads can exist within a process with shared resources including memory, file handles, sockets, etc.

Threads are executed concurrently and share the same memory space. Different threads within a process can execute in parallel on separate CPU cores.

Threads are used as a way to improve application responsiveness and performance. For example, one thread can fetch data in the background while another thread formats and displays the data.

Comparing Processes and Threads

Here is a comparison of some key differences between processes and threads:

  • Processes have their own separate memory space while threads share memory
  • Threads within a process share resources like memory, sockets, file handles etc. Processes do not.
  • Switching between threads is faster than switching between processes
  • Threads within a process can communicate with each other more easily compared to inter-process communication
  • If one thread crashes, it won‘t affect other threads but a process crash will kill all threads
  • Threads are more lightweight since they use less resources than processes

So in summary, threads are more efficient and lightweight but processes provide better isolation.

Concurrency vs Parallelism

Before we dive into Python threading, let‘s also understand concurrency and parallelism.

Concurrency

Concurrency refers to the ability of a program to be decomposed into constituent parts that could potentially be executed out of order or in partial order without affecting the final outcome.

In a concurrent system, multiple computations can happen at the same time. This doesn‘t necessarily mean they are executing simultaneously. The important thing is that their execution is interleaved over time in a fair scheduling manner.

For example, on a single-core CPU, concurrency is implemented via rapid context switching between threads. This creates the illusion of parallel execution even though the threads are not actually running in parallel.

Parallelism

Parallelism is the simultaneous execution of two or more tasks at the same time. For example, executing multiple threads on a multi-core processor is an example of parallelism.

True parallelism requires a multi-core processor where threads and processes can be distributed over the available cores with each core executing its own instruction stream simultaneously.

So in summary, concurrency is about dealing with lots of things at once while parallelism is about doing lots of things at once.

How Python Implements Threading

Python has a Global Interpreter Lock (GIL) that ensures only one thread can run Python bytecodes at a time. This effectively prevents multiple Python threads from running on multiple CPU cores at once.

So even if you have a multi-core CPU, the GIL limits a single Python process to use only one core at a time. The reasoning behind this design decision was the avoidance of race conditions and synchronization overhead in non-threaded CPython implementation.

To allow concurrency, Python switches between threads running bytecode instructions extremely quickly. This gives an illusion of parallel execution. But true parallelism is not possible since two Python threads cannot run simultaneously on separate CPUs.

This is an important distinction from other languages like Java that create real operating system threads mapped to kernel threads.

The GIL has been highly controversial but it‘s important to understand its implications while using threading in Python. For CPU-bound parallel tasks, Python multiprocessing is usually a better choice than threading.

However, the GIL does allow Python threads to run truly simultaneously during I/O-bound operations like making a network request, reading/writing to disk or interacting with a database. During I/O-bound tasks, Python automatically releases the GIL to allow other threads to run.

So while CPU-bound parallelism isn‘t possible with the GIL, it still allows concurrent execution between threads during I/O-bound operations.

Python Threading Module

Python provides a built-in threading module to allow Python programs to have threads. By using the module, you can create threads, execute code in separate threads and synchronize them in an easy manner.

The threading module exposes a few important components:

  • A Thread class to create and manage threads
  • A Lock object to synchronize thread execution
  • A Semaphore object to control access to resources
  • Event, Timer and other utility classes

The Thread class is the primary interface to manage concurrent threads. It‘s easy to create Thread instances and start execution. Each thread can call functions, share data and synchronize with other threads.

Let‘s explore some examples of using it.

Starting a Thread

To start a new thread, create a Thread instance and pass the target function as an argument. For example:

import threading

def print_nums():
  print("Thread started")
  for i in range(10):
    print(i)

t = threading.Thread(target=print_nums)
t.start()

print("Main thread")

A few things to note here:

  • The Thread constructor takes target as the function to be executed in the thread.
  • Calling start() on the Thread actually starts execution.
  • The main thread continues execution after calling start().
  • Output of both threads is interleaved.

Starting multiple threads is as simple as creating multiple Thread instances. For example:

t1 = threading.Thread(target=print_nums) 
t2 = threading.Thread(target=print_letters)

t1.start()
t2.start()

This will start both threads almost simultaneously.

Passing Arguments to Threads

To pass arguments to the target function:

import threading

def print_nums(start, end):
  print(f"Thread started")  
  for i in range(start, end):
    print(i)

t = threading.Thread(target=print_nums, args=(1, 20))  
t.start()

Pass the arguments as a tuple to the args parameter of the Thread constructor. The arguments will be unpacked and passed to the target function.

You can also pass keyword arguments to the target function using the kwargs parameter:

t = threading.Thread(target=print_nums, 
                     kwargs={‘start‘: 1, ‘end‘: 20})

Determining and Naming Threads

Every thread has a unique id to identify it:

print(threading.current_thread().ident)

You can also set a name for a thread by passing the name parameter to Thread():

t = threading.Thread(target=print_nums, name=‘printer‘)

Naming threads is very useful for debugging and logging purposes.

Joining Threads

The .join() method on a thread instance blocks the calling thread until the target thread completes execution.

For example:

t = threading.Thread(target=print_nums)  
t.start()

# Do something here   

t.join() # Wait for t to finish

Calling join() ensures all the work being done in the thread has completed before the next steps are executed.

Combining Threads

A very common threading pattern is launching a group of threads and waiting for them all to finish:

import threading

# Threads  
t1 = threading.Thread(target=print_chars, args=(‘A‘, 100))
t2 = threading.Thread(target=print_nums, args=(1, 20))
t3 = threading.Thread(target=print_letters, args=(‘C‘, 75))

# Start threads
t1.start()
t2.start()
t3.start()   

# Wait for them to finish
t1.join()
t2.join()
t3.join()

print("All threads finished!")

This starts three threads almost simultaneously and prints a message after waiting for all of them to finish.

Daemon Threads

By default, the main program will not terminate until all threads finish execution. But sometimes, you want the program to exit immediately – without waiting for daemon threads doing background tasks.

To do this, you can set a thread as a daemon:

t = threading.Thread(target=background_print)  
t.daemon = True
t.start()

When the main thread exits, it terminates all daemon threads automatically.

Controlling Threads

There are a few ways to control thread execution:

  • threading.current_thread() – Returns the current thread object
  • threading.enumerate() – Get a list of active thread objects
  • thread.is_alive() – Checks if the thread is still executing
  • thread.isAlive – Thread status property

Here is an example:

print(threading.current_thread().getName(), "Starting")

threads = threading.enumerate()
print(threads)

print(threads[0].is_alive())

You can also set a thread to become inactive after a timeout:

t = threading.Thread(target=print_nums, args=(10,))
t.daemon = True   
t.start()

t.join(timeout=5)  # Wait 5 seconds
print(‘Finished‘)

There are many ways the Thread API can be used to monitor and control execution.

Thread Synchronization

Since threads share memory within a process, mutual exclusion locks (mutex) are needed to prevent data corruption.

For example, say two threads are manipulating a shared counter without locks:

counter = 0

def increment():
  global counter
  counter += 1

def decrement():
  global counter
  counter -= 1

If increment() and decrement() execute simultaneously on separate CPU cores, it can lead to incorrect counter value due to a race condition.

To fix this, you need to use a Lock to ensure only one thread can access the shared state at a time:

counter = 0
lock = threading.Lock()

def increment():
  global counter
  lock.acquire()
  counter += 1
  lock.release()

def decrement():
  global lock  
  lock.acquire()
  counter -= 1
  lock.release() 
  • Calling acquire() locks the mutex if it is available else the thread waits until its available.
  • Once done, the thread releases the mutex by calling release()
  • This ensures only one thread can access the shared state at a time.

Lock prevents race conditions reliably. But you need to remember to release the lock in a finally block so it doesn‘t get permanently locked.

Avoiding Deadlocks

Deadlocks occur when two threads are waiting for each other to release locks, creating a permanent blocked state.

For example:

lock_1 = threading.Lock()
lock_2 = threading.Lock()

def worker_1():
  lock_1.acquire()
  lock_2.acquire()

  # Do something

  lock_2.release()
  lock_1.release()

def worker_2():
  lock_2.acquire() 
  lock_1.acquire()

  # Do something

  lock_1.release()
  lock_2.release()  

If worker_1() acquires lock_1 and worker_2() acquires lock_2, they will reach a permanently deadlocked state waiting for each other.

To avoid this, all threads must always acquire locks in the same global order.

Thread Communication using Queue

Queue provides a safe way for threads to communicate with each other using a queue without needing locks.

One thread can push data to a Queue and another can pop data from it:

from queue import Queue

q = Queue()

# Producer thread
def producer():
  for i in range(6):
     q.put(i)

# Consumer thread
def consumer():
  while True:
    item = q.get()
    print(item)
    q.task_done() 

t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)

t1.start()
t2.start()

q.join() # Wait until all tasks are marked done

The consumer thread blocks on get() if the queue is empty and waits for the producer to put items into it.

This shows how queues can facilitate thread-safe communication without mutexes.

Some Common Threading Pitfalls

While threading can improve performance of I/O-bound tasks, there are some common pitfalls to be aware of:

  • Race conditions from unchecked shared memory access
  • Deadlocks from improper lock ordering
  • Bugs from wrong assumptions about thread safety
  • Overhead from unnecessary threads reducing performance
  • Difficulty debugging and reproducing concurrency issues

Proper use of locks, semaphores, queues and thread management is important to build robust multi-threaded Python programs.

Careful code review and testing methodology is required to detect subtle concurrency bugs before they cause issues in production.

Conclusion

The Python threading module provides a good foundation for basic threading in Python. Using Thread instances and synchronization primitives like Lock/Queue allow building multi-threaded programs.

However, for CPU-bound parallel processing, Python‘s multiprocessing module is more suitable than threading because of the GIL limitations.

I hope this guide gives you a comprehensive overview of threading in Python! Let me know if you have any other threading questions.

AlexisKestler

Written by Alexis Kestler

A female web designer and programmer - Now is a 36-year IT professional with over 15 years of experience living in NorCal. I enjoy keeping my feet wet in the world of technology through reading, working, and researching topics that pique my interest.