Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ১৩/১৫অ্যাডভান্সড১ ঘণ্টা ৫ মিনিট

Concurrency Model — threads, async/await, actors, CSP: একই সমস্যার চার উত্তর

Concurrency Models: Threads, Async/Await, Actors, CSP

শত কাজ একসাথে চালানোর একটাই সমস্যা, ভাষাগুলোর চারটা ভিন্ন উত্তর — প্রতিটা উত্তর আসলে 'কোথায় block করা যাবে, কে state দেখবে, ভুল হলে কে মরবে' প্রশ্নের একটা ভিন্ন সিদ্ধান্ত-সেট।

এই লেসন শেষে আপনি পারবেন

  • চারটা concurrency model — shared-memory threads, async/await state machine, M:N green threads, actor/CSP — প্রতিটার state sharing, preemption, deadlock class, cancellation আর error propagation টেবিলে তুলনা করতে পারবেন
  • async function কীভাবে compiler দ্বারা pollable state machine-এ transform হয় (Rust Future::poll, JS microtask queue, Python asyncio loop over epoll) — একটা async snippet-এর সম্পূর্ণ desugaring লিখতে পারবেন
  • একই IO-bound workload (১০০ concurrent task) চার উপায়ে চালিয়ে wall time আর peak RSS মেপে বলতে পারবেন কোন মডেল কোন workload-এ কেন জেতে
  • bounded channel দিয়ে backpressure আর unbounded queue-র memory blowup-এর পার্থক্য ব্যাখ্যা করে pipeline design-এ প্রয়োগ করতে পারবেন
  • virtual-clock cooperative scheduler নিজের হাতে লিখে seeded random yield দিয়ে interleaving-এর determinism প্রমাণ করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

একটা web server-কে ১০,০০০ connection একসাথে সামলাতে হবে। প্রতিটা connection বেশিরভাগ সময় অপেক্ষায় কাটায় — socket-এ ডেটা আসার, database-এর উত্তরের, disk-এর। CPU-র কাজ মাত্র কয়েক percent। এই একটা সমস্যার জন্য পৃথিবীর ভাষাগুলো চার রকম উত্তর দিয়েছে:

১. Threads + Locks   — Java, C, C++: OS-এর thread, shared memory, mutex
২. Async/Await       — JavaScript, Rust, Python(asyncio): এক thread, event loop
৩. Green Threads     — Go: user-space scheduler, লাখো হালকা goroutine
৪. Actors / CSP      — Erlang, Go channels: কেউ কাউকে ছোঁয় না, বার্তা আদান-প্রদান

operating-systems module-এ আপনি thread, mutex, epoll — সব যন্ত্রাংশ চিনেছেন। আজকের প্রশ্ন যন্ত্রাংশের না, স্থাপত্যের: এই যন্ত্রাংশগুলো দিয়ে ভাষা programmer-কে কোন চিন্তার ফ্রেম দেয়, প্রতিটা ফ্রেম কোন ভুল অসম্ভব করে, কোন ভুল সহজ করে। runtime-systems লেসনে দেখেছেন Go-র runtime নিজেই user-space OS — আজ দেখব সেটা কেন বানানো হয়েছিল, আর বিকল্পগুলো কী দেয় যা সে দেয় না।

আজকের প্রশ্ন তিনটা: (১) “একসাথে অনেক কাজ” বলতে ঠিক কী বোঝায় — আর কোন মডেল কোন ধরনের “একসাথে”-তে জেতে? (২) async/await আসলে কী জিনিস — thread? নাকি compiler-এর বানানো একটা state machine? (৩) shared state ছাড়লে কোন সমস্যাগুলো নিখোঁশ হয়, আর কোন নতুন সমস্যা জন্মায়?

মূল ধারণা

Model মানে কী — তিনটা সিদ্ধান্ত

প্রতিটা concurrency model আসলে তিনটা প্রশ্নের উত্তর:

  1. State কে দেখবে? সবাই একই memory (shared) নাকি প্রত্যেকে নিজের (isolated)?
  2. কখন থামতে পারবে? OS যেকোনো instruction-এ preemption করবে (preemptive), নাকি কাজ নিজে সুযোগ দেবে (cooperative)?
  3. ভুল হলে কে মরবে? পুরো process, শুধু কাজটা, নাকি supervisor ঠিক করবে?

চারটা মডেল = এই তিন উত্তরের চারটা combination।

১. Shared-memory threads + locks

operating-systems/threads আর synchronization-primitives লেসনের জগৎ: প্রতিটা <Term id="thread">Thread</Term> একটা kernel entity, নিজের stack, সবার একই heap। শেয়ার করতে <Term id="mutex">Mutex</Term>:

counter = 0
lock = threading.Lock()

def worker():
    global counter
    for _ in range(100000):
        with lock:          # acquire → increment → release
            counter += 1    # তিনটা machine instruction — মাঝে preemption সম্ভব

