Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›Python›Multithreading with threading

Advanced Python

Multithreading with threading

Multithreading allows a program to perform multiple tasks concurrently within the same memory space by managing lightweight execution threads. It is essential for improving responsiveness in applications performing I/O-bound operations by allowing background processing while the main thread remains free. You should reach for threading when your performance bottlenecks are caused by waiting for network responses or disk access rather than heavy mathematical calculations.

The Fundamentals of Threading

At its core, the threading module provides a way to spawn multiple threads of execution within a single process. Unlike multiple processes that maintain entirely separate memory spaces, threads share the same address space. This shared memory architecture allows threads to easily access global variables and data structures, which is both a benefit and a source of potential complexity. When you instantiate a Thread object, you are telling the interpreter to execute a specific target function concurrently with the rest of your program. The operating system, or the Python interpreter's thread scheduler, decides when to switch between these threads. Because threads share memory, context switching between them is significantly less expensive than switching between heavy processes, making them an ideal choice for tasks that are waiting on external resources rather than performing intensive computations. Understanding this shared access is the first step toward mastering concurrent execution.

import threading
import time

def background_task(name):
    # Simulate a task that waits for I/O
    print(f"Task {name} starting")
    time.sleep(1)
    print(f"Task {name} finished")

# Create a thread instance targeting a function
thread = threading.Thread(target=background_task, args=("A",))
thread.start()  # Begin execution
thread.join()   # Wait for thread completion before proceeding

Synchronizing Data with Locks

Because threads operate within the same memory space, multiple threads might attempt to modify the same variable or object simultaneously. This concurrent modification can lead to race conditions, where the outcome of the program depends on the unpredictable timing of thread execution. To prevent data corruption, we utilize synchronization primitives, primarily the Lock. A lock acts as a gatekeeper: when a thread acquires a lock, any other thread attempting to acquire the same lock must wait until the first thread releases it. This ensures that only one thread executes a critical section of code at a time. It is vital to use locks sparingly, as improper implementation can lead to deadlocks, where two or more threads are waiting indefinitely for each other to release resources. By wrapping shared data modifications in context managers, you ensure that the lock is always released, even if an error occurs during the operation.

import threading

counter = 0
counter_lock = threading.Lock()

def increment():
    global counter
    # Use context manager to handle lock acquisition and release
    with counter_lock:
        local_copy = counter
        counter = local_copy + 1

threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Final count: {counter}")

Communicating via Thread-Safe Queues

Rather than manually managing locks and shared memory to pass data between threads, the Queue module provides a much cleaner, more robust alternative. A queue is inherently thread-safe, meaning that multiple threads can safely add items to or remove items from the queue without needing explicit locks. This effectively implements the producer-consumer pattern, where one set of threads generates data and another set consumes it. By decoupling the generation of data from the processing of data, you create a more modular and easier-to-debug architecture. The queue handles all internal synchronization, allowing you to focus on the business logic of your application. When a consumer thread attempts to pull from an empty queue, it blocks until an item is available, which provides a natural flow-control mechanism that prevents the consumer from spinning and wasting CPU cycles while waiting for data to arrive from producers.

import threading
import queue

def worker(q):
    while True:
        item = q.get() # Blocks until item is available
        if item is None: break
        print(f"Processed {item}")
        q.task_done()

q = queue.Queue()
thread = threading.Thread(target=worker, args=(q,))
thread.start()
q.put("Data")
q.put(None) # Sentinel to stop worker
thread.join()

Managing Lifecycle with Daemon Threads

In many scenarios, you may create background threads that perform long-running tasks, such as monitoring a network connection or refreshing a cache. If the main thread of your application exits, you might not want these background threads to prevent the program from shutting down. This is where daemon threads become useful. A daemon thread is a thread that is marked to run in the background and is automatically killed by the Python interpreter when all non-daemon threads have completed. If you do not mark a thread as a daemon, your program will wait indefinitely for that thread to finish before exiting. Using daemon threads is an excellent strategy for background services, but you must be careful: since they can be terminated abruptly at any time, you should never use them for tasks that involve writing sensitive data to disk or performing operations that must be completed to maintain data integrity across restarts.

import threading
import time

def monitor_service():
    while True:
        print("Monitoring system health...")
        time.sleep(0.5)

# Mark thread as daemon so it exits with the main program
thread = threading.Thread(target=monitor_service, daemon=True)
thread.start()
time.sleep(1.2)
print("Main program exiting")

Understanding Threading Limitations

