I used this to review Python multithreading for upcoming interviews; I hope it helps you. PS: It is repetitive to help with memorization — you can’t understand if you don’t remember.

Must-Know Terms

Questions To Ask Yourself About Python Multithreading

  1. What are the gotchas I should look out for?
  2. What is a thread?
  3. What is multithreading?
  4. How does multithreading differ from multiprocessing?
  5. When should I use threads vs processes?
  6. What is the Global Interpreter Lock (GIL)?
  7. What is the Global Interpreter Lock (GIL) on multithreading?
  8. How do I create a new thread?
  9. How do I pass arguments into a thread?
  10. How do I start a thread? What are the three gotchas for starting threads?
  11. What are the different types of threads?
  12. What is a daemon thread?
  13. How do I create a daemon thread?
  14. How do I check if a thread has started? If it is running? Stopped?
  15. How do I wait for a thread to finish? What is thread joining?
  16. Can I timeout thread.join() — how do I wait X seconds for a thread to finish?
  17. How do I stop a thread?
  18. Can I restart a thread?
  19. Does a thread exit when it runs into an unhandled exception?
  20. What happens to unhandled exceptions in threads? Does it cause an error in the parent thread?
  21. How do I handle exceptions thrown in threads?
  22. What are the four limitations of threading.excepthook?
  23. How do I exchange information between threads?
  24. What is thread safety?
  25. What happens if I don’t use thread-safe objects?
  26. Why do I need thread-safe objects if Python has the Global Interpreter Lock (GIL)?
  27. Do all objects passed into threads have to be thread-safe?
  28. How do you synchronize threads to avoid race conditions? How do you tell one thread to wait for another?
  29. How can I keep track of resources used by multiple threads? What are semaphores?
  30. How can I schedule a thread to start at a particular time?
  31. Where can I learn more?

What are the gotchas I should look out for?

Don’t use threading.Thread, there are better alternatives that require less setup, have friendly interfaces, and are more forgiving — only use threading.Threads if the two options below are insufficient (both use threading.Thread underneath).

  1. Asyncio — gives you the option to switch to using processes instead of threads
  2. concurrent.futures.ThreadPoolExecutor — handles creating, executing, and keeping track of threads

Threads do NOT execute code simultaneously because the Global Interpreter Lock limits the CPU to execute only one thread per Python process. Threads are not run in parallel.

Threads aren’t meant for everything.

  1. If you are doing CPU bound tasks (math, data transformations, etc.), use multiprocessing, not multithreading — if you are doing I/O bound tasks (network calls, DB queries, disk reads/writes), use multithreading.
  2. But if you need to do a lot of communication between your tasks, use threads — shared memory makes it easier to communicate vs Inter Process Calls (IPC), network calls, third-party services, pickling, etc., for processes
  3. Need to use shared memory i.e. work on one object, threads

You have to keep track of the thread’s state.

  1. You can only call thread.start() once.
  2. You can call thread.join() multiple times but…
  3. It will error when calling thread.join() on itself, i.e., you can’t join a thread on itself because it will create a deadlock.
  4. You can’t call thread.join() on a thread that has not started — causes a runtime error.

Stopping threads is tricky.

  1. Threads can cause your program to freeze. Once you start a thread, it does NOT stop until it is completed or runs into an unhandled error — even if the process tries to exit (shutdown the program), the thread keeps running and prevents the exit (unless it is a daemon thread).
  2. Daemon threads don’t freeze your program — you can exit the current thread, and if the daemon thread is the only thing alive, the Python will kill, but this may cause memory leaks (DB connections, etc., won’t be cleaned).

Threads don’t start and stop when you want them to but when they can.

  1. thread.start() does NOT immediately execute the function passed into the thread — it attempts to find a free thread, but if all the process’s threads are busy, it waits for them to be free.
  2. thread.is_alive() will return False even if the thread is not freed (after the code is run, the thread needs to be cleaned up and waits to do so). Be careful if you want to reuse that same CPU thread to run a different piece of code or if you want to immediately start another job thinking you have a thread that is free.

You can’t rely on Python to gracefully handle errors in threads.

  1. Unhandled errors thrown in child threads will be silent unless you catch them or use threading.excepthook (in the parent) to listen for errors.
  2. threading.excepthook will not be called when a system kill message is sent i.e. when the program is abruptly closed.
  3. Errors in threading.excepthook will be sent to the System errors handler, so make sure to set that up if you want to do complex logging/alerting in excepthook.
  4. Delete the trace and error objects in excepthook, don’t pass them around or you will get a circular dependency and eventually a memory leak.
  5. Start/Run/Join don’t propagate exceptions thrown within the thread.

