Python Threading
Learn how Python threads work, how to create and manage threads, protect shared data with locks, use thread pools, and choose between threading and asynchronous programming for I/O-bound applications.
Learning Objectives
Threading is another important concurrency technique in Python. It is especially useful when a program needs to manage multiple I/O-bound operations.
By the end of this lesson, you will be able to:
- Understand what a thread is.
- Explain concurrency and parallelism.
- Create threads using the threading module.
- Start and wait for threads.
- Pass arguments to threads.
- Create multiple threads.
- Understand daemon threads.
- Recognize race conditions.
- Protect shared data with locks.
- Use RLock, Semaphore, Event, and Queue.
- Use ThreadPoolExecutor.
- Handle exceptions in threaded applications.
- Compare threading with asyncio.
- Identify suitable real-world use cases.
What Is Threading?
A thread is a unit of execution within a process.
A Python program normally starts with at least one thread, commonly called the main thread.
Threading allows an application to create additional threads that can perform work concurrently.
Simple idea
Imagine a restaurant with several workers. Instead of one worker handling every task sequentially, multiple workers can handle different tasks during overlapping periods.
Threads are particularly useful when tasks spend significant time waiting for external resources.
Concurrency & Parallelism
These two terms are related but they are not identical.
Concurrency
Multiple tasks can make progress during overlapping periods.
Parallelism
Multiple tasks are actually executing at the same time, typically using multiple CPU cores.
Python threading can be very useful for concurrency, especially for I/O-bound work.
The GIL
In standard CPython builds, the Global Interpreter Lock (GIL) historically prevents multiple threads from executing Python bytecode simultaneously in the same interpreter. Therefore, threads are generally not the preferred solution for CPU-bound parallel computation.
For CPU-heavy workloads, multiprocessing or other approaches may be more appropriate.
The threading Module
Python provides the threading module in its
standard library.
import threading
It provides classes and functions for creating and coordinating threads.
ThreadLockRLockSemaphoreEventTimer
Your First Thread
The simplest way to create a thread is to pass a function
to threading.Thread.
import threading
def worker():
print("Worker thread is running")
thread = threading.Thread(
target=worker
)
thread.start()
The target specifies the function that the
thread should execute.
The thread does not begin execution until
start() is called.
start() & join()
Two important thread methods are
start() and join().
start()
Starts the thread's execution.
join()
Waits for the thread to finish.
import threading
def worker():
print("Worker started")
for number in range(3):
print(number)
thread = threading.Thread(
target=worker
)
thread.start()
thread.join()
print("Main thread finished")
Use join() when the main program needs to
wait for a worker thread before continuing.
Creating Multiple Threads
You can create several threads and start them before waiting for them to finish.
import threading
import time
def worker(name):
print(name, "started")
time.sleep(2)
print(name, "finished")
threads = []
for name in ["Thread A", "Thread B", "Thread C"]:
thread = threading.Thread(
target=worker,
args=(name,)
)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print("All threads finished")
The three threads can overlap their waiting periods.
Passing Arguments to Threads
Use args to pass positional arguments to the
target function.
import threading
def greet(name):
print("Hello,", name)
thread = threading.Thread(
target=greet,
args=("Ali",)
)
thread.start()
thread.join()
Notice the comma in ("Ali",). This creates a
one-element tuple.
Keyword Arguments
thread = threading.Thread(
target=greet,
kwargs={"name": "Sara"}
)
Threading with a Class
For more complex behavior, you can create a class that
inherits from threading.Thread.
import threading
class Worker(threading.Thread):
def run(self):
print("Worker is running")
thread = Worker()
thread.start()
thread.join()
The run() method contains the work performed
by the thread.
Using Thread(target=...) is often simpler
for straightforward tasks. Subclassing can be useful
when you want to encapsulate more thread-specific
behavior.
Daemon Threads
A daemon thread is intended to run in the background and does not normally keep the Python process alive after all non-daemon threads have finished.
import threading
import time
def background_task():
while True:
print("Background task running")
time.sleep(1)
thread = threading.Thread(
target=background_task,
daemon=True
)
thread.start()
print("Main program continues")
Be careful
Daemon threads can be stopped when the interpreter exits. Do not use them when important work must be completed or resources must be carefully cleaned up.
Race Conditions
A race condition can occur when multiple threads access shared mutable data and the final result depends on the timing of their operations.
Consider a shared counter:
counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1
If several threads modify shared state, you should not assume that the operations are automatically safe.
Shared state requires care
Thread safety is about ensuring that shared resources are accessed and modified in a controlled manner.
Thread Locks
A Lock can protect a critical section so that
only one thread at a time enters it.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
with lock:
counter += 1
The with lock: block acquires the lock before
entering the critical section and releases it afterward.
Critical section
A critical section is a part of code where shared data is accessed or modified and therefore requires synchronization.
RLock
threading.RLock is a reentrant lock.
It allows the same thread to acquire the lock multiple times without deadlocking itself, provided it releases the lock the corresponding number of times.
import threading
lock = threading.RLock()
def outer():
with lock:
inner()
def inner():
with lock:
print("Nested lock acquisition")
outer()
Use an RLock when a design genuinely requires the same thread to re-enter protected code.
Semaphores
A semaphore limits how many threads can access a resource at the same time.
import threading
import time
semaphore = threading.Semaphore(2)
def worker(name):
with semaphore:
print(name, "entered")
time.sleep(2)
print(name, "left")
A semaphore initialized with 2 allows up to
two threads to hold the semaphore simultaneously.
This can be useful for limiting access to a resource such as a connection pool or external service.
Events
A threading.Event provides a simple way for
threads to communicate that something has happened.
import threading
import time
ready = threading.Event()
def worker():
print("Worker waiting...")
ready.wait()
print("Worker received signal")
thread = threading.Thread(
target=worker
)
thread.start()
time.sleep(2)
print("Main thread signals worker")
ready.set()
thread.join()
The worker waits until another thread calls
set().
Thread-Safe Queues
The queue module provides thread-safe
structures that are particularly useful for producer and
consumer patterns.
import queue
tasks = queue.Queue()
tasks.put("Download file")
tasks.put("Process order")
tasks.put("Send email")
print(tasks.get())
print(tasks.get())
A producer can add work to a queue while one or more consumers retrieve and process that work.
Why queues matter
Queues reduce the need for multiple threads to directly manipulate the same shared data structure.
ThreadPoolExecutor
For many applications, manually creating and managing individual threads is unnecessary.
The concurrent.futures module provides
ThreadPoolExecutor for managing a pool of
worker threads.
from concurrent.futures import ThreadPoolExecutor
def process_order(order):
return f"Processed {order}"
orders = [
"Order 101",
"Order 102",
"Order 103",
"Order 104"
]
with ThreadPoolExecutor(
max_workers=4
) as executor:
results = executor.map(
process_order,
orders
)
for result in results:
print(result)
The executor manages the worker threads for you.
Professional approach
Thread pools are often easier to maintain than creating a new thread manually for every small task.
Exceptions in Threads
Exceptions raised inside a worker thread require careful
handling. With ThreadPoolExecutor, exceptions
can be retrieved from Future objects.
from concurrent.futures import ThreadPoolExecutor
def divide(number):
return 100 / number
numbers = [10, 5, 0, 20]
with ThreadPoolExecutor(
max_workers=4
) as executor:
futures = [
executor.submit(divide, number)
for number in numbers
]
for future in futures:
try:
print(future.result())
except ZeroDivisionError:
print("Cannot divide by zero")
Calling future.result() retrieves the result
or re-raises an exception that occurred in the worker.
Threading vs Async Python
You have just learned threading after studying asynchronous programming. It is important to understand when the two approaches differ.
Threading
- Uses operating-system-managed threads.
- Can work well with blocking I/O libraries.
- Can make existing synchronous code concurrent.
- Requires care around shared mutable state.
- Threads consume more resources than lightweight async tasks.
Asyncio
- Uses an event-loop-based concurrency model.
- Works especially well with async-compatible I/O.
- Uses coroutines and tasks.
- Can handle many waiting operations efficiently.
- Requires async-compatible libraries for best results.
Practical rule
If you already have blocking synchronous I/O code, threads can be a practical way to introduce concurrency. If you are building an async application from the beginning and your libraries support async operations, asyncio may be a better fit.
Threading for I/O-Bound Work
Threads can be particularly useful when tasks spend a lot of time waiting for external operations.
Examples
- Downloading multiple files
- Calling external APIs
- Reading data from multiple sources
- Working with blocking network libraries
- Processing independent I/O requests
Example
from concurrent.futures import ThreadPoolExecutor
import time
def download_file(filename):
print("Downloading", filename)
time.sleep(2)
return filename + " downloaded"
files = [
"file1.csv",
"file2.csv",
"file3.csv"
]
with ThreadPoolExecutor(
max_workers=3
) as executor:
results = executor.map(
download_file,
files
)
for result in results:
print(result)
This example simulates three independent downloads.
Real-World Applications
Threading can be useful in many practical applications.
Ecommerce
Fetch product information, inventory data, or shipping information from multiple blocking services.
Data Collection
Process multiple independent downloads or network sources concurrently.
Background Services
Handle independent background tasks such as sending notifications or processing queues.
Database Work
Coordinate independent blocking database operations when the database driver and application architecture support this approach.
Common Threading Mistakes
Mistake 1 — Forgetting start()
Creating a Thread object does not start its execution.
You need to call start().
Mistake 2 — Calling the function directly
threading.Thread(
target=worker()
)
This calls the function immediately instead of passing the function to the thread.
Correct:
threading.Thread(
target=worker
)
Mistake 3 — Ignoring shared state
Multiple threads accessing shared mutable data can create difficult-to-debug synchronization problems.
Mistake 4 — Holding locks too long
Large critical sections can reduce concurrency and increase the chance of deadlocks.
Mistake 5 — Using threads for heavy CPU work
Standard CPython threading is generally not the best solution for CPU-bound parallel computation.
Mistake 6 — Creating too many threads
More threads do not automatically mean better performance. Excessive threads increase scheduling and memory overhead.
Practice Exercises
Exercise 1 — First Thread
Create a worker function and execute it using a
threading.Thread.
Exercise 2 — Multiple Threads
Create five threads that each print their own name.
Exercise 3 — Arguments
Pass a student's name and marks to a worker thread.
Exercise 4 — Thread Pool
Use ThreadPoolExecutor to process ten
independent tasks.
Exercise 5 — Lock
Create multiple threads that update a shared counter safely using a lock.
Exercise 6 — Queue
Build a simple producer-consumer program using
queue.Queue.
Exercise 7 — Compare Approaches
Create the same simulated I/O task using normal execution, threading, and asyncio. Compare the conceptual differences.
Mini Challenge — Ecommerce Order Processor
Build a threaded ecommerce order-processing simulation.
Your Tasks
- Create a list of customer orders.
-
Write a function called
process_order(). -
Simulate processing time using
time.sleep(). -
Use
ThreadPoolExecutor. - Process multiple orders concurrently.
- Return a result for each order.
- Calculate the total number of successfully processed orders.
- Add exception handling for a failed order.
Starter Code
from concurrent.futures import ThreadPoolExecutor
import time
orders = [
{"id": 101, "customer": "Ali"},
{"id": 102, "customer": "Sara"},
{"id": 103, "customer": "Ahmed"},
{"id": 104, "customer": "Hina"},
{"id": 105, "customer": "Usman"}
]
def process_order(order):
print(
"Processing order",
order["id"]
)
time.sleep(2)
return {
"id": order["id"],
"status": "Completed"
}
with ThreadPoolExecutor(
max_workers=3
) as executor:
results = executor.map(
process_order,
orders
)
for result in results:
print(result)
Challenge goal
Think about why a thread pool with three workers can process five independent waiting tasks without creating five manually managed threads.
Knowledge Check
Test your understanding of Python threading.
1. What is a thread?
2. Which module provides Python's basic threading tools?
3. Which method starts a thread?
4. What does join() do?
5. What problem can a Lock help prevent?
6. What does ThreadPoolExecutor provide?
7. Which workload is commonly suitable for threading?
8. What does a semaphore help control?
9. What is queue.Queue useful for?
10. How is threading different from asyncio?
Lesson Summary
Python threading provides a way to perform multiple operations concurrently using threads. It is especially useful for applications that spend significant time waiting for I/O operations.
Key concepts to remember
- A thread is a unit of execution inside a process.
- Threading can provide useful concurrency for I/O-bound workloads.
-
threading.Threadcreates a thread. -
start()starts a thread. -
join()waits for a thread to finish. -
Arguments can be passed using
argsandkwargs. - Daemon threads are intended for background work that should not keep the process alive.
- Shared mutable state can introduce race conditions.
- Locks can protect critical sections.
- RLock supports reentrant locking.
- Semaphores limit concurrent access to resources.
- Events allow threads to signal one another.
- Queue provides thread-safe producer-consumer communication.
- ThreadPoolExecutor simplifies thread management.
- Standard CPython threading is generally not the preferred approach for CPU-bound parallel work.
- Threading and asyncio solve concurrency problems using different models.
What's next?
You have now learned asynchronous programming and threading. In the final lesson of the PyLearn course, we will study Multiprocessing and learn how Python can use separate processes for CPU-bound workloads and true parallel execution.
Ready for the final lesson?
Mark this lesson complete when you can create and manage threads, use thread pools, protect shared data with locks, and explain when threading is more appropriate than asyncio or multiprocessing.