Day 20: Multithreading & Multiprocessing — Running in Parallel
🐍 Day 20: Multithreading & Multiprocessing — Running in Parallel
1. Learning Objectives
By the end of Day 20, you will be able to:
- Distinguish between concurrency and parallelism
- Understand the Global Interpreter Lock (GIL) and how it affects threading
- Use the
threadingmodule to run I/O‑bound tasks concurrently - Use the
multiprocessingmodule to run CPU‑bound tasks in parallel - Know when to choose threading vs. multiprocessing vs. asyncio
- Apply thread‑safe communication using
queue.Queue - Avoid common pitfalls like race conditions and deadlocks
2. Concept Explanation
2.1 Concurrency vs. Parallelism — The Dinner Party Analogy
- Concurrency: One person cooking multiple dishes, rapidly switching between tasks. (One chef, multiple meals in progress.)
- Parallelism: Several cooks working on different dishes simultaneously. (Multiple chefs, multiple meals finished faster.)
Python's threading provides concurrency (interleaved execution), while multiprocessing provides true parallelism (multiple cores).
2.2 The Global Interpreter Lock (GIL) — The Bottleneck
Python's reference implementation (CPython) has a GIL: a mutex that allows only one thread to execute Python bytecode at a time, even on multi‑core CPUs.
- I/O‑bound tasks (network, file I/O, user input) release the GIL, so threading helps.
- CPU‑bound tasks (calculations, image processing) hold the GIL; threading doesn't improve performance. Use multiprocessing here.
2.3 The threading Module — Running Multiple Things "At Once"
import threading
import time
def worker(name, delay):
for i in range(3):
print(f"{name}: iteration {i}")
time.sleep(delay)
# Create threads
t1 = threading.Thread(target=worker, args=("Thread-A", 0.5))
t2 = threading.Thread(target=worker, args=("Thread-B", 0.2))
# Start threads
t1.start()
t2.start()
# Wait for both to finish
t1.join()
t2.join()
print("All threads finished.")
Key points:
- Threads run concurrently; output may interleave.
join()blocks until the thread completes.- Use
daemonthreads for background tasks (they exit when main exits).
2.4 The multiprocessing Module — Real Parallelism
Each process has its own Python interpreter and memory space, bypassing the GIL.
import multiprocessing
def square(number):
return number * number
if __name__ == "__main__":
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(square, range(10))
print(results)
Important: The if __name__ == "__main__" guard is mandatory on Windows to avoid infinite process spawning.
2.5 Communication Between Threads / Processes
Threads share memory, so use locks or a thread‑safe queue:
from queue import Queue
q = Queue()
q.put("data")
item = q.get()
Processes don't share memory; use multiprocessing.Queue or Pipe:
from multiprocessing import Queue
q = Queue()
q.put([1, 2, 3])
print(q.get())
2.6 When to Use What
| Scenario | Tool |
|---|---|
| Network requests, file I/O, GUI responsiveness | threading |
| CPU‑heavy calculations, data crunching | multiprocessing |
| High‑level async I/O (many connections) | asyncio (Day 23 preview) |
| Simple parallel loops | concurrent.futures (ThreadPoolExecutor / ProcessPoolExecutor) |
2.7 Common Pitfalls
- Race conditions: Two threads updating a shared variable without synchronisation → corrupted data. Use
threading.Lock. - Deadlock: Two threads each waiting for a lock the other holds.
- Overhead: Spawning too many threads or processes can hurt performance.
- GIL forgetfulness: Using threading for CPU‑bound tasks gives no speedup.
3. Code Examples
Example 1: Downloading URLs with Threads (I/O‑bound)
import threading
import requests
import time
def download(url, thread_name):
print(f"{thread_name} starting download: {url}")
response = requests.get(url)
print(f"{thread_name} finished, status {response.status_code}")
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/1",
]
start = time.time()
threads = []
for i, url in enumerate(urls):
t = threading.Thread(target=download, args=(url, f"T{i}"))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Total time: {time.time() - start:.1f}s")
Example 2: CPU‑Heavy with Multiprocessing
import multiprocessing
import math
def compute_pi(dummy):
"""A dummy function that runs a heavy computation."""
return sum(1.0/(k*k) for k in range(1, 3000000))
if __name__ == "__main__":
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(compute_pi, range(4))
print(f"Results: {results}")
Example 3: concurrent.futures — Simpler Interface
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch(url):
import requests
return requests.get(url).status_code
urls = ["https://httpbin.org/get"] * 4
with ThreadPoolExecutor(max_workers=4) as executor:
future_to_url = {executor.submit(fetch, url): url for url in urls}
for future in as_completed(future_to_url):
url = future_to_url[future]
print(f"{url} -> {future.result()}")
4. Hands-On Exercises
Exercise 1: Threaded Counter
Create a thread‑safe counter using threading.Lock. Two threads each increment the counter 1000 times. Print the final count.
Exercise 2: Concurrent File Reader
Write a program that reads two large text files simultaneously using threads and counts lines. Print the total line count.
Exercise 3: Prime Checker with Multiprocessing
Given a list of numbers, use a multiprocessing.Pool to determine which ones are prime. Print the primes.
Exercise 4: Producer‑Consumer with Queue
Implement a producer thread that generates numbers and a consumer thread that squares them, using a queue.Queue. The producer sends 10 numbers, the consumer prints the squares.
Exercise 5: Compare Sequential vs. Threaded vs. Multiprocessing
Measure the time to calculate the sum of squares of numbers 1 to 10,000,000 three ways: sequentially, with 2 threads, and with 2 processes. (Note: threading won't help here; observe the difference.)
5. Applied Challenge Task 🏗️
Web Scraper with Thread Pool
Build a concurrent web scraper that fetches titles from a list of URLs and saves them to a file.
Requirements:
- Accept a list of at least 10 URLs (use
httpbin.orgor a test site). - Use
concurrent.futures.ThreadPoolExecutorto fetch all URLs concurrently (max 5 workers). - Extract the
<title>from each response (use regex or just return the full text if no title). - Handle timeouts and connection errors gracefully, skipping failed URLs.
- Write the results (URL + title) to a JSON file
titles.json. - Measure the total time and compare with sequential execution.
Stretch goals:
- Add retry logic (exponential backoff) for failed URLs.
- Implement a progress bar using
tqdm. - Fetch meta descriptions as well.
6. Brief Review Summary
| Concept | Key Points |
|---|---|
| Concurrency vs. Parallelism | Concurrency = interleaving; Parallelism = simultaneous |
| GIL | Limits Python bytecode execution to one thread at a time |
| Threading | Good for I/O‑bound tasks; light‑weight, shared memory |
| Multiprocessing | Good for CPU‑bound tasks; separate memory, true parallelism |
queue.Queue | Thread‑safe communication |
concurrent.futures | High‑level interface for thread/process pools |
| Pitfalls | Race conditions, deadlocks, GIL ignorance |
7. Preview of Next Topic — Day 21
Tomorrow we'll add persistence to our programs:
- Introduction to Databases — why they matter
- SQLite — a light, built‑in database engine
- Creating tables, inserting, querying, updating, and deleting data
- Using
sqlite3from Python - Best practices for database connections
🎯 Your Action Items for Day 20:
- ✅ Complete all 5 exercises
- ✅ Build the Web Scraper with Thread Pool challenge
- ✅ Observe the GIL effect: compare threaded vs sequential for a CPU‑bound task
- ✅ Experiment with
ThreadPoolExecutorvsProcessPoolExecutor
Comments
Post a Comment
Leave us your comments here...