Debugging threads can be hard; don’t treat errors in threads like regular errors.

  1. Threads share the same memory but NOT the same callstack — error traces will be available but out of context in threading.excepthook. The error traces won’t show who started the thread, only what function the thread ran and where the error occurred in that function.
  2. Once the thread throws an error, it goes through the clean-up process. This means you won’t have access to actual objects (or any other objects from within the thread) that caused the error when you handled the error in excepthook.
  3. Race conditions are a pain — this bug occurs when two threads try to access the same resource, and the order of their access is completely random, resulting in unpredictable states. Imagine a banking app that uses two separate threads for deposits and withdrawals. If a customer decides to deposit and withdraw at the same time but these two threads are not synchronized with each other (e.g., do deposits first always), the customer can end up with a negative balance. This is what locks are meant to solve, but it is very hard to detect race conditions since the outcome is so random — you have to wait until something goes wrong.

Forgetting that threads need thread-safe objects to avoid race conditions and data corruption.

  1. It is easy to pass objects into threads and start changing them without realizing that other threads may depend on this object. Before you pass anything into a thread, remember to ask yourself what you will do to it.

Forgetting to release locks — deadlocks.

  1. Every call to acquire() should have a mirror call to release(). This is very easy to forget. A good coding habit is to never write one of these methods alone — always write them together, even if you don’t know when you call the other method.

What is a thread?

A thread is a semi-isolated stream of operations — a single worker at a factory is a thread. They can do work without supervision, but since they work in the factory, they have to share the same resources as everyone else in the factory. Threads share memory with the parent thread that created them and their sibling threads. The call stack, the series of functions executed by the thread, is unique and separate for each thread — this means you won’t be able to find where the thread was started using the error trace, only what function the thread executed.

What is multithreading?

Multithreading is an approach to solving problems by splitting them into multiple independent execution paths. Imagine a worker at a car factory; that worker can do everything to build the car, or we can have multiple workers, each building different parts of the car separately and then putting them together. The latter approach is more efficient under certain circumstances. Multithreading is just that, using more workers (threads) to do a task concurrently (together but not at the same time).

But don’t just use multithreading everywhere. It is more expensive resource-wise, and the code is more complex. Knowing when to use multithreading is 80% of the journey. Once you know, creating and managing threads is quite easy. Even synchronizing them can be simple with the correct mental model.

How does multithreading differ from multiprocessing?

Suppose a thread is a worker at a factory. A process is the factory. It is an isolated stream of operations and resources that contains at least one thread.

Multithreading

Multiprocessing

When should I use threads vs processes?

But you shouldn’t use threads or processes directly; you should use the AsyncIO module — whatever your problem is, you probably don’t need to implement a solution from scratch using threads or processes. AsyncIO is an abstraction over multithreading and multiprocessing — it turns your Python script into an event-driven script; instead of waiting for network or DB calls to be finished, you can pass those calls a callback and continue running the script. AsyncIO will spin up a thread or process (if you want it; sometimes, it can get away with just using the main thread) to handle the waiting and response. AsyncIO handles all the multithreading and multiprocessing use cases. It is well-tested and documented. If that’s not enough, there are other libraries built on top of AsyncIO for specific tasks like HTTP communication.

What is the Global Interpreter Lock (GIL)?

It is the toll gate at the heart of Python. For each Python process, there is exactly one Python interpreter (the thing that reads and executes your Python code). However, processes can have multiple threads running at the same time. What if two threads try to use the same location in memory, e.g., the same object at the same time? What if a thread tries to delete an object that is being used by another thread? All these behaviors are unpredictable and would cause errors. The GIL exists to solve these problems in the simplest possible way by making these scenarios impossible or at least less likely. It does this by limiting how many threads can be executed by the interpreter at a time. That’s why it is called a lock. It locks the interpreter from all other threads while a thread is executing. You don’t have to worry about memory corruption while multithreading in Python.

What is the Global Interpreter Lock (GIL) impact on multithreading?

The GIL limits one thread per process (each process has one Python interpreter) to execute on the CPU simultaneously. This prevents threads from executing conflicting code, e.g., deleting an object another thread is using. However, this limits parallelism; operations that use the CPU (math, data manipulation, etc.) are run sequentially when using threads. This means that Python threads are not run in parallel—the threads are not running at the same time.

How do I create a new thread?

  1. Import the threading module.

  2. Create a new instance of threading.Thread class.

  3. Pass it the function you want to execute on the thread.

import threading

# Define a function that will be executed in the new thread
def print_numbers():
  for i in range(1, 6):
    print("Number:", i)

# Create a new thread and specify the function to execute
thread = threading.Thread(target=print_numbers)

How do I pass arguments into a thread?

threading.Thread(target=print_numbers, args=[6])

args expects only iterables (tuples, lists, etc.) to pass a single variable use (6,) or [6] — (6) is not a tuple; it is just 6

