Concurrency: threading, multiprocessing & the GIL
একসাথে একাধিক কাজ — thread, process ও GIL
1. Concurrency vs Parallelism
Concurrency is about structuring a program to deal with many things at once. Parallelism is about executing many things at the exact same instant on multiple CPU cores. A single-core machine can be concurrent (interleaving tasks) but never truly parallel.
2. The GIL — Global Interpreter Lock
CPython — the reference Python implementation — holds a single lock called the Global Interpreter Lock. Only one thread can execute Python bytecode at a time. This makes memory management simple and reference counting fast, but it means pure-Python CPU-bound work cannot use multiple cores with threads. I/O releases the GIL, so threading still helps for I/O-bound work (network, disk, sleep).
| Workload | Best tool | Why |
|---|---|---|
| Many network requests | threading / asyncio | Waits release the GIL. |
| Heavy math (pure Python) | multiprocessing | Each process has its own GIL. |
| Heavy math (NumPy) | NumPy / threads | NumPy releases the GIL in C code. |
| Thousands of sockets | asyncio | One thread, cooperative scheduling. |
3. Threading for I/O-Bound Work
The modern way to run threads is concurrent.futures.ThreadPoolExecutor.
The snippet below simulates network calls with time.sleep — a perfect I/O-bound use case.
concurrent.futures.ThreadPoolExecutor। নিচে time.sleep দিয়ে network call simulate করা — এটি একটি আদর্শ I/O-bound কাজ।
from concurrent.futures import ThreadPoolExecutor
import time
def fake_download(url):
time.sleep(1) # pretend this is a network call
return len(url)
urls = [f"https://site/{i}" for i in range(8)]
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as ex:
sizes = list(ex.map(fake_download, urls))
print(f"{len(urls)} downloads in {time.perf_counter()-t0:.2f}s")
print(f"total bytes: {sum(sizes)}")
4. Multiprocessing for CPU-Bound Work
multiprocessing spawns separate OS processes, each with its own Python interpreter and
its own GIL. They can truly run in parallel on multiple cores. The price is startup cost and
the fact that arguments must be picklable so they can cross the process boundary.
multiprocessing আলাদা OS process তৈরি করে — প্রতিটির নিজস্ব Python interpreter ও নিজস্ব GIL। এরা সত্যিই একাধিক core-এ parallel চলতে পারে। বিনিময়ে process startup-এ সময় লাগে এবং argument picklable হতে হয়।
from concurrent.futures import ProcessPoolExecutor
import time, math
def is_prime(n):
if n < 2: return False
for i in range(2, int(math.isqrt(n)) + 1):
if n % i == 0: return False
return True
if __name__ == "__main__":
nums = range(100_000, 100_400)
t0 = time.perf_counter()
with ProcessPoolExecutor() as ex:
primes = sum(ex.map(is_prime, nums))
print(f"primes found: {primes}")
print(f"time : {time.perf_counter()-t0:.2f}s")
if __name__ == "__main__": when using multiprocessing — on Windows and macOS child processes import your script and will otherwise spawn endlessly.
Windows / macOS-এ child process script-টি import করে, তাই
if __name__ == "__main__": guard অবশ্যই দিতে হবে — নইলে অসীম spawn হতে থাকবে।
5. Locks and Race Conditions
When threads share state, operations that look atomic often aren't. counter += 1 is
really read-modify-write. Protect shared state with a threading.Lock.
counter += 1-এর মতো operation আসলে read-modify-write — atomic নয়। threading.Lock দিয়ে shared state রক্ষা করুন।
import threading
counter = 0
lock = threading.Lock()
def worker():
global counter
for _ in range(100_000):
with lock:
counter += 1
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"final counter: {counter}") # expect 400000
6. Producer–Consumer with queue.Queue
queue.Queue is a thread-safe FIFO. It is the simplest correct pattern for passing jobs
from producers to workers.
queue.Queue একটি thread-safe FIFO। producer থেকে worker-এ job পাঠানোর সবচেয়ে সহজ ও সঠিক প্যাটার্ন।
import threading, queue, time
q = queue.Queue()
results = []
def worker():
while True:
item = q.get()
if item is None:
q.task_done(); break
results.append(item * item)
q.task_done()
ts = [threading.Thread(target=worker) for _ in range(3)]
for t in ts: t.start()
for i in range(20): q.put(i)
for _ in ts: q.put(None)
q.join()
for t in ts: t.join()
print(sorted(results))
7. Vocabulary (শব্দভাণ্ডার)
| Term | Meaning | বাংলায় |
|---|---|---|
| GIL | Global Interpreter Lock — one Python thread runs bytecode at a time. | একবারে এক Python thread bytecode চালায় — সেই lock। |
| I/O-bound | Work limited by waiting on disk/network. | Disk/network-এ অপেক্ষার কারণে সীমাবদ্ধ কাজ। |
| CPU-bound | Work limited by raw computation. | গণনার কারণে সীমাবদ্ধ কাজ। |
| Race condition | Result depends on thread interleaving. | Thread-এর সময় অনুসারে ফলাফল বদলে যাওয়া। |
| Deadlock | Two threads wait for each other's lock forever. | দুই thread পরস্পরের lock-এর জন্য চিরকাল অপেক্ষা। |
| Picklable | Can be serialised to cross a process boundary. | Serialise করে process-এ পাঠানো যায় এমন। |
8. Practice Problems
Pick the right tool for each workload.
-
Use
ThreadPoolExecutorto download 5 fake URLs concurrently (simulate withtime.sleep(1)). Measure total time.ThreadPoolExecutorদিয়ে ৫টি fake URL একসাথে "ডাউনলোড" করুন (time.sleep(1)দিয়ে simulate)। মোট সময় মাপুন।✨ Show Answer (উত্তর দেখুন)
ans1.pyfrom concurrent.futures import ThreadPoolExecutor import time def fetch(u): time.sleep(1); return u t0 = time.perf_counter() with ThreadPoolExecutor(5) as ex: list(ex.map(fetch, range(5))) print(f"{time.perf_counter()-t0:.2f}s") -
Explain in three sentences why threads do NOT speed up pure-Python CPU-bound work.তিন বাক্যে বলুন — pure-Python CPU-bound কাজে thread কেন দ্রুত করে না।
✨ Show Answer (উত্তর দেখুন)
Answer: CPython holds a single Global Interpreter Lock so only one thread executes Python bytecode at a time. When many threads compete for CPU bytecode, they must take turns and actually add scheduling overhead. Real CPU parallelism therefore requires multiple processes (multiprocessing) or C extensions that release the GIL (NumPy).
CPython-এ একটি মাত্র GIL, তাই একবারে এক thread-ই bytecode চালাতে পারে। একাধিক thread কেবল পালাক্রমে চালাতে হয় — overhead বাড়ে। সত্যিকারের parallel CPU কাজের জন্য multiprocessing বা GIL-ছাড়া C extension (NumPy) লাগে।
-
Use
ProcessPoolExecutorto square numbers 1 to 10 in parallel.ProcessPoolExecutorদিয়ে ১ থেকে ১০ সংখ্যার square parallel-এ বের করুন।✨ Show Answer (উত্তর দেখুন)
ans3.pyfrom concurrent.futures import ProcessPoolExecutor def sq(x): return x*x if __name__ == "__main__": with ProcessPoolExecutor() as ex: print(list(ex.map(sq, range(1, 11)))) -
Demonstrate a race condition: 4 threads each add 10,000 to a shared counter without a lock, print the (usually wrong) result.Lock ছাড়া ৪টি thread shared counter-এ ১০,০০০ করে যোগ করলে race condition দেখান।
✨ Show Answer (উত্তর দেখুন)
ans4.pyimport threading c = 0 def w(): global c for _ in range(10_000): c += 1 ts = [threading.Thread(target=w) for _ in range(4)] for t in ts: t.start() for t in ts: t.join() print(f"expected 40000, got {c}") -
When should you use
asyncioinstead of threads? Answer in three bullets.asyncioকখন thread-এর চেয়ে ভালো — তিনটি bullet-এ লিখুন।✨ Show Answer (উত্তর দেখুন)
- When you have thousands of concurrent I/O operations (threads would waste too much memory).
- When you control the libraries and they are async-aware (
httpx,aiofiles). - When you want deterministic, cooperative scheduling without locks.
(১) হাজার হাজার concurrent I/O হলে (thread-এ memory নষ্ট)। (২) Library async-aware হলে (
httpx,aiofiles)। (৩) Lock ছাড়াই cooperative, deterministic scheduling চাইলে।
Summary — Module 32
The GIL lets only one thread run Python bytecode at a time, but releases during I/O.
Use threading for I/O-bound work, multiprocessing for CPU-bound pure-Python work,
and asyncio for huge fan-out networking. Always protect shared state with Lock
or pass messages through queue.Queue.
threading, CPU-bound pure-Python-এ multiprocessing, বিশাল networking-এ asyncio। Shared state সব সময় Lock বা Queue দিয়ে রক্ষা করুন।