শক্তি: CPU-bound parallelism-এর সরাসরি রাস্তা (multi-core-এ)। দাম: preemption যেকোনো instruction-এ — দুই thread counter += 1-এর মাঝেই থামতে পারে, race condition probabilistic, reproduce কঠিন; lock-এর ক্রম ভুল হলে deadlock। সঠিকতার পুরো দায় programmer-এর কাঁধে — প্রতিটা shared byte-এর জন্য।

২. Async/Await — compiler-বানিয়ে state machine

JavaScript, Rust, Python-এর asyncio — তিনটাতেই মূল ভাবনা এক: একটাই OS thread, আর blocking হওয়া নিষিদ্ধ। আপনি await লিখলে compiler/runtime আপনার function-কে ভেঙে দেয় ছোট ছোট টুকরোয়, প্রতিটা IO-wait-এর জায়গায়। বিস্তারিত hood section-এ — এটাই আজকের সবচেয়ে গুরুত্বপূর্ণ মেকানিজম।

শক্তি: ১০,০০০ connection-ও এক thread-এ — প্রতিটা wait মাত্র একটা callback registration (io-models লেসনের epoll!)। দাম: function-এর রঙ (colored functions) — async কোড sync কোড ডাকতে পারে কিন্তু sync কোড async-কে block করে; আর একটা blocking call পুরো event loop জিম্মা করে বসে।

৩. Green threads / M:N scheduling

Go-র উত্তর: blocking-style কোডের স্বাচ্ছন্দ্য + async-এর scale। Runtime নিজেই user-space scheduler — M goroutine চলে N OS thread-এ (runtime-systems লেসনের G-M-P ছবি মনে করুন)। go func(){} মাত্র ~২ KB stack; blocking call লিখলেও runtime epoll-এ রেজিস্টার করে goroutine park করে, OS thread-কে অন্য goroutine দেয় (netpoller)। Programmer async/await-এর syntax দেখে না — রঙের সমস্যাটা অদৃশ্য। দাম: runtime বিশাল (scheduler + GC integration + stack growth), আর FFI-তে C-র ভেতরে blocking syscall হলে runtime-এর নজরের বাইরে একটা OS thread সত্যিই আটকে যায়।

৪. CSP আর Actor — “share memory by communicating”

Hoare-র 1978 paper-এর ভাবনা: shared state + lock ভুলে যাও; ডেটার মালিকানা বার্তা ধরে হাতবদল করো। দুইটা ভাইবন্ধ model:

CSP (Go channels): channel একটা conduit — sender ch ← v, receiver v ← ch। বার্তা হস্তান্তরের মুহূর্তেই মালিকানা বদলায়; একই ডেটা একসাথে দুই জনের হাতে থাকে না (Go-র motto: “Don’t communicate by sharing memory — share memory by communicating”)।

func worker(jobs <-chan int, done chan<- int) {
    for j := range jobs {          // channel বন্ধ হলে loop শেষ
        done <- process(j)         // ফলাফল পাঠানো — কোনো shared variable নেই
    }
}

Actor (Erlang/Elixir): প্রতিটা actor-এর একটা mailbox; সে একবারে একটা বার্তা প্রক্রিয়া করে, নিজের state ছাড়া কেউ কিছু দেখতেই পারে না। বাড়তি ধারণা — supervision tree: প্রতিটা actor-এর একজন guardian, সন্তান crash করলে সে জেনে যায় আর fresh state-এ পুনরায় চালু করে। Joe Armstrong-এর সূত্র: “let it crash” — defensive coding না, restart নীতি।

ভেতরে কী ঘটছে

async/await আসলে কী — desugaring

await কোনো magic suspend না — compiler-এর একটা যান্ত্রিক রূপান্তর। Rust উদাহরণ, আগে-পরে:

async fn fetch() -> u32 {
    let a = load().await;     // ← এখানেই function "থামতে" পারে
    a + 1
}

Compiler এটাকে দেখায় এমন একটা state machine হিসেবে:

enum FetchFut {
    Start,
    Waiting(LoadFut),          // load()-এর future, suspended অবস্থায়
    Done,
}

fn poll(self: Pin<&mut FetchFut>, cx: &mut Context) -> Poll<u32> {
    loop {
        match self {
            FetchFut::Start => {
                self = FetchFut::Waiting(load());   // ধাপ ১ শুরু
            }
            FetchFut::Waiting(f) => match f.poll(cx) {
                Poll::Ready(a)  => return Poll::Ready(a + 1),
                Poll::Pending   => return Poll::Pending,  // waker রেখে ফেরত
            },
            FetchFut::Done => panic!("polled after completion"),
        }
    }
}

তিনটা মূল সত্য এখান থেকে বেরিয়ে আসে:

  1. “Thamano” কোনো OS concept না — future-টা নিজের state (enum variant) আর captured local-রা (a-এর জায়গা) নিজের ভেতরে জমা রাখে। Await point-এর মধ্যের local variable-রাই enum-এর field হয়ে যায় — এজন্যই বড় async function-এর future বড় হয়।
  2. Driver না থাকলে কিছুই চলে না। poll কেউ ডাকতে হবে — Rust-এ executor (Tokio), JS-এ event loop, Python-এ asyncio। async fn call করলে শুধু machine তৈরি হয়, চালায় না।
  3. Waker = callback-এর সভ্য রূপ। IO complete হলে epoll-এর event (io-models লেসন!) waker-কে জাগায়, waker future-টাকে executor-এর ready-queue-তে ফেরত দেয় — পরের poll-এ কাজ এগোয়।

