Concurrency: threading, multiprocessing & the GIL

একসাথে একাধিক কাজ — thread, process ও GIL

Read: ~40 min Hard 5 practice problems Live code runner

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.

Concurrency মানে একাধিক কাজ সামলানোর জন্য প্রোগ্রামকে সাজানো। Parallelism মানে সেই কাজগুলো একই মুহূর্তে একাধিক CPU core-এ চালানো। Single-core মেশিন concurrent হতে পারে, কিন্তু সত্যিকারের parallel নয়।
Concurrency — 1 CPU, interleaved A B A B A Parallelism — 2 CPUs, same time CPU 1: A A A A A A A CPU 2: B B B B B B B Python threads (because of GIL) give concurrency for CPU-bound work, not parallelism. Figure 32.1 — Concurrency হলো সাজানো, parallelism হলো সত্যিকারের একসাথে চালানো।

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).

CPython-এ একটি global lock থাকে — GIL। এই কারণে একবারে মাত্র একটি thread Python bytecode চালাতে পারে। ফলে pure-Python CPU-bound কাজে thread একাধিক core ব্যবহার করতে পারে না। তবে I/O-র সময় GIL ছেড়ে দেয়, তাই I/O-bound কাজে threading কার্যকর (network, disk, sleep)।
WorkloadBest toolWhy
Many network requeststhreading / asyncioWaits release the GIL.
Heavy math (pure Python)multiprocessingEach process has its own GIL.
Heavy math (NumPy)NumPy / threadsNumPy releases the GIL in C code.
Thousands of socketsasyncioOne 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.

Thread চালানোর আধুনিক উপায় concurrent.futures.ThreadPoolExecutor। নিচে time.sleep দিয়ে network call simulate করা — এটি একটি আদর্শ I/O-bound কাজ।
threads.py
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 হতে হয়।
mp_demo.py
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")
Always guard with 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.

Thread যখন একই data share করে তখন counter += 1-এর মতো operation আসলে read-modify-write — atomic নয়। threading.Lock দিয়ে shared state রক্ষা করুন।
lock.py
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 পাঠানোর সবচেয়ে সহজ ও সঠিক প্যাটার্ন।
queue_demo.py
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 (শব্দভাণ্ডার)

TermMeaningবাংলায়
GILGlobal Interpreter Lock — one Python thread runs bytecode at a time.একবারে এক Python thread bytecode চালায় — সেই lock।
I/O-boundWork limited by waiting on disk/network.Disk/network-এ অপেক্ষার কারণে সীমাবদ্ধ কাজ।
CPU-boundWork limited by raw computation.গণনার কারণে সীমাবদ্ধ কাজ।
Race conditionResult depends on thread interleaving.Thread-এর সময় অনুসারে ফলাফল বদলে যাওয়া।
DeadlockTwo threads wait for each other's lock forever.দুই thread পরস্পরের lock-এর জন্য চিরকাল অপেক্ষা।
PicklableCan be serialised to cross a process boundary.Serialise করে process-এ পাঠানো যায় এমন।

8. Practice Problems

Pick the right tool for each workload.

প্রতিটি কাজের জন্য সঠিক tool বাছাই করুন।
  1. Use ThreadPoolExecutor to download 5 fake URLs concurrently (simulate with time.sleep(1)). Measure total time.
    ThreadPoolExecutor দিয়ে ৫টি fake URL একসাথে "ডাউনলোড" করুন (time.sleep(1) দিয়ে simulate)। মোট সময় মাপুন।
    ✨ Show Answer (উত্তর দেখুন)
    ans1.py
    from 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")
  2. 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) লাগে।

  3. Use ProcessPoolExecutor to square numbers 1 to 10 in parallel.
    ProcessPoolExecutor দিয়ে ১ থেকে ১০ সংখ্যার square parallel-এ বের করুন।
    ✨ Show Answer (উত্তর দেখুন)
    ans3.py
    from 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))))
  4. 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.py
    import 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}")
  5. When should you use asyncio instead 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.

GIL একবারে এক thread-ই Python bytecode চালাতে দেয়, কিন্তু I/O-এর সময় ছেড়ে দেয়। I/O-bound-এ threading, CPU-bound pure-Python-এ multiprocessing, বিশাল networking-এ asyncio। Shared state সব সময় Lock বা Queue দিয়ে রক্ষা করুন।

Next Module → Async Programming: asyncio, async/await।