Python Series track

Week 04 · Advanced Track

Week 4 — Threading & Multiprocessing

Write concurrent programs with threading and multiprocessing. Understand the GIL, synchronize shared state with Lock and Queue, and use concurrent.futures for a clean high-level API.

Advanced Track2 min readFree lesson

Level: Advanced | Prerequisite: Week 3

Learning Goals

  • Distinguish between I/O-bound and CPU-bound tasks
  • Accelerate I/O tasks with ThreadPoolExecutor
  • Accelerate CPU tasks with ProcessPoolExecutor
  • Understand the meaning of the GIL
  • Synchronize using threading.Lock

1. GIL (Global Interpreter Lock)

CPython executes only one thread of Python bytecode at a time → CPU-bound tasks cannot be accelerated with threading. Effective for I/O operations (network, disk).

Type of Task Tool
I/O-bound (network, file) threading, asyncio
CPU-bound (computation, encoding) multiprocessing

2. ThreadPoolExecutor

from concurrent.futures import ThreadPoolExecutor
import requests

urls = ["https://example.com"] * 10

def fetch(url):
    return len(requests.get(url).text)

with ThreadPoolExecutor(max_workers=5) as ex:
    results = list(ex.map(fetch, urls))

About 5 times faster compared to sequential execution (other tasks run during network wait).

3. ProcessPoolExecutor

from concurrent.futures import ProcessPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":   #Windows is required
    with ProcessPoolExecutor() as ex:
        print(list(ex.map(heavy, [1_000_000] * 4)))

4. submit / as_completed

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor() as ex:
    futures = [ex.submit(fetch, url) for url in urls]
    for fut in as_completed(futures):
        print(fut.result())

5. threading.Lock

Prevents data corruption when multiple threads modify the same resource.

import threading

counter = 0
lock = threading.Lock()

def inc():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

