Skip to content

A Comprehensive Guide to Python Threading and Concurrency for Technical Interviews

Updated: at 04:56 AM

Threading and concurrency are advanced concepts in Python programming that allow developers to execute multiple parts of a program simultaneously to improve performance and efficiency. In technical interviews, interviewers frequently ask candidates questions about threading and concurrency to assess their experience with parallelism and ability to handle complexity in large programs. This guide provides an in-depth look at threading and concurrency in Python to help prepare for technical interviews.

We will cover key concepts like the Global Interpreter Lock (GIL), multi-threading vs multi-processing, thread synchronization, race conditions, deadlocks, and more. Real-world examples and sample code are provided to illustrate the techniques. By the end of this guide, you will have a strong fundamental understanding of threading and concurrency in Python that will demonstrate your technical knowledge during interviews.

Overview of Threading and Concurrency

Threading and concurrency enable multiple tasks or computations to be executed concurrently within a program. This allows different components of a program to run simultaneously and can improve performance, responsiveness, and efficiency.

Threading refers to the ability to execute multiple threads of execution concurrently within a single process. A thread is a sequence of instructions that can be managed independently by the operating system scheduler.

Concurrency is a more general concept describing the ability of different parts of a program to be executed out-of-order or in partial order without affecting the final outcome. This includes multi-threading as well as asynchronous programming.

Python supports both multi-threading and multi-processing to achieve concurrency. Here are some key differences:

Global Interpreter Lock (GIL) in Python

A key concept to understand in Python threading is the Global Interpreter Lock or GIL. The GIL is a mutual exclusion lock that allows only one thread to execute Python bytecodes at a time. This prevents multiple threads from executing Python code simultaneously.

The GIL simplifies coding in Python by avoiding the need for explicit locking when accessing Python objects. But it also limits parallelism since only one thread can execute Python code at once. However, other threads are still able to run I/O operations and release the GIL.

# GIL allows only one thread to execute Python bytecodes at a time

import threading

x = 0

def increment():
  global x
  x += 1

def thread_task():
  for _ in range(100000):
    increment()

t1 = threading.Thread(target=thread_task)
t2 = threading.Thread(target=thread_task)

t1.start()
t2.start()

t1.join()
t2.join()

print("x =", x)

# x = 200000

As seen above, the GIL prevents full parallel execution despite using threads. So Python multi-threading is better suited for I/O bound tasks rather than CPU bound computations.

Multi-threading in Python

The threading module in Python provides thread-based parallelism. Some key aspects of multi-threading in Python include:

Here is an example of creating and running threads in Python:

import threading

def thread_task(num):
  print(f"Task {num} started")

for i in range(10):
  thread = threading.Thread(target=thread_task, args=(i,))
  thread.start()

print("Main thread exiting")

This creates and starts 10 threads that run concurrently to execute the thread_task() function.

Thread Synchronization

Thread synchronization is important to avoid race conditions when threads access shared resources. The Lock class allows mutual exclusion:

import threading

lock = threading.Lock()

def thread_task():
  with lock:
    # thread-safe access

Only one thread can acquire the lock and execute the critical section at a time.

The RLock() reentrant lock allows acquiring the lock multiple times by the same thread. And Semaphore limits access to a fixed number of threads.

Avoiding Deadlocks

Deadlocks occur when threads are blocked waiting for locks that will never be released. This can halt program execution. Common causes include:

Deadlocks can be avoided by:

Multi-processing in Python

The multiprocessing module enables process-based parallelism in Python. Some key features include:

Here is an example of creating multiple processes:

from multiprocessing import Process

def process_task(num):
  print(f"Task {num} running")

if __name__ == "__main__":
  for i in range(5):
    p = Process(target=process_task, args=(i,))
    p.start()
  print("Main process joined")

Since processes have separate memory spaces, no lock is needed to share state. But synchronization constructs like locks can be useful.

Thread vs Process Tradeoffs

Threads have some advantages over processes:

