Redirect Chapter 10: Concurrency & Memory Management | AI Fundamentals
← Back to Tutorials Chapter 10

Concurrency & Memory Management

Modern computers have many cores, yet naive programs use one at a time. This chapter explains how to make Python work in parallel — with threads for I/O-bound work and processes for CPU-bound work — and how Python manages the memory those programs use. You will build a multithreaded web scraper and a multiprocessing data pipeline along the way.

Processes vs Threads

A process is a running program with its own memory space, its own file descriptors, and its own interpreter. A thread is a lightweight execution path inside a process; all threads of a process share the same memory.

The Global Interpreter Lock (GIL): CPython ensures only one thread runs Python bytecode at a time. Pure-math threads therefore cannot speed up CPU work — the GIL serializes them. Processes bypass the GIL because each has its own interpreter. For waiting on I/O, threads release the GIL and genuinely overlap.

Multithreading with the threading Module

Creating and Starting Threads

import threading
import time

def work(name, delay):
    time.sleep(delay)
    print(f"thread {name} finished")

t1 = threading.Thread(target=work, args=("A", 2))
t2 = threading.Thread(target=work, args=("B", 1))

t1.start()
t2.start()
t1.join()
t2.join()
print("both threads done")

Race Conditions and Locks

When two threads mutate the same value without coordination, updates can be lost. A lock lets only one thread enter the protected section at a time.

import threading

balance = 100
lock = threading.Lock()

def withdraw(amount):
    global balance
    with lock:                       # acquire before touching balance
        if balance >= amount:
            balance -= amount

threads = [threading.Thread(target=withdraw, args=(30,)) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print("final balance:", balance)
Why the lock matters: Without it, two threads can both read the current balance, both compute a new value, and both write — one update vanishes. Locks serialize the critical section so each read-modify-write completes atomically.

Multiprocessing with the multiprocessing Module

Process and Pool

import multiprocessing as mp

def square(x):
    return x * x

if __name__ == "__main__":
    with mp.Pool(processes=4) as pool:
        results = pool.map(square, range(10))
    print(results)

Process launches one named task; Pool distributes many tasks over a fixed set of workers. Because each process has its own memory, results come back through queues or shared objects, not plain global variables.

Sharing State Between Processes

import multiprocessing as mp

def add_one(shared_value, lock):
    with lock:
        shared_value.value += 1

if __name__ == "__main__":
    lock = mp.Lock()
    counter = mp.Value("i", 0)
    procs = [mp.Process(target=add_one, args=(counter, lock)) for _ in range(8)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()
    print("shared counter:", counter.value)

Thread and Process Pools via concurrent.futures

The concurrent.futures module offers a uniform interface: swap ThreadPoolExecutor for ProcessPoolExecutor and change almost nothing else.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def slow_add(a, b):
    return a + b

with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(slow_add, range(5), range(5)))
print(results)

with ProcessPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(slow_add, range(5), range(5)))
print(results)

Use Case: Multithreaded Web Scraping

Fetching many pages is dominated by network wait, so threads are the right tool. Here each thread downloads a page's text with urllib and records its length.

import threading
import urllib.request

urls = ["https://example.com"] * 10
results = {}
lock = threading.Lock()

def fetch(url, idx):
    with urllib.request.urlopen(url) as resp:
        body = resp.read()
    with lock:
        results[idx] = len(body)

threads = [threading.Thread(target=fetch, args=(urls[i], i)) for i in range(len(urls))]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(sorted(results.items()))
A scraper caution: Always respect a site's rules — read robots.txt, limit request rates, and add a delay between requests. Scraping that hammer a server is both rude and often against terms of service. A polite version of this example would sleep briefly between fetches.

Use Case: Multiprocessing for Number Crunching

Computing hashes of many files is CPU-bound, so processes give a real speedup. Each worker computes a checksum and returns it.

import hashlib
from concurrent.futures import ProcessPoolExecutor

def sha256_hex(text):
    return hashlib.sha256(text.encode()).hexdigest()

chunks = [f"sample-{i}" for i in range(20)]

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as pool:
        digests = list(pool.map(sha256_hex, chunks))
    print(digests[:3])

Memory Allocation, Deallocation, and Garbage Collection

Python manages memory automatically. When an object is created, space is allocated from a private heap; when no references point to it anymore, it becomes garbage.

Reference Counting and Generational GC

import gc
import sys

def make_cycle():
    a = {}
    b = {}
    a["other"] = b
    b["other"] = a
    return a, b

make_cycle()
collected = gc.collect()
print("objects collected:", collected)

Best Practices

Rule of thumb recap: Waiting on the network or disk? Use threads. Crunching numbers? Use processes. Never share mutable state between threads without a lock, and keep shared data between processes minimal — passing results through pool.map is almost always simpler than trying to share memory.
Practice exercise: Write a program that hashes 50 short strings with SHA-256, first serially, then with a ProcessPoolExecutor using 4 workers. Time both with time.perf_counter() and print the speedup. Next, simulate I/O: write a function that sleeps for 0.2 seconds and returns its argument squared, and run 20 such calls with a ThreadPoolExecutor, comparing wall time against the serial version. Then answer in a comment: why does the thread version win the sleeping test but not the hashing test?