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:
- Multi-threading executes multiple threads within the same process. Threads share memory and resources.
- Multi-processing runs independent processes each with their own memory space. Processes have higher overhead compared to threads.
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:
- Creating threads using the
Thread
class. - Starting threads with the
thread.start()
method. join()
blocks until thread completes.Lock
objects allow thread synchronization and prevent race conditions.local()
makes variables thread-specific.Enum
enables synchronization between threads.
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:
- Circular lock dependency - Thread 1 locks A then waits for B, thread 2 locks B then waits for A
- Improper lock acquisition order - Locks must always be acquired and released in the same global order
Deadlocks can be avoided by:
- Using
timeout
on lock acquisition - Using
try/finally
to ensure lock release - Lock hierarchies - Each lock is assigned a level, lower levels acquired before higher
Multi-processing in Python
The multiprocessing
module enables process-based parallelism in Python. Some key features include:
Process
class to create processes- Inter-process communication using queues and pipes
- Locks and semaphores for synchronization
- Shared memory for efficient data exchange
- Process pools for simpler parallel mapping
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:
- Shared memory - Threads share state. Processes require Inter-Process Communication (IPC)
- Lower overhead - Context switching between threads is faster than processes
But processes avoid GIL limitations and benefit from multiple CPU cores.
In general:
- Use threads for I/O bound tasks - file, network I/O
- Use processes for CPU intensive parallel processing
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:
- Timeout on lock acquisition
- Lock hierarchy - Each lock assigned a level, lower levels acquired first
- Avoid circular wait - Only acquire locks in a global fixed order
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 executes multiple threads within a process for concurrency
- Global Interpreter Lock allows only one Python thread at a time
- Use multi-threading for I/O bound tasks
- Multi-processing avoids GIL by using separate processes
- Locks and mutexes prevent race conditions
- Careful lock ordering avoids deadlocks
- Thread pools manage worker threads
- Asyncio provides cooperative concurrent model
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.