Python Threading and Multiprocessing: Threads, Locks, Pools

By Engr. Henry Akpan

Doing Several Things at Once

Downloading fifty files one after another wastes time waiting for the network. Processing ten thousand images one at a time leaves most of your computer's processor cores idle. Python offers two standard ways to run work concurrently: threads, which share one process and suit waiting-heavy (I/O-bound) tasks, and processes, which run on separate CPU cores and suit calculation-heavy (CPU-bound) tasks. This tutorial covers Python threading and multiprocessing: starting threads, thread pools with concurrent.futures, race conditions and locks, the Global Interpreter Lock (GIL), process pools for parallel computation, and how to choose between threads, processes and asyncio. It follows on from the tutorial on asyncio.

Processes, Threads and Cores

  • A process is a running program with its own memory. Two processes do not share variables.
  • A thread is a separate flow of execution inside a process. All the threads of a process share the same memory, so they can read and change the same variables.
  • A modern CPU has several cores, each of which can run code at the same moment as the others.

Starting Threads

The threading module runs a function in a new thread. Call start() to begin, and join() to wait until it finishes.

import threading
import time

def download(name, seconds):
    time.sleep(seconds)            # stands in for waiting on the network
    results.append(f"{name} done")

results = []
start = time.perf_counter()
threads = [threading.Thread(target=download, args=(f"file{i}", 0.5)) for i in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()                       # wait for every thread to finish

print(sorted(results))
elapsed = time.perf_counter() - start
print("finished in under 1 second:", elapsed < 1.0)

Output:

['file0 done', 'file1 done', 'file2 done', 'file3 done']
finished in under 1 second: True

Four half-second waits took only a little over half a second in total, not two seconds, because the threads waited at the same time. While one thread sleeps or waits for the network, the others can run.

Thread Pools with concurrent.futures

Creating threads by hand is fiddly. A thread pool keeps a fixed number of worker threads and hands them tasks. ThreadPoolExecutor.map works like the built-in map, but runs the calls concurrently and returns the results in order.

from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch_price(item):
    delays = {"rice": 0.3, "beans": 0.1, "garri": 0.2}
    time.sleep(delays[item])
    prices = {"rice": 75000, "beans": 52000, "garri": 30000}
    return item, prices[item]

items = ["rice", "beans", "garri"]
with ThreadPoolExecutor(max_workers=3) as pool:
    for item, price in pool.map(fetch_price, items):
        print(item, price)

Output:

rice 75000
beans 52000
garri 30000

To handle each result as soon as it is ready, submit the tasks and use as_completed:

with ThreadPoolExecutor(max_workers=3) as pool:
    futures = [pool.submit(fetch_price, item) for item in items]
    for future in as_completed(futures):
        print("finished:", future.result()[0])

Output:

finished: beans
finished: garri
finished: rice

submit returns a Future, an object representing a result that will be available later. future.result() waits for it, and it re-raises any exception that the task raised. The with block waits for all the tasks to finish and then shuts the pool down.

Race Conditions and Locks

Because threads share memory, two threads updating the same variable can interfere. If both read the old value before either writes the new one, one update is lost. This is a race condition. The example below exaggerates the gap between reading and writing, so that the problem always shows up:

balance = 0

def unsafe_deposit():
    global balance
    for _ in range(10):
        current = balance          # read
        time.sleep(0.001)          # another thread may run here
        balance = current + 1      # write: may overwrite someone else's update

threads = [threading.Thread(target=unsafe_deposit) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("expected 100, got fewer:", balance < 100)

Output:

expected 100, got fewer: True

A lock fixes this. Only one thread at a time can hold it, so the read and the write happen together, with no other thread in between. Using the lock in a with statement guarantees that it is released, even if an error occurs.

balance = 0
lock = threading.Lock()

def safe_deposit():
    global balance
    for _ in range(10):
        with lock:                 # only one thread inside at a time
            current = balance
            time.sleep(0.001)
            balance = current + 1

threads = [threading.Thread(target=safe_deposit) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(balance)

Output:

100

Race conditions are dangerous because they may appear only occasionally, under heavy load. Wherever threads share data that changes, protect it with a lock, or avoid sharing by passing results back through return values or a queue.Queue, which is already thread-safe.

The Global Interpreter Lock (GIL)

In the standard Python interpreter (CPython), a Global Interpreter Lock allows only one thread to execute Python code at any instant. Threads still help when they spend their time waiting (for networks, disks or databases), because a waiting thread releases the GIL. But for CPU-bound work, such as long calculations in pure Python, threads take turns on one core and give little or no speed-up.

For CPU-bound work, use processes. Each process has its own interpreter and its own GIL, so several processes really can run at the same time on different cores. (Python 3.13 introduced an experimental "free-threaded" build without the GIL, but the standard build still has it.)

Process Pools for Parallel Computation

On Windows and macOS, a new process starts by importing your program afresh. So the function given to a process pool must be importable from a module, and the code that creates the pool must be inside an if __name__ == "__main__": guard. Here, the work function lives in its own file, workers.py:

# workers.py
def count_primes(bounds):
    """Count the primes in the range [start, end) by trial division (deliberately CPU-heavy)."""
    start, end = bounds
    count = 0
    for n in range(max(start, 2), end):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count

The main program splits the range into chunks and counts each chunk in a separate process:

from concurrent.futures import ProcessPoolExecutor
import os
from workers import count_primes

def parallel_prime_count(limit, chunks=4):
    step = limit // chunks
    ranges = [(i * step, limit if i == chunks - 1 else (i + 1) * step) for i in range(chunks)]
    with ProcessPoolExecutor(max_workers=chunks) as pool:
        return sum(pool.map(count_primes, ranges))

if __name__ == "__main__":
    serial = count_primes((0, 200_000))
    parallel = parallel_prime_count(200_000)
    print(serial, parallel, serial == parallel)
    print("CPU cores available:", os.cpu_count() >= 1)

Output:

17984 17984 True
CPU cores available: True

Both methods find the 17,984 primes below 200,000. On a computer with four or more cores, the parallel version typically runs two to four times faster. The exact speed-up depends on the machine, so it is not printed here.

Processes are heavier than threads: each one takes time to start, and the data passed to it (the arguments and results) must be copied between processes using pickling. Use them for chunks of work that are large enough to be worth the overhead.

Threads, Processes or asyncio?

  • asyncio: for very many concurrent network operations (thousands of connections), when async libraries are available. It uses one thread and very little memory per task.
  • Threads (ThreadPoolExecutor): for a moderate number of I/O-bound tasks using ordinary blocking libraries, such as requests, file access and database drivers.
  • Processes (ProcessPoolExecutor): for CPU-bound work in pure Python, such as simulations, image processing and number crunching.
  • Neither: if the work is already in NumPy or another compiled library, vectorising it is often the biggest speed-up of all, and such libraries frequently use multiple cores internally.

Because ThreadPoolExecutor and ProcessPoolExecutor share the same interface, you can switch between them by changing a single word.

Common Mistakes with Python Threading and Multiprocessing

  • Using threads to speed up pure-Python calculations. The GIL prevents a real speed-up. Use processes.
  • Sharing changing data between threads without a lock, which causes race conditions.
  • Forgetting the if __name__ == "__main__": guard with multiprocessing on Windows, which makes each new process start the pool again.
  • Passing un-picklable objects, such as lambdas or open files, to a process pool.
  • Calling start() without join(), so that the program uses results before the threads have finished.

Python Threading and Multiprocessing: Practice Exercises

  1. Use a ThreadPoolExecutor to "download" five pages, each taking 0.2 s, and show that the total time is well under 1 s.
  2. Write a thread-safe Counter class with an increment() method protected by a lock.
  3. Use future.result() to catch an exception raised inside a thread-pool task.

Sample solutions:

def fake_download(page):
    time.sleep(0.2)
    return len(page)

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
    sizes = list(pool.map(fake_download, ["home", "news", "sports", "jobs", "about"]))
print(sizes, time.perf_counter() - start < 0.6)

class Counter:
    def __init__(self):
        self.value = 0
        self._lock = threading.Lock()
    def increment(self):
        with self._lock:
            self.value += 1

c = Counter()
threads = [threading.Thread(target=lambda: [c.increment() for _ in range(1000)]) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(c.value)

def risky(n):
    if n == 3:
        raise ValueError("bad input 3")
    return n * n

with ThreadPoolExecutor() as pool:
    futures = {n: pool.submit(risky, n) for n in range(1, 5)}
    for n, f in futures.items():
        try:
            print(n, f.result())
        except ValueError as e:
            print(n, "failed:", e)

Output:

[4, 4, 6, 4, 5] True
8000
1 1
2 4
3 failed: bad input 3
4 16

Conclusion

Threads share one process's memory and are ideal for I/O-bound tasks: ThreadPoolExecutor runs blocking calls concurrently, with map for ordered results and as_completed for results as they finish. Shared data that changes must be protected with a Lock to avoid race conditions. Because of the GIL, threads do not speed up pure-Python calculations. For CPU-bound work, ProcessPoolExecutor runs code in parallel on several cores, with importable functions and a __main__ guard. Choose asyncio for very many network tasks, threads for blocking I/O, and processes for heavy computation.

Related Tutorials