ts = [threading.Thread(target=inc) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print(counter)   # 400000

Without a Lock, results are small or variable.

Key Examples (examples/)

File Content
01_thread_pool.py ThreadPoolExecutor (I/O)
02_process_pool.py ProcessPoolExecutor (CPU)
03_lock.py threading.Lock
04_compare.py Sequential vs Thread Time Comparison

⚠️ Common Mistakes

  1. Using thread for CPU tasks — May become slower due to GIL
  2. Missing __main__ guard (Windows + multiprocessing) — Infinite loop
  3. Missing Lock on shared resource — Non-deterministic results
  4. Not retrieving Future results — Exceptions are hidden

❓ FAQ

Q1. Compared to asyncio? A. asyncio is cooperative in a single thread. threading is scheduled by the OS. If I/O is light, use asyncio; if library compatibility is important, use threading.

Q2. What are the disadvantages of multiprocessing? A. High cost to start a process. Small tasks may actually be slower. Object serialization (pickle) is required.

📝 Exercises (exercises/)

  • exercise1.md — Simultaneous downloading of multiple URLs (time comparison)
  • exercise2.md — Accelerating CPU tasks with process pool
  • exercise3.md — Comparison before and after applying shared counter + Lock

Next Week

Week 5. Concurrency (2) - asyncio

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

examples/01_thread_pool.pyPython
import time
from concurrent.futures import ThreadPoolExecutor

def slow_io(n):
    time.sleep(0.5)   #Network/File Wait Simulation
    return n * 10

#Sequential
start = time.perf_counter()
results = [slow_io(i) for i in range(8)]
print(f"Sequential: {time.perf_counter() - start:.2f}s, {results}")

#Thread Pool
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as ex:
    results = list(ex.map(slow_io, range(8)))
print(f"Thread: {time.perf_counter() - start:.2f}s, {results}")
examples/02_process_pool.pyPython
import time
from concurrent.futures import ProcessPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    workload = [1_000_000] * 4

    start = time.perf_counter()
    serial = [heavy(n) for n in workload]
    print(f"Sequence: {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ProcessPoolExecutor() as ex:
        parallel = list(ex.map(heavy, workload))
    print(f"Process: {time.perf_counter() - start:.2f}s")

    print("Result is the same:", serial == parallel)
examples/03_lock.pyPython
import threading

counter_unsafe = 0
counter_safe = 0
lock = threading.Lock()

def inc_unsafe():
    global counter_unsafe
    for _ in range(100_000):
        counter_unsafe += 1

def inc_safe():
    global counter_safe
    for _ in range(100_000):
        with lock:
            counter_safe += 1

threads = []
for fn in [inc_unsafe] * 4:
    t = threading.Thread(target=fn); t.start(); threads.append(t)
for t in threads: t.join()

threads = []
for fn in [inc_safe] * 4:
    t = threading.Thread(target=fn); t.start(); threads.append(t)
for t in threads: t.join()

print(f"Without lock: {counter_unsafe} (expected 400000, actual may be smaller)")
print(f"With lock: {counter_safe}")
examples/04_compare.pyPython
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def io_task(n):
    time.sleep(0.2)
    return n

def cpu_task(n):
    return sum(i * i for i in range(n))

N = 8

print("=== I/O Bound ===")
for label, ex_class in [("Sequential", None), ("Thread", ThreadPoolExecutor), ("Process", ProcessPoolExecutor)]:
    start = time.perf_counter()
    if ex_class is None:
        [io_task(i) for i in range(N)]
    else:
        with ex_class(max_workers=N) as ex:
            list(ex.map(io_task, range(N)))
    print(f"{label}: {time.perf_counter() - start:.2f}s")

print("=== CPU Bound ===")
workload = [500_000] * N
for label, ex_class in [("Sequential", None), ("Thread", ThreadPoolExecutor), ("Process", ProcessPoolExecutor)]:
    start = time.perf_counter()
    if ex_class is None:
        [cpu_task(n) for n in workload]
    elif __name__ == "__main__" or ex_class is ThreadPoolExecutor:
        with ex_class() as ex:
            list(ex.map(cpu_task, workload))
    print(f"{label}: {time.perf_counter() - start:.2f}s")

Assignment

Goal

Receive a list of URLs and print the response size. Compare sequential vs thread timing.

Requirements

  • Use requests
  • 8~10 URLs (can use something like httpbin.org/delay/N)
  • Measure both methods and print the time

Challenge

  • Process starting with as_completed first

Assignment 2. CPU Task Process Pool Acceleration

Goal

Count the number of prime numbers between 1 and 1,000,000 (CPU bound).

Requirements

  • Divide tasks (e.g., 1250,000, 250,001500,000, ...)
  • Use ProcessPoolExecutor
  • Confirm time reduction compared to sequential

Assignment 3. Shared Counter + Lock

Goal

4 threads each increment a counter 10,000 times. Compare results before and after applying Lock.

Requirements

  • Print both scenarios
  • Without Lock: result is less than 40,000 or variable
  • With Lock: exactly 40,000

Observation

  • Include comparison messages in the output
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

solution1.pyPython
import time
from concurrent.futures import ThreadPoolExecutor

#Does not operate without requests. Simulation:
def fetch(url):
    time.sleep(0.5)   #Network simulation
    return f"{url}: 1234 bytes"

URLS = [f"https://example.com/{i}" for i in range(8)]

start = time.perf_counter()
serial = [fetch(u) for u in URLS]
print(f"Sequence: {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as ex:
    parallel = list(ex.map(fetch, URLS))
print(f"Thread: {time.perf_counter() - start:.2f}s")
solution2.pyPython
import time
from concurrent.futures import ProcessPoolExecutor

def is_prime(n):
    if n < 2: return False
    if n == 2: return True
    if n % 2 == 0: return False
    return all(n % i for i in range(3, int(n ** 0.5) + 1, 2))

def count_primes(start_end):
    start, end = start_end
    return sum(1 for n in range(start, end) if is_prime(n))

if __name__ == "__main__":
    LIMIT = 200_000

    start = time.perf_counter()
    total_serial = count_primes((2, LIMIT))
    print(f"Sequential: {time.perf_counter() - start:.2f}s, {total_serial} primes")

    chunks = [(i, i + LIMIT // 4) for i in range(2, LIMIT, LIMIT // 4)]
    start = time.perf_counter()
    with ProcessPoolExecutor() as ex:
        total_parallel = sum(ex.map(count_primes, chunks))
    print(f"Process: {time.perf_counter() - start:.2f}s, {total_parallel} primes")
solution3.pyPython
import threading

def run(use_lock):
    counter = 0
    lock = threading.Lock()

    def inc():
        nonlocal counter
        if use_lock:
            for _ in range(10_000):
                with lock:
                    counter += 1
        else:
            for _ in range(10_000):
                counter += 1

    threads = [threading.Thread(target=inc) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()
    return counter

print(f"Without Lock: {run(False)} (Expected 40000)")
print(f"With Lock: {run(True)}")
print("→ Race condition may occur if there is no Lock")

Learn this lesson with a mentor

Work through the Python Series track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now