Lesson 72 · Advanced Python

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.

50–60 minutes Intermediate Concurrency
0%
01

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.
02

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.

03

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.

04

The threading Module

Python provides the threading module in its standard library.

import threading

It provides classes and functions for creating and coordinating threads.

  • Thread
  • Lock
  • RLock
  • Semaphore
  • Event
  • Timer
05

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.

06

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.

07

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.

08

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"}
)
09

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.

10

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.

11

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.

12

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.

13

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.

14

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.

15

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().

16

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.

17

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.

18

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.

19

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.

20

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.

21

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.

22

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.

23

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.

24

Mini Challenge — Ecommerce Order Processor

Build a threaded ecommerce order-processing simulation.

Your Tasks

  1. Create a list of customer orders.
  2. Write a function called process_order().
  3. Simulate processing time using time.sleep().
  4. Use ThreadPoolExecutor.
  5. Process multiple orders concurrently.
  6. Return a result for each order.
  7. Calculate the total number of successfully processed orders.
  8. 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.

25

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?

26

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.Thread creates a thread.
  • start() starts a thread.
  • join() waits for a thread to finish.
  • Arguments can be passed using args and kwargs.
  • 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.