ভাষাভেদে একই যন্ত্র, ভিন্ন পোশাক

JavaScript — microtask queue: promise callback মূল কাজ শেষ হওয়ার সাথে সাথে, পরের macrotask-এর আগে চলে:

console.log('1');
setTimeout(() => console.log('4'), 0);            // macrotask queue
Promise.resolve().then(() => console.log('3'));   // microtask queue
console.log('2');
// output: 1 2 3 4 — microtask সম্পূর্ণ drain হয় macrotask-এর আগে

Python asyncio: event loop = selector (epoll/kqueue) + ready-callback queue + scheduled-coroutine heap। await asyncio.sleep(0.1) মানে coroutine টা নিজের continuation টাইমারে রেজিস্টার করে control loop-কে ফেরত দেয় — আমাদের আজকের build-এর সরাসরি বড় ভাই।

Green threads — একই সমস্যা, runtime-স্তরে সমাধান

Go-তে time.Sleep বা socket read লিখলে আপনি OS-কে সরাসরি কিছু বলছেন না: runtime syscall-টা আটকে নিজের netpoller-এ রেজিস্টার করে, চলমান goroutine-কে park করে, OS thread-কে অন্য goroutine দেয়। Compiler প্রতিটা function-এ stack-growth check বসায় (runtime-systems লেসনের morestack), scheduler cooperative yield point-গুলো function call-এই বসিয়ে দেয় — 1.14 থেকে signal- ভিত্তিক async preemption-ও আছে। ফল: async/await-এর সব scale, কোনো রঙ ছাড়া।

Channel-এর ভেতরটা

Go channel আসলে একটা struct: mutex + ring buffer + দুইটা waiting queue (sender/receiver)। Send-এ receiver অপেক্ষমাণ থাকলে সরাসরি hand-off (rendezvous), নইলে buffer-এ, buffer ভরা হলে sender park। Bounded channel-ই backpressure- এর যন্ত্র: consumer ধীর হলে producer buffer ভরে park হয়ে যায় — পুরো pipeline-এর গতি সবচেয়ে ধীর ধাপের কাছে স্বয়ংক্রিয়ভাবে সীমাবদ্ধ হয়। Unbounded queue লিখলে সেই চাপ মেমরি হয়ে ফুটবে — production incident-এর একটা চিরচেনা উৎস।

await একটা HTTP call — উপর থেকে নিচে
  1. async fnawait দেখে compiler state machine-এর পরের state-এ সরিয়ে Pending ফেরত দেয়
  2. Executor/event loopfuture-টার waker নিয়ে IO-র সাথে রেজিস্টার করে, অন্য ready task চালায় — এক OS thread
  3. epoll/kqueuesocket ready হলে event — io-models লেসনের readiness model
  4. Waker → ready queuefuture আবার poll পায়, state machine পরের ধাপে এগোয়

উদাহরণ

একই কাজ চার পোশাকে — ৩টা “fetch”, প্রতিটায় ১০০ms IO

Sequential (baseline):
  fetch1 ██████  fetch2 ██████  fetch3 ██████          total ≈ 300 ms

Threads (৩ OS thread):
  T1 fetch1 ██████
  T2 fetch2 ██████
  T3 fetch3 ██████                                     total ≈ 100 ms
  খরচ: ৩ × ৮ MB stack reservation + kernel scheduling

Async (১ thread, event loop):
  loop: start all ── wait epoll ── collect ৩টা একসাথে   total ≈ 100 ms
  খরচ: ১ thread, প্রতি task কয়েক KB state machine

Goroutines (৩ × ২ KB stack, M:N):
  G1/G2/G3 park ── netpoller জাগায়                     total ≈ 100 ms
  খরচ: কয়েক KB, কিন্তু কোড দেখতে sequential-ই

কোডের আকারে পার্থক্যটা আরও প্রকট — Python:

async def fetch_all(urls):
    return await asyncio.gather(*(fetch(u) for u in urls))

Go-তে একই কাজ goroutine দিয়ে — দেখতে একদম plain, fetch-এর ভেতরের প্রতিটা blocking call runtime-এর কাছে cooperative yield; কোনো await না থাকায় অদৃশ্যতাই Go-র design bet (আর C-র FFI-তে ঢুকলেই জাদু বন্ধ — সেটাই তার দুর্বলতা)।

ছবিতে

State machine-এর জন্ম — আগে-পরে

আপনি লেখেন:                    compiler বানায়:

async fn job() {               enum JobState {
  a = step1().await;             S0,
  b = step2(a).await;   ──►      S1 { a: A, fut1: F1 },
  return b;                      S2 { b: B, fut2: F2 },
}                                Done
                               }
                               poll(): S0→শুরু, S1→fut1.poll, S2→fut2.poll
                 shared state ◄──────────────────► isolated state