threading.Thread(target=print_numbers, args=[ARG1, ARG2, ARG3, …, ARGN])

threading.Thread(target=print_numbers, kargs={ 'key1': val1, 'key2': val2 })

How do I start a thread? What are the three gotchas for starting threads?

Call start() on the thread object to start it:

import threading

def print_numbers(num):
  for i in range(1, num):
    print("Number:", i)

thread = threading.Thread(target=print_numbers, args=[6])
thread.start()

But beware:

What are the different types of threads?

What is a daemon thread?

Daemons (pronounced like “demon” but not as nefarious, more like spirits) are threads meant for background jobs. They are excellent for tasks that can or need to be abruptly closed.

But daemonic threads come with caveats.

How do I create a daemon thread?

Python 3.3 and later:

thread = threading.Thread(target=print_numbers, daemon=True)

All older versions of Python are very error-prone since you can forget to set the flag.

thread = threading.Thread(target=print_numbers)
thread.daemon = True

A common pattern in older versions of Python to avoid forgetting to set the flag:

class DaemonThread(threading.Thread):
  def __init__(self, target=None, args=(), kwargs={}):
    super(DaemonThread, self).__init__(target=target, args=args, kwargs=kwargs)
    self.daemon = True

How do I check if a thread has started? If it is running? Stopped?

Started, use thread.is_alive():

Running, use thread.is_alive(), it will return true up until the code finishes execution but NOT the thread — docs.

Stopped, use thread.is_alive(), it will return false

How do I wait for a thread to finish? What is thread joining?

You can use join() to wait for a thread to finish — this is called joining.

thread_a.join() # goto sleep until thread_a is finished
print("Main thread continues…")

Can I timeout thread.join() — how do I wait X seconds for a thread to finish?

You can pass thread.join() a timeout in seconds.

timeout_in_seconds = 2
# waits two seconds than wakes up and executes next line
thread.join(timeout_in_seconds)

How do I stop a thread?

There is no easy or direct way provided by the Threading module or Thread class to stop a running thread. But you can:

An example of the event system:

import threading
from time import sleep

def worker(event):
  print('Thread started, waiting for start flag.')
  event.wait()
  print('Flag was set, thread awoken.')
  num = 0
  while event.is_set():
    # do work
    num += 1
    print('work done…', num)
  print('Thread stopped.')

event = threading.Event()

thread = threading.Thread(target=worker, args=[event])
# Start the thread but it waits on the event
thread.start()

# Set the flag to true
event.set()

# wait for the thread to do work
sleep(2)

# Stop the thread
event.clear()
thread.join()

An excellent article with examples: https://www.geeksforgeeks.org/python-different-ways-to-kill-a-thread/

Can I restart a thread?

No, you can only call start() once per thread object, BUT the physical thread (the thing in your CPU that does the work) can be reused multiple times. If you need to retry a failed thread, create a new thread and pass it the same function that the old thread had.

Does a thread exit when it runs into an unhandled exception?

Yes, just like any Python script — a thread will exit when it encounters an error.

What happens to unhandled exceptions in threads? Does it cause an error in the parent thread?

The error is passed up to the parent thread, but it is NOT rethrown in the parent thread. It is made available through the Threading event system. It is up to you to listen for these errors with threading.excepthook and handle them there.

How do I handle exceptions thrown in threads?

By using threading.excepthook:

import threading
import traceback

def worker():
  print('Thread started, throwing error.')
  raise Exception('An error occurred in the worker thread.')

def errorHandler(args):
  print('An error occurred in the worker thread.')
  print(f'Exception type: {args.exc_type}')
  print(f'Caught exception: {args.exc_value}')
  # error tracebacks DO NOT include callstack of parent thread
  traceback.print_tb(args.exc_traceback)

# setup the error handler
threading.excepthook = errorHandler

thread = threading.Thread(target=worker)
thread.start()
thread.join()

print('Thread has finished.')

Excepthook callback is given an object with these attributes:

What are the four limitations on threading.excepthook?

How do I exchange information between threads?

Use a thread-safe object like Python’s queue module. You can safely store information on the queue and retrieve that information without worrying about synchronizing your threads (i.e., using locks). The queue will ensure only one thread updates the queue at a time.

What is thread safety?

Thread safety is a mechanism that involves locking objects to avoid common problems when multiple threads attempt to change a shared object simultaneously. It works by limiting which threads can access the object at any given time — some objects in Python are built thread-safe, but most are not.

What happens if I don’t use thread-safe objects?

Using non-thread-safe objects in threads can lead to these problems:

Why do I need thread-safe objects if Python has the Global Interpreter Lock (GIL)?

Yes, even with the GIL, Python needs thread-safe objects. The GIL does not prevent race conditions or data corruption.