But processes avoid GIL limitations and benefit from multiple CPU cores.

In general:

Race Conditions and Mutexes

A race condition occurs when the timing or ordering of events affects program correctness. This often happens with threads when accessing shared resources without synchronization.

For example, consider two threads incrementing a shared counter:

counter = 0

def increment():
  global counter
  # read value into register
  tmp = counter
  # increment
  tmp += 1
  # write back
  counter = tmp

t1 = Thread(target=increment)
t2 = Thread(target=increment)
t1.start()
t2.start()

The final value of counter depends on thread timing. If both read-increment-write sequences interleave, counter ends up incremented only once rather than twice.

This can be fixed using a mutex (mutual exclusion). In Python, the Lock class provides mutual exclusion:

lock = Lock()

def increment():
  global counter
  with lock:
    tmp = counter
    tmp += 1
    counter = tmp

Using the mutex ensures the critical section is atomic. The other thread waits until the lock is released.

Deadlocks and Lock Ordering

Deadlocks occur when threads are blocked waiting on each other holding locks. For example:

lock_1 = Lock()
lock_2 = Lock()

def thread_1():
  with lock_1:
    with lock_2:
      # do work

def thread_2():
  with lock_2:
    with lock_1:
      # do work

If thread_1 acquires lock_1 and thread_2 acquires lock_2, they will deadlock waiting for the other lock.

Solutions include:

Careful lock ordering and avoidance of circular wait prevents deadlocks.

Thread Pools and Queues

Thread pools manage a pool of threads for efficient execution of tasks in parallel. The concurrent.futures module provides thread pool support:

with ThreadPoolExecutor() as executor:
  executor.map(task, data)

This maps the function over the data in parallel using a thread pool.

Queues can be used for safe communication between threads:

from queue import Queue

q = Queue()

def producer():
  q.put(item)

def consumer():
  print(q.get())

The producer thread adds to the queue while the consumer thread retrieves items.

Python Asyncio for Concurrency

The asyncio module provides infrastructure for asynchronous programming to achieve concurrency. It uses cooperative multitasking and an event loop:

import asyncio

async def task():
  print("Task executing")
  await asyncio.sleep(1)

async def main():
  await asyncio.gather(
    task(),
    task()
  )

asyncio.run(main())

Asyncio interleaves execution of the tasks on a single thread by having them cooperate by awaiting. This concurrent model avoids GIL limitations.

Real World Examples

Here are some real-world examples demonstrating uses of threading and concurrency in Python:

Web scraping - Threads can parallelize scraping multiple web pages:

def scrape_page(url):
  # scrape page

threads = []
for url in urls:
  thread = Thread(target=scrape_page, args=(url,))
  thread.start()
  threads.append(thread)

for thread in threads:
  thread.join()

Serving web requests - An asynchronous web server can process multiple requests concurrently:

async def handle_request(request):
  # return response

async def main():
  server = await asyncio.start_server(handle_request, "0.0.0.0", 8000)
  await server.serve_forever()

asyncio.run(main())

Machine learning - Multi-processing can parallelize CPU intensive model training:

def train_model(data):
  # train model

if __name__ == "__main__":
  with multiprocessing.Pool() as pool:
    pool.map(train_model, partitioned_data)

These are just some examples of applying threading and concurrency to improve performance of Python programs.

Summary

Key points covered in this guide:

Threading and concurrency enable parallelism in Python to build efficient programs. Mastering these concepts will demonstrate strong technical knowledge during Python interviews.

Conclusion

This comprehensive guide examined key aspects of threading and concurrency in Python including multi-threading, multi-processing, asynchronous programming, thread safety, synchronization, deadlocks, and real-world examples. With the fundamental knowledge and sample code covered, you should feel prepared to discuss Python parallelism and concurrency confidently during technical interviews. Remember to practice implementing the examples yourself for coding fluency. Concurrency is an essential skill for Python developers. Master it, and you will ace the multi-threading and parallelism questions during your next technical interview.