preemptive   ┌──────────────────────┐
  (OS যেকোনো  │ Threads + Locks      │
  মুহূর্তে থামায়)│ race, deadlock-class:│
             │ lock cycle           │
             └──────────────────────┘

cooperative  ┌──────────────────────┐  ┌──────────────────────┐
  (নির্দিষ্ট   │ Async/Await          │  │ Green threads (Go)   │
  বিন্দুতে হস্তান্তর)│ forgotten await,  │  │ (shared, কিন্তু yield│
             │ blocking-loop বিপদ    │  │  বিন্দু runtime-এর)   │
             └──────────────────────┘  └──────────────────────┘
             ┌──────────────────────┐  ┌──────────────────────┐
             │ CSP channels (Go)    │  │ Actor (Erlang)       │
             │ wait-cycle deadlock, │  │ mailbox overflow,    │
             │ backpressure সহজ      │  │ supervision restart  │
             └──────────────────────┘  └──────────────────────┘
চার মডেল, এক মানচিত্রে — বাঁদিকে 'কে state দেখে', ডানদিকে 'কখন control হস্তান্তর হয়'। উপরের দুইটা shared/isolated অক্ষ, নিচের দুইটা preemptive/cooperative অক্ষ — প্রতিটা মডেলের শক্তি আর রোগ এই অবস্থান থেকেই অনুমান করা যায়।

নিজে চালিয়ে দেখুন

EXPERIMENT

১০০ concurrent task, চার উপায়ে — wall time বনাম memory

Linux/macOS, Python 3.8+ (Go থাকলে বোনাস)· ২৫ মিনিট

ধাপ ১ — একই workload, তিন Python কৌশল:

# io_bench.py — usage: python3 io_bench.py sequential|threads|pool|asyncio
import asyncio
import concurrent.futures
import resource
import sys
import threading
import time

TASKS = 100
IO_MS = 100

def io_task(i):
    time.sleep(IO_MS / 1000)          # blocking — kernel-এ ঘুমান, GIL ছাড়ে
    return i * 2

def peak_rss_mb():
    raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    return raw / (1024 * 1024) if sys.platform == "darwin" else raw / 1024

def run_sequential():
    return [io_task(i) for i in range(TASKS)]

def run_threads():
    results = [None] * TASKS
    threads = []
    def wrapper(i):
        results[i] = io_task(i)
    for i in range(TASKS):
        t = threading.Thread(target=wrapper, args=(i,))
        threads.append(t)
        t.start()
    for t in threads:
        t.join()
    return results

def run_pool():
    with concurrent.futures.ThreadPoolExecutor(max_workers=TASKS) as pool:
        return list(pool.map(io_task, range(TASKS)))

async def aio_task(i):
    await asyncio.sleep(IO_MS / 1000)   # loop-এর কাছে ছেড়ে দেয়
    return i * 2

def run_asyncio():
    async def main():
        return await asyncio.gather(*(aio_task(i) for i in range(TASKS)))
    return asyncio.run(main())

MODES = {
    "sequential": run_sequential,
    "threads": run_threads,
    "pool": run_pool,
    "asyncio": run_asyncio,
}

t0 = time.perf_counter()
results = MODES[sys.argv[1]]()
elapsed = time.perf_counter() - t0
assert len(results) == TASKS
print(f"{sys.argv[1]:<11} wall={elapsed:6.2f}s  peakRSS={peak_rss_mb():6.1f} MB")
python3 io_bench.py sequential
python3 io_bench.py threads
python3 io_bench.py pool
python3 io_bench.py asyncio

Typical pattern:

sequential  wall= 10.05s   peakRSS=    10 MB   ← ১০০ × ১০০ms সোজা যোগ
threads     wall=  0.16s   peakRSS=    12 MB   ← parallel, কিন্তু ১০০ kernel stack
pool        wall=  0.15s   peakRSS=    12 MB   ← একই, নিয়ন্ত্রিত তৈরি
asyncio     wall=  0.11s   peakRSS=    10 MB   ← এক thread, সবচেয়ে হালকা

RSS-এ পার্থক্য ছোট দেখাচ্ছে কারণ thread stack virtual reservation — physical commit কম। পার্থক্যটা দেখতে ps -o rss,nlwp -p <pid> বা /usr/bin/time -v (Linux) / /usr/bin/time -l (macOS) দিয়ে thread count আর page-level RSS মাপুন; ১০,০০০ task-এ গিয়ে এই হারটাই ফুটে ওঠে।

ধাপ ২ — চতুর্থ উত্তর, Go (installed থাকলে):

