← Back to Chapters

Python Concurrency with Threads

? Python Concurrency with Threads

⚡ Quick Overview

Threads allow a Python program to perform multiple tasks seemingly at the same time within a single process. They are lightweight units of execution that share the same memory space. In CPython, the Global Interpreter Lock (GIL) means only one thread executes Python bytecode at a time, but threads are still very useful for I/O-bound workloads such as network calls, disk access, or waiting for user input.

In everyday Python, you will usually work with threads via the threading module or concurrent.futures.ThreadPoolExecutor.

? Key Concepts

  • Process vs Thread – A process has its own memory; threads live inside a process and share memory.
  • Concurrency vs Parallelism – Concurrency is dealing with many tasks at once; parallelism is actually doing many at the same instant.
  • Global Interpreter Lock (GIL) – Only one thread can execute Python bytecode at a time in CPython.
  • I/O-bound Tasks – Spend most of the time waiting (network, disk, APIs). Threads work great here.
  • CPU-bound Tasks – Heavy computation (math, loops). Threads usually do not speed these up in CPython.
  • Race Condition – Multiple threads updating shared data in a conflicting way.
  • Lock / Mutex – An object used to guard critical sections and prevent race conditions.
  • Thread Life Cycle – Create → Start → Run → (optionally) Join → Finish.

? Syntax and Theory

There are two common ways to create threads in Python:

  1. Pass a target function to threading.Thread.
  2. Subclass threading.Thread and override run().

Basic pattern using threading.Thread with a target function:

? View Basic Thread Syntax
import threading

def worker(name):
    print(f"Starting task for {name}")
    # Do some work here...
    print(f"Finished task for {name}")

# Create thread objects
t1 = threading.Thread(target=worker, args=("Thread-1",))
t2 = threading.Thread(target=worker, args=("Thread-2",))

# Start threads (they run concurrently)
t1.start()
t2.start()

# Wait for both threads to finish
t1.join()
t2.join()

print("All work done!")

Thread-safe update using a lock:

? View Lock Usage Example
import threading
import time

counter = 0
lock = threading.Lock()

def increment_many(times):
    global counter
    for _ in range(times):
        # Only one thread can enter this block at a time
        with lock:
            current = counter
            # Simulate some processing delay
            time.sleep(0.0001)
            counter = current + 1

threads = []
for _ in range(5):
    t = threading.Thread(target=increment_many, args=(100,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("Final counter value:", counter)  # Always 500 when protected by lock

? Code Examples

The following example simulates downloading multiple files using threads. It is I/O-bound (we use time.sleep to mimic network delay), so threads can overlap waiting time.

? Simulating I/O-bound Work with Threads
import threading
import time

def download_file(file_id, delay):
    print(f"[{file_id}] Starting download...")
    time.sleep(delay)  # Simulate network delay
    print(f"[{file_id}] Download complete!")

start = time.time()

threads = []
files = [("file-1", 2), ("file-2", 3), ("file-3", 1)]

for file_id, delay in files:
    t = threading.Thread(target=download_file, args=(file_id, delay))
    threads.append(t)
    t.start()

# Wait for all downloads to complete
for t in threads:
    t.join()

end = time.time()
print(f"All downloads finished in {end - start:.2f} seconds")

Using a thread pool with ThreadPoolExecutor is often cleaner:

? Using ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch_page(url):
    print(f"Fetching {url}...")
    time.sleep(1)  # Simulate network delay
    return f"Content of {url}"

urls = [
    "https://example.com",
    "https://example.org",
    "https://example.net",
]

start = time.time()

with ThreadPoolExecutor(max_workers=3) as executor:
    futures = [executor.submit(fetch_page, url) for url in urls]

    for future in as_completed(futures):
        print("Result:", future.result())

end = time.time()
print(f"Fetched all pages in {end - start:.2f} seconds")

? Live Output and Explanation

? What the program does conceptually

  • Each download or fetch function prints when it starts and when it ends.
  • All threads are started almost at the same time and then wait during time.sleep.
  • Because they are waiting on I/O (simulated), the GIL is released and another thread can run.
  • So while one thread is “sleeping”, another can start or finish its work.
  • The total time is close to the longest single delay, not the sum of all delays.

For example, if the delays are 2s, 3s, and 1s, the total time should be a bit over 3 seconds instead of 2+3+1 = 6 seconds, because the tasks overlap.

? Practical Use Cases

  • Making multiple HTTP API calls at once instead of one by one.
  • Handling multiple client connections in a simple socket server.
  • Background tasks such as logging, monitoring, or updating UI while main work continues.
  • File uploads/downloads, or any work dominated by waiting for I/O.

✅ Best Practices for Python Threads

  • Prefer threads for I/O-bound tasks; use processes for heavy CPU-bound work.
  • Always protect shared mutable data (like global counters, lists, dicts) with Lock or other synchronization primitives.
  • Use ThreadPoolExecutor for simple “map this function over many inputs” cases.
  • Give threads clear, small responsibilities; avoid very complex logic in a single thread.
  • Use join() to wait for threads to finish before your program exits.
  • Consider daemon=True for background threads that should not block program shutdown.
  • Log thread names in debug output to track behaviour when troubleshooting.

? Practice Tasks

  1. Modify the download_file example to simulate 10 files with different delays. Measure and print the total time taken. Compare it with a version that processes downloads sequentially.
  2. Implement a simple counter that is incremented by 3 threads 1000 times each. First do it without a lock and observe if the result is always correct. Then add a Lock and compare.
  3. Use ThreadPoolExecutor to fetch (or simulate fetching) data from a list of URLs. Print the results in the order they complete using as_completed.
  4. Create a small program where one thread listens for user input in a loop, and another thread prints a status message every 2 seconds. Let it run until the user types quit.