-
Section 1: Getting Started
-
Section 2: Core Syntax and Data Types
-
Section 3: Collections
-
39: Set Operations: Union, Intersection, Difference
-
Section 4: Control Flow
-
Section 5: Functions
-
Section 6: Turtle Graphics and Early Practice Projects
-
Section 7: Working with Files and I/O
-
Section 8: Regular Expressions
-
Section 9: Object-Oriented Python
-
Section 10: Error Handling
-
Section 11: Modules and Packages
-
Section 12: Iterators, Generators, and Functional Tools
-
Section 13: Decorators and Metaprogramming
-
Section 14: Concurrency and Parallelism
-
Section 15: Working with Dates, Times, and Numbers
-
Section 16: Standard Library Deep Dive I: Data Structures
-
Section 17: Standard Library Deep Dive II: System and Introspection
-
Section 18: Standard Library Deep Dive III: Security and Encoding
-
Section 19: Standard Library Deep Dive IV: Text and Data Utilities
-
Section 20: Networking and Web Basics
-
Section 21: Working with Databases
-
Section 22: Testing and Quality
-
Section 23: Advanced Typing
-
Section 24: Context Managers and Resource Handling
-
Section 25: Text, Unicode, and Binary Data
-
Section 26: More Functional and Iteration Tools
-
Section 27: Data Validation and Configuration
-
Section 28: Working with Images and Media
-
Section 29: Property-Based and Documentation Testing
-
Section 30: Packaging and Deployment
-
Section 31: Performance and Internals
-
Section 32: Design Patterns in Python
-
Section 33: GUI Programming
-
Section 34: Security Basics
-
Section 35: Data Structures and Algorithms
-
Section 36: Practical Projects
-
Section 37: Capstone Projects
-
Section 38: Interview and Algorithm Practice
-
Section 39: Writing Idiomatic Python
215: Choosing Between Threading, Multiprocessing, and Asyncio
I see this mistake happen constantly. A developer realizes their code is running slowly, remembers that they have a 12-core processor, and decides to "parallelize" the work using the threading module. They expect a linear speedup, but instead, they find the code is actually running slower than the original single-threaded version.
Here is a classic example. Let's say you're writing a script to calculate some heavy mathematical operations—in this case, checking if a large list of numbers are primes.
import threading
import time
def is_prime(n):
if n < 2: return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0: return False
return True
numbers = [1000000007, 1000000009, 1000000021, 1000000033] * 10
start = time.time()
threads = []
for n in numbers:
t = threading.Thread(target=is_prime, args=(n,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Time taken: {time.time() - start:.2f} seconds")
The GIL is Killing Your Performance
If you run this, you'll notice something frustrating: it doesn't actually run the calculations in parallel. On most Python implementations (CPython), we have the Global Interpreter Lock (GIL). The GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once.
In the code above, your threads are spending all their time fighting over the GIL. Because is_prime is CPU-bound (it's doing heavy math, not waiting for a website to load), the threads just take turns. The overhead of creating the threads and switching between them actually makes the program slower than if you'd just used a simple for loop.
Bypassing the GIL with Multiprocessing
To actually use those CPU cores, we need to stop sharing a single GIL. The solution is multiprocessing. Instead of creating threads within one process, this module spawns entirely new Python processes, each with its own Python interpreter and its own GIL.
import multiprocessing
import time
# ... (is_prime function stays the same) ...
if __name__ == "__main__":
numbers = [1000000007, 1000000009, 1000000021, 1000000033] * 10
start = time.time()
with multiprocessing.Pool() as pool:
pool.map(is_prime, numbers)
print(f"Time taken: {time.time() - start:.2f} seconds")
Now, the OS distributes these processes across your actual CPU cores. You'll see a massive drop in execution time. However, there's a trade-off: processes are "heavier" than threads. They take more memory and communicating between them is slower because data has to be serialized (pickled) to move from one process to another.
Choosing Between Threading, Multiprocessing, and Asyncio
So, how do you decide which one to use? I usually break it down by what the program is "waiting" for.
- Multiprocessing: Use this when you are CPU-bound. If your code is doing heavy math, image processing, or data crunching, you need separate processes to bypass the GIL.
- Threading: Use this when you are I/O-bound but the libraries you're using are synchronous. If you're downloading five files at once using
requests, threading is great. While one thread is waiting for a server to respond, the GIL is released, allowing another thread to start its request. - Asyncio: Use this when you have massive amounts of I/O (thousands of concurrent connections). Asyncio isn't about parallelism (running things at the same time); it's about concurrency (efficiently managing many tasks). It uses a single-threaded event loop. It's significantly more memory-efficient than threading, but it requires you to use
async/awaitcompatible libraries (likehttpxoraiohttpinstead ofrequests).
A quick rule of thumb I use: If you're fighting the CPU, go multiprocessing. If you're fighting the network or the hard drive, go asyncio. If you're stuck with a library that doesn't support async but you still need to do I/O, go threading.
📋 Practical Task
Exercise: Optimizing a Hybrid Log Processor
You have been given a script that processes a list of large log files. The current script is incredibly slow because it does two things for every file:
- Downloads the log file from a remote server (I/O-bound).
- Performs a complex Regex-based analysis to count errors (CPU-bound).
threading for both, which is causing a bottleneck during the analysis phase.
Your Task: Refactor the provided skeleton code to use the correct concurrency primitive for each task. Use threading (or asyncio) for the download_log function and multiprocessing for the analyze_log function.
import time
import multiprocessing
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# Simulated I/O bound task
def download_log(file_id):
print(f"Downloading file {file_id}...")
time.sleep(1) # Simulate network delay
return f"Log data for {file_id}"
# Simulated CPU bound task
def analyze_log(data):
print(f"Analyzing {data}...")
# Simulate heavy CPU work
count = 0
for i in range(10**7):
count += 1
return count
def main():
files = [1, 2, 3, 4, 5, 6, 7, 8]
# TODO:
# 1. Use a ThreadPoolExecutor to download all files concurrently.
# 2. Take the results from the downloads and use a ProcessPoolExecutor
# to analyze the data in parallel across CPU cores.
# 3. Print the total time taken.
pass
if __name__ == "__main__":
main()
There are no comments for now.