// gorob.go — go run gorob.go
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func main() {
	const tasks = 100
	results := make([]int, tasks)
	var wg sync.WaitGroup
	t0 := time.Now()
	for i := 0; i < tasks; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			time.Sleep(100 * time.Millisecond)
			results[i] = i * 2
		}(i)
	}
	wg.Wait()
	var mem runtime.MemStats
	runtime.ReadMemStats(&mem)
	fmt.Printf("goroutines  wall=%v  heapAlloc=%d KB  goroutines-now=%d\n",
		time.Since(t0).Round(time.Millisecond),
		mem.HeapAlloc/1024, runtime.NumGoroutine())
}
go build -o gorob gorob.go
/usr/bin/time -v ./gorob 2>&1 | grep -E "wall|Maximum resident"   # Linux
/usr/bin/time -l ./gorob 2>&1 | grep -E "wall|maximum resident"   # macOS

runtime.NumGoroutine() আর heapAlloc-ই আসল মেট্রিক — goroutine প্রতি খরচ KB-স্তরে থাকে, OS thread-এর MB-স্তরের বিপরীতে।

যা প্রমাণিত: একই সমস্যা, চার উত্তর, চার রকম resource signature। Sequential সঠিক কিন্তু ১০০× ধীর; threads দ্রুত কিন্তু per-task দামি; asyncio সবচেয়ে হালকা কিন্তু একটা blocking call-ই সব আটকে দেয় (নিজে time.sleep-কে await asyncio.sleep-এ বদলে দেখুন — ভুলটা হলে wall=10s ফিরে আসবে)।

এটা কী প্রমাণ করে

IO-bound workload-এ ১০০ task-ও সঠিক মডেলে ~১০০ms-এ শেষ হয়, ভুল মডেলে ১০ সেকেন্ড; আর memory-তে OS thread-এর per-task খরচ (MB-স্তর) user-space task-এর খরচের (KB-স্তর) হাজার গুণ — মডেল নির্বাচন আসলে resource model নির্বাচন।

নিজে বানান

BUILD IT

Cooperative scheduler — virtual clock, channel, determinism

Python · ●●●○○
  1. Task wrapper বানান — generator মোড়ানো, পরের yield-এ value ফেরত পাঠানোর জায়গা সহ
  2. Event loop লিখুন — ready deque + timer heap (virtual tick), wall-clock কোথাও না
  3. Channel primitive যোগ করুন — buffered, sender/receiver parking queue সহ (rendezvous)
  4. ৩ producer + ২ consumer + closer চালান — poison pill দিয়ে সুশৃঙ্খল shutdown
  5. seeded random yield দিয়ে প্রমাণ করুন প্রতি run-এ interleaving হুবহু একই

লক্ষ্য: asyncio-র কঙ্কাল নিজের হাতে — আর দেখানো যে cooperative scheduling-এর সবচেয়ে লুকানো উপহারটা determinism: wall-clock বাদ দিলে একই seed মানে একই execution history, যা OS-thread জগতে অসম্ভব।

# sched.py — cooperative event loop: virtual clock + Channel
import collections
import heapq
import random


class Channel:
    """Buffered channel — CSP-র rendezvous primitive"""

    def __init__(self, cap=1):
        self.cap = cap
        self.buf = collections.deque()
        self.recv_q = collections.deque()   # অপেক্ষমাণ receiver (Task)
        self.send_q = collections.deque()   # parked sender (Task, value)


class Task:
    _next = 0

    def __init__(self, gen):
        Task._next += 1
        self.id = Task._next
        self.gen = gen
        self.value = None                   # পরের resume-এ পাঠানো মান


class Loop:
    def __init__(self, seed=7):
        self.ready = collections.deque()
        self.timers = []                    # (wake_tick, seq, Task)
        self.clock = 0                      # virtual time — কখনো পিছায় না
        self.seq = 0
        self.rng = random.Random(seed)      # seeded → deterministic
        self.alive = 0

    def spawn(self, gen):
        self.alive += 1
        self.ready.append(Task(gen))

    def wake(self, task, value):
        task.value = value
        self.ready.append(task)

    def step(self, task):
        """task-কে পরের yield পর্যন্ত চালাও, request অনুযায়ী সাজাও"""
        try:
            req = task.gen.send(task.value)
        except StopIteration:
            self.alive -= 1
            return
        task.value = None
        if isinstance(req, int):                      # sleep n tick
            self.seq += 1
            heapq.heappush(self.timers, (self.clock + req, self.seq, task))
        elif req[0] == 'send':
            _, ch, item = req
            if ch.recv_q:                             # rendezvous hand-off
                self.wake(ch.recv_q.popleft(), item)
                self.ready.append(task)
            elif len(ch.buf) < ch.cap:
                ch.buf.append(item)
                self.ready.append(task)
            else:
                ch.send_q.append((task, item))        # parked — backpressure
        elif req[0] == 'recv':
            _, ch = req
            if ch.buf:
                item = ch.buf.popleft()
                if ch.send_q:                         # জায়গা ফাঁকা — sender ঢোকাও
                    s, v = ch.send_q.popleft()
                    ch.buf.append(v)
                    self.ready.append(s)
                self.wake(task, item)
            else:
                ch.recv_q.append(task)
        else:
            raise ValueError(f"unknown request: {req!r}")

    def run(self):
        while self.alive:
            if not self.ready:
                if not self.timers:
                    raise RuntimeError("deadlock: সব task blocked")
                self.clock = max(self.clock, self.timers[0][0])
                while self.timers and self.timers[0][0] <= self.clock:
                    self.ready.append(heapq.heappop(self.timers)[2])
            self.step(self.ready.popleft())