While threading is highly effective for I/O-bound tasks, it is crucial to recognize its limitations regarding CPU-intensive work. Due to the Global Interpreter Lock (GIL), only one thread can execute Python bytecode at any given moment, even on multi-core processors. This means that if your threads are performing heavy calculations, they will not run in parallel and may even perform worse than a single-threaded approach due to the overhead of context switching. Threading is strictly for tasks that spend most of their time waiting, such as network requests, database queries, or user input processing. If you find your application is slow because of complex mathematical computations or image processing, you should consider using the multiprocessing module instead. Multiprocessing bypasses the GIL by creating separate memory spaces and individual Python interpreters for each process, allowing for true parallel execution on multi-core systems at the cost of higher memory usage.

import threading
import time

def cpu_heavy():
    # This will not scale across cores due to the GIL
    x = 0
    for i in range(10**7): x += i

start = time.time()
threads = [threading.Thread(target=cpu_heavy) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Time taken: {time.time() - start:.2f}s")

Key points

  • Threads share the same memory space, making data exchange convenient but susceptible to race conditions.
  • The Lock primitive is necessary to prevent concurrent access to shared resources and avoid data corruption.
  • Queue objects offer a thread-safe way to implement producer-consumer patterns without manual locking.
  • Daemon threads automatically terminate when the main program finishes, making them ideal for background tasks.
  • The threading module excels at handling I/O-bound tasks where the application often waits for external signals.
  • The Global Interpreter Lock prevents multiple threads from executing Python code in parallel on separate cores.
  • Context managers are the preferred way to acquire and release locks to prevent deadlocks and ensure cleanup.
  • Multiprocessing should be chosen over threading when the primary bottleneck is CPU-bound computation.

Common mistakes

  • Mistake: Expecting true parallelism for CPU-bound tasks. Why it's wrong: The Global Interpreter Lock (GIL) prevents multiple native threads from executing Python bytecodes at once. Fix: Use the 'multiprocessing' module for CPU-intensive work to bypass the GIL.
  • Mistake: Forgetting to use Locks when accessing shared data. Why it's wrong: Threads share memory, and race conditions can occur if multiple threads read/modify variables simultaneously. Fix: Use 'threading.Lock()' or 'threading.RLock()' to synchronize access.
  • Mistake: Overusing threads for simple I/O tasks. Why it's wrong: Creating a large number of threads can lead to high memory consumption and overhead from context switching. Fix: Use a 'ThreadPoolExecutor' to manage a finite pool of worker threads.
  • Mistake: Forgetting to join threads. Why it's wrong: If the main thread finishes, the program may exit while child threads are still running, leading to unpredictable behavior. Fix: Always call 'thread.join()' to ensure the main program waits for completion.
  • Mistake: Using global variables across threads without synchronization. Why it's wrong: Global state is implicitly shared; concurrent writes to a global variable will lead to data corruption. Fix: Encapsulate data in objects and pass them as arguments to thread targets.

Interview questions

What is the primary purpose of the threading module in Python, and when should you use it?

The threading module allows you to run multiple threads of execution concurrently within a single process. You should use it primarily for I/O-bound tasks, such as reading files, making network requests, or waiting for user input. Because these tasks spend most of their time waiting for external resources, threading allows Python to switch to another task during that wait time, effectively improving the overall responsiveness and throughput of your application.

How do you create and start a new thread in Python using the threading module?

To create a thread, you instantiate the 'threading.Thread' class, passing a target function as the 'target' argument. You can also pass arguments to that function using the 'args' tuple. Once the thread object is created, you must call its 'start()' method to begin execution. For example: 't = threading.Thread(target=my_func, args=(arg1,))' followed by 't.start()'. This schedules the function to run in a separate thread context managed by the Python interpreter.

What is the role of the join() method in Python threading?

The 'join()' method is used to synchronize the main thread with a background thread. When you call 'thread.join()', the calling thread will block and pause its execution until the target thread has completely finished its work. This is crucial if your main program needs the results produced by the thread before proceeding. Without 'join()', the main thread might exit while background threads are still running, potentially causing the program to terminate unexpectedly or leave tasks unfinished.

Explain the concept of a Race Condition and how the threading module helps to prevent it.

A race condition occurs when two or more threads attempt to read and modify shared data simultaneously, leading to unpredictable results. Because thread scheduling is controlled by the operating system, the outcome depends on the timing of context switches. To prevent this, the threading module provides 'threading.Lock'. By acquiring a lock with 'lock.acquire()' before accessing shared data and releasing it with 'lock.release()' afterward, you ensure that only one thread can modify the data at a time, keeping the state consistent.

Compare the threading module with the multiprocessing module. When would you choose one over the other?

The threading module uses shared memory, which is lightweight but limited by Python's Global Interpreter Lock (GIL), meaning it cannot execute bytecode in parallel on multiple CPU cores. Use threading for I/O-bound tasks to handle waiting periods. Conversely, the multiprocessing module creates separate memory spaces and individual Python instances for each process, bypassing the GIL. You should choose multiprocessing for CPU-bound tasks like heavy mathematical calculations, as it allows your code to run on multiple processor cores simultaneously, truly parallelizing the workload.

What is the Global Interpreter Lock (GIL), and how does it influence the design of multithreaded Python applications?

The Global Interpreter Lock is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. Even on a multi-core processor, the GIL ensures only one thread executes in the Python interpreter at a time. This design choice simplifies memory management and C-extension integration but means that threading is not effective for CPU-intensive parallel processing. Therefore, developers design multithreaded Python applications to offload I/O operations to threads while reserving CPU-intensive tasks for multiprocessing to achieve actual performance gains.

All Python interview questions →

Check yourself

1. If you have a Python script that calculates prime numbers across four threads on a quad-core processor, why might it not run faster than a single-threaded version?

  • A.The OS scheduler favors the main thread by default.
  • B.The Global Interpreter Lock allows only one thread to execute Python bytecode at a time.
  • C.Python threads are not true OS-level threads.
  • D.Prime calculation requires constant memory allocation that is blocked by the OS.
Show answer

B. The Global Interpreter Lock allows only one thread to execute Python bytecode at a time.
Option 1 is incorrect because the OS treats all threads fairly. Option 2 is correct because the GIL restricts execution to one thread at a time, preventing CPU-bound tasks from gaining speedup. Option 3 is false as Python threads are wrappers around native OS threads. Option 4 is false; the memory allocation isn't the primary bottleneck for this behavior.

2. What is the primary benefit of using 'threading' in a Python application that performs network requests?

  • A.It speeds up the processing of data returned by the server.
  • B.It bypasses the GIL to allow multi-core utilization.
  • C.It allows the program to initiate new requests while waiting for others to finish.
  • D.It automatically optimizes the memory usage of the network buffers.
Show answer

C. It allows the program to initiate new requests while waiting for others to finish.
Option 1 is wrong because processing returned data is CPU-bound. Option 2 is wrong because the GIL still exists. Option 3 is correct because network I/O releases the GIL, allowing threads to overlap their waiting times. Option 4 is incorrect as threading does not perform buffer optimization.

3. What happens if you omit the 'join()' method call on a standard thread in a Python script?

  • A.The thread will throw a RuntimeError immediately.
  • B.The thread will continue to run in the background after the main script completes.
  • C.The thread will be automatically terminated by the interpreter as soon as the main thread completes.
  • D.The Python script will wait for the thread anyway.
Show answer

C. The thread will be automatically terminated by the interpreter as soon as the main thread completes.
Option 1 is false; threads don't require joining to be created. Option 2 is misleading as the process exits, killing non-daemon threads. Option 3 is correct; non-daemon threads are killed when the main thread stops. Option 4 is false; the program will not wait if the thread is not joined.

4. When is it appropriate to use a 'threading.Lock'?

  • A.Whenever you create more than two threads.
  • B.When multiple threads need to write to a shared dictionary or list simultaneously.
  • C.To force threads to execute in a specific order.
  • D.To stop the GIL from interrupting a thread.
Show answer

B. When multiple threads need to write to a shared dictionary or list simultaneously.
Option 1 is wrong; threads are not required to be locked unless they share mutable data. Option 2 is correct as shared mutable structures are prone to race conditions. Option 3 is wrong because Locks provide mutual exclusion, not strict sequencing. Option 4 is wrong because you cannot interact with or stop the GIL from a standard thread.

5. Which of these best describes a 'Daemon' thread?

  • A.A thread that has higher priority than the main thread.
  • B.A thread that is killed automatically when the main program finishes.
  • C.A thread that is protected from being interrupted by the OS.
  • D.A thread that is specifically designed for CPU-intensive calculations.
Show answer

B. A thread that is killed automatically when the main program finishes.
Option 1 is false; daemon status is about lifecycle, not priority. Option 2 is correct; daemon threads do not block the program from exiting. Option 3 is false; threads can always be interrupted. Option 4 is false; daemon status has nothing to do with computational complexity.

Take the full Python quiz →

← Previousfunctools — partial, reduce, lru_cacheNext →Multiprocessing Basics

Python

78 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app