Do all objects passed into threads have to be thread-safe?

No — you can pass any object into a thread and use it in that thread. But if that object is also going to be used in another thread, it should be thread-safe.

How do you synchronize threads to avoid race conditions? How do you tell one thread to wait for another?

Use locks! Locks are special objects that we can pass into threads that can make threads sleep or wake. Imagine a busy factory with one bathroom (one toilet), all these workers want to use the toilet, and we don’t want them barraging in on each other, so we put a lock on the bathroom and provide a key for it. Workers end up queuing in front of the bathroom, waiting for it to be unlocked instead of constantly opening the door and seeing if there’s someone in there. Thread locks work the same way.

When you have something that you need to control access to or simply have to orchestrate one task before another, you introduce a lock. Pass the lock to the threads that need it. In the threads, you acquire() the lock before you access the resource. This is like checking if the bathroom is unlocked. If the thread can’t get the lock, acquire() puts the thread to sleep — the bathroom is locked, wait for it. When the bathroom is free, acquire() wakes the thread and gives it the lock. After this thread is done, it is CRUCIAL to remember to release() the lock i.e. unlock the bathroom. If the release() is NOT called, the other threads will never wake up.

The pattern is:

Everything starts with acquire() and ends with release() — this is the crucial cycle you need to follow with all forms of concurrent programming. To prevent bugs, it is common to count the number of times acquire() and release() have been called — the number should always be equal.

lock = threading.Lock()
lock.acquire()

def worker():
  print('Getting lock.')
  lock.acquire()
  print('Have lock.')
  lock.release()
  print('Released lock.')

thread = threading.Thread(target=worker)
thread.start()

# thread is alive but HAS not finished because it is waiting for the lock to
# be released, hence the join() times out
timeout = 2
thread.join(timeout)

assert thread.is_alive(), 'The thread should still be alive'

# we release the lock, the thread can finish
lock.release()
thread.join()

How can I keep track of resources used by multiple threads? What are semaphores?

A semaphore is a lock with a counter. It can be used to keep track of how many times a shared resource is used. For example, if you have an object that allows your threads to connect to a server with only three connections at a time, you can use a semaphore with a counter set to three to limit the number of connections. Not doing this can lead to the threads failing because the server is unreachable.

This is how semaphores work:

import threading
import time

# Shared resource
shared_resource = {
  "connect": lambda: print("Connected to server."),
}
# Semaphore with a maximum of 3 permits
semaphore = threading.Semaphore(3)

# Function to access the shared resource
def access_shared_resource(thread_name):
  print(f"{thread_name} started, waiting for semaphore")
  semaphore.acquire()
  print(f"{thread_name} acquired semaphore")
  shared_resource["connect"]()
  time.sleep(1)  # Simulate some work
  print(f"{thread_name} released semaphore")
  semaphore.release()

# Create multiple threads to access the shared resource
threads = []
for i in range(5):
  thread = threading.Thread(target=access_shared_resource, args=(f"Thread-{i+1}",))
  thread.start()
  threads.append(thread)

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


# Notice how the threads don't acquire
# the semaphore in the order they were created, nor
# do they execute in that order - this is due to the GIL
# randomly switching which thread is run.
# Thread-1 started, waiting for semaphore
# Thread-1 acquired semaphore
# Thread-2 started, waiting for semaphore
# Connected to server.
# Thread-2 acquired semaphore
# Thread-4 started, waiting for semaphore
# Thread-5 started, waiting for semaphore
# Thread-3 started, waiting for semaphore
# Connected to server.
# Thread-4 acquired semaphore
# Connected to server.
# Thread-2 released semaphore
# Thread-4 released semaphore
# Thread-1 released semaphore
# Thread-5 acquired semaphore
# Thread-3 acquired semaphore
# Connected to server.
# Connected to server.
# Thread-5 released semaphore
# Thread-3 released semaphore

How can I schedule a thread to start at a particular time?

Use threading.Timer, takes the same arguments as Thread but with an extra argument, called interval, for the number of seconds to delay before starting the thread.

Things to remember:

import threading
import datetime

now = datetime.datetime.now()
def print_hello(now, seconds):
  print(f"Hello, world, {datetime.datetime.now()}!")
  assert datetime.datetime.now() > now + datetime.timedelta(seconds=seconds), 'The timeout should have passed'

seconds = 5
timer = threading.Timer(
  seconds, print_hello, args=(now, seconds)
)

print(f"Starting timer at {now}")

timer.start()
timer.join()

print(f"Finished {datetime.datetime.now()}")

Where can I learn more?

Multithreading

AsyncIO

concurrent.futures.ThreadPoolExecutor

Multiprocessing

Global Interpreter Lock (GIL)

Geek to Geek — Different Ways to Kill a Thread

Locks