# ── pipeline: ৩ producer → bounded channel → ২ consumer ──

state = {'done': 0}

def producer(loop, ch, name, n):
    for i in range(1, n + 1):
        yield loop.rng.randint(1, 3)              # "কাজ" — seeded সময়
        yield ('send', ch, f"{name}#{i}")
    state['done'] += 1

def closer(loop, ch, n_consumers):
    while state['done'] < 3:                      # সব producer শেষ হোক
        yield 1
    for _ in range(n_consumers):
        yield ('send', ch, None)                  # poison pill

def consumer(loop, ch):
    while True:
        item = yield ('recv', ch)
        if item is None:
            return                                # poison — শেষ
        print(f"    tick {loop.clock:>3}: consumed {item}")


if __name__ == '__main__':
    loop = Loop(seed=7)
    ch = Channel(cap=2)                           # bounded → backpressure
    for name in ('P1', 'P2', 'P3'):
        loop.spawn(producer(loop, ch, name, 3))
    loop.spawn(consumer(loop, ch))
    loop.spawn(consumer(loop, ch))
    loop.spawn(closer(loop, ch, n_consumers=2))
    loop.run()
    print(f"done at tick {loop.clock}")

এক run-এর output — এই কোডে কোনো wall-clock নেই, rng seeded; নিজে দুইবার চালিয়ে diff করুন: byte-for-byte identical আসার কথা।

    tick   1: consumed P2#1
    tick   2: consumed P1#1
    tick   2: consumed P3#1
    tick   3: consumed P1#2
    tick   3: consumed P3#2
    tick   4: consumed P2#2
    tick   4: consumed P3#3
    tick   6: consumed P1#3
    tick   6: consumed P2#3
done at tick 7

যা ঘটল: নয়টা item, দুই consumer, সব interleaving ঠিক একই — কারণ scheduling-এর প্রতিটা সিদ্ধান্ত (ready deque-র FIFO ক্রম, timer-এর seq tie-breaker, seeded rng) deterministic। OS-thread জগতে এই প্রশ্নই করা যায় না — race condition debug করার অসম্ভবতার মূল কারণ। আর cap=2 bounded channel-টাই খেয়াল করুন: producer দ্রুত হলে send_q-তে park হয় — backpressure, কোনো unbounded memory নেই।

নিজে বাড়ান:

  1. seed বদলে দেখুন interleaving বদলায় কিন্তু ফলাফল invariant থাকে (৯টা item, সব consumed) — এটাই সঠিক concurrent program-এর সংজ্ঞা।
  2. Consumer একটা মুছে দিন — closer এখন ২ poison পাঠায় কিন্তু receiver একজন: দ্বিতীয় poison কোথায় আটকায়? Deadlock-এর message-টা দেখুন।
  3. ('join', other_task) request যোগ করুন — task completion-এর অপেক্ষা, WaitGroup-এর হাতে-লেখা রূপ।
  4. একটা task-এ infinite yield 1 দিন — loop-এর deadlock detection কি ধরতে পারে? (পারে না — সে alive; starvation detection কেন কঠিন, লিখুন।)

বাস্তব সিস্টেমে

চার মডেল production-এ যেখানে বসে

nginx / Node.js — event loop-এর জয়। nginx worker-প্রতি এক thread, epoll-driven; Node.js পুরো runtime-ই একটা event loop। C10K সমস্যার চূড়ান্ত উত্তর দুটোই async দিয়েছে — কারণ workload টাইপটা ছিল হাজার-hands-idle IO।

Discord — Elixir/actor। কোটি user, লাখো concurrent session — Erlang VM (BEAM)-এর actor model আর supervision tree-তে। এক user-এর bug এক process মারে, supervisor restart করে — পুরো fleet নয়। “Nine nines” (99.9999999% uptime) দাবির পেছনের স্থাপত্য।

Go — cloud infrastructure-এর মূল স্বাদ। Docker, Kubernetes, etcd — সব goroutine-ভিত্তিক। M:N model-এর ব্যবহারিক প্রমাণ: এক process-এ লাখো connection, কোড দেখতে sequential।

Java 21+ virtual threads (Project Loom)। সবচেয়ে বড় নতুন প্রমাণ যে green-thread ভাবনাটা জেতে চলেছে: JVM-ও thread-per-request economics ঠিক করতে user-space scheduling নিল — পুরনো blocking-style Java কোড অপরিবর্তিত রেখে।

Tokio (Rust) আর asyncio (Python)। State-machine মডেলের production বাস্তবায়ন — যেখানে মূল trade-off হলো colored-function জটিলতার বিনিময়ে zero-cost abstraction আর explicit suspension point।

কখন threads-ই ঠিক। CPU-bound numeric work (BLAS, image processing) — wait নেই, তাই async-এর কোনো লাভ নেই; OS thread + data parallelism সরাসরি multi-core ছোঁয়। মডেল নির্বাচনের প্রথম প্রশ্নটা তাই কখনো “কোনটা ভালো” না — “আমার কাজ কি অপেক্ষা করে, নাকি হিসাব করে?”

যে ভুলগুলো সবাই করে

“async/await মানে multithreading।”

না — asyncio-র default একটাই OS thread। Concurrency আছে (interleaving), parallelism নেই (একসাথে এক instruction-ও না)। প্রমাণ: CPU-bound loop একটা async task-এ চালালে পুরো event loop জমে বসে — অন্য কোনো task এক মাইক্রোসেকেন্ডও এগোয় না। Rust-এর Tokio আলাদা: multi-threaded scheduler আছে, কিন্তু সেটা executor-এর সিদ্ধান্ত, async ভাষার না।

“async function-এ সবকিছু দ্রুত হয়ে যায়।”

শুধু IO-bound কাজে লাভ — যেখানে অপেক্ষার সময়টা অন্য কাজে ভাগ করা যায়। CPU-bound কাজ async-এ একদম একই গতি (বা সামান্য ধীর — state machine overhead), আর ভুল করে event loop-এ চালালে সব ব্লক হয়ে যায়। একটা time.sleep async function-এর ভেতরে রেখে দিলে পুরো “concurrent” সিস্টেম sequential হয়ে যায় — আজকের experiment-এর সবচেয়ে সস্তা প্রমাণ।

“Goroutine মানে সস্তা OS thread।”

Goroutine OS thread না-ই — user-space construct, runtime-এর scheduler-এর সম্পদ; OS thread (M) তাদের মধ্যে ভাগ হয় M:N। পার্থক্য ব্যবহারিক: goroutine ২ KB stack দিয়ে শুরু, context switch runtime-এ (ন্যানোসেকেন্ড-স্তর, kernel entry ছাড়া), blocking syscall-এ M hand-off। Thread-এর সাথে মিল শুধু “blocking-স্টাইল কোড লেখা যায়” — বাকি সব ভিন্ন।

“Actor/CSP মডেলে deadlock অসম্ভব — shared state নেই তো।”

Shared-state deadlock (lock cycle) নিশ্চিয় অসম্ভব, কিন্তু wait-cycle deadlock পুরোপুরি সম্ভব: actor A বার্তার উত্তর অপেক্ষায় B-কে পাঠাল, B সেই মুহূর্তে A-কে synchronous বার্তা পাঠাল — দুজনেই অপেক্ষায়। Go-তে unbuffered channel-দের বৃত্তাকার send-এ একই পরিণতি। যা সত্যিই বদলায়: সমস্যাটা এখন message-flow graph-এ দৃশ্যমান (কে কার কাছে synchronous অপেক্ষা করছে), lock-এর hidden interleaving-এ না — তাই চিন্তা আর test সহজ, নির্মূল না।

বুঝেছেন কি না দেখুন

1

async function-কে compiler state machine-এ রূপান্তর করে — সেই machine-এর তিনটা অপরিহার্য অংশ কী কী? poll কে ডাকে, আর IO complete হওয়ার পরে future কীভাবে আবার চলে আসে?

স্মরণ

অংশ: (১) state enum — প্রতিটা await point একটা variant; (২) captured locals — await-এর মধ্যে বেঁচে থাকা variable-রা enum-এর field; (৩) child futures + waker। poll ডাকে executor/event loop (Tokio, JS loop, asyncio)। IO complete হলে epoll event waker-কে জাগায়, waker future-কে ready-queue-তে ফেরত দেয়, executor পরের poll-এ state machine এগিয়ে দেয়। Future নিজে নিজে কখনো চলে না — driver ছাড়া async কোড মৃত।

2

JS snippet: setTimeout(f,0); Promise.resolve().then(g); console.log('x'); — কোন ক্রমে চলে? কেন?

প্রয়োগ

'x' → g() → f()। Sync কোড (console.log) আগে; তারপর event loop microtask queue সম্পূর্ণ drain করে (promise callback g); তারপর পরের macrotask (timer f)। নিয়ম: প্রতিটা macrotask-এর পরে microtask queue শূন্য হতে হয় — এই ordering-ই promise-চেইনকে setTimeout-এর চেয়ে “তাৎক্ষণিক” করে তোলে।

3

Go-তে লাখো goroutine-এ blocking-style কোড চলে, কিন্তু JavaScript-এ একটাও blocking call সহ্য করে না। এই পার্থক্যের structural কারণ কী — ভাষার না runtime-এর?

যুক্তি

Runtime-এর — এবং তার সাথে একটা ভাষাগত চুক্তির। Go-র runtime প্রতিটা potentially-blocking call-কে আটকে নেয় (netpoller, syscall hook): goroutine park হয়, OS thread অন্য কাজে যায় — blocking কোডের নিচে cooperative yield runtime নিজেই বসিয়ে দেয়। JS-এ কোনো এই ধরার স্তর নেই: fetch-এর মতো API-রা async কারণ তারা তাই লেখা, কিন্তু sync API ডাকলে সেটা সত্যিই kernel-এ ঘুমায় আর একমাত্র thread-টাই ঘুমায়। ভাষাগত চুক্তিটা হলো Go-তে “সব function থামতে পারে” অনুমান নিরাপদ (runtime রক্ষা করে), JS-এ অনিরাপদ (কেউ রক্ষা করে না)। তাই একই syntax লিখলেও দুই জগতে ব্যর্থতার ধরন আকাশ-পাতাল।

4

Image-processing pipeline: downloader (দ্রুত, network) → resizer (ধীর, CPU) → uploader (মাঝারি)। Downloader কি unbounded queue দিয়ে resizer-কে feed করবে? Design-টা bounded channel দিয়ে করুন আর ব্যাখ্যা করুন backpressure কোথায় কোথায় আটকায়।

ডিজাইন

না — unbounded queue-তে downloader-এর গতিতে সব image memory-তে জমবে (resizer ধীর), OOM নিশ্চিত। Design: প্রতিটা ধাপের মাঝে bounded channel (যেমন cap=১৬)। Backpressure chain: resizer ব্যস্ত → তার input channel ভরে → downloader-এর send park → downloader স্বয়ংক্রিয়ভাবে network-এর গতিতে সীমাবদ্ধ; uploader ধীর হলে resizer-এর output ভরে → resizer park → input আরো ভরে → সিস্টেমের throughput সবচেয়ে ধীর ধাপের (resizer) সমান হয়ে স্থিতিশীল। মেমরি বাউন্ড = ধাপসংখ্যা × cap। বাড়তি সুবিধা: প্রতিটা ধাপ আলাদা worker pool-এ চলতে পারে (CPU ধাপে N core) — CSP-র composition-এর প্রকৃত শক্তি।

5

“Threads+locks-এ ভুল করা সহজ কিন্তু ভুলগুলো স্থানীয়; actors-এ ভুল করা কঠিন কিন্তু ভুলগুলো বৈশ্বিক” — এই দাবির সঠিক রূপটা লিখুন: কোন ভুলগুলো কোন মডেলে কেন দৃশ্যমান/অদৃশ্য?

যুক্তি

সঠিক রূপ প্রায় উল্টো। Threads+locks-এ ভুল (race, lock-order cycle) অদৃশ্য এবং non-local: দুইটা দূরের function-এর interleaving-এর ফল, কোনো single লাইনে নেই, reproduce probabilistic — এজন্যই সবচেয়ে দামি bug class। Actor/CSP-তে state-access ভুল (দুই জনের একসাথে mutation) structural ভাবে অসম্ভব — মালিকানা হস্তান্তরই এটা আটকায়; বাকি থাকে protocol ভুল (dead message, wait-cycle, mailbox overflow) — যেগুলো message-flow diagram-এ আঁকা যায়, static analysis আর test-এ ধরা যায়। অর্থাৎ actor-এর প্রকৃত উপহার “ভুল কম” না — “ভুলগুলো দৃশ্যমান জায়গায় সরে গেছে”।

এরপর কী

এরপর কী

এক ভাষার ভেতরে অনেক কাজ — এখন সামলানো হলো। উল্টো প্রশ্নটা বাকি: এক প্রোগ্রামে অনেক ভাষা। Python-এর ভেতর থেকে C লাইব্রেরি ডাকলে ঠিক কী ঘটে? দুই ভাষা যে একে অপরের function call পড়তে পারে, তার পেছনে কোন binary চুক্তি — ABI — কাজ করে? Struct-এর padding ভুল করলে কেন চিৎকার না করে নীরবে ডেটা নষ্ট হয়? আর GC-চালিত ভাষার object C-র হাতে দিলে কোন মালিকানার নিয়ম মানতে হয়? পরের লেসনে FFI-র পুরো রাস্তা — dlopen থেকে libffi পর্যন্ত — হাঁটব, আর ইচ্ছা করে ভেঙেও দেখব।

পরের লেসন: FFI ও ABI — ভিন্ন ভাষারা একে অপরকে ডাকে যেভাবে।

আরও পড়ুন

  • Communicating Sequential Processes (CACM 1978) — C. A. R. Hoare · CSP-র original paper — channels আর rendezvous-এর গাণিতিক ভিত্তি
  • Share Memory By Communicating (Go Blog) — Andrew Gerrand, The Go Blog · Go-র CSP-দর্শনের canonical বর্ণনা — এই লেসনের pipeline pattern-এর উৎস
  • Making Reliable Distributed Systems in the Presence of Software Errors — Joe Armstrong · Erlang/OTP-র actor model আর supervision tree-র জনকের PhD thesis
  • Seven Concurrency Models in Seven Weeks — Paul Butcher · মডেল-ভিত্তিক তুলনার সর্বাধিক পাঠযোগ্য survey