Concurrency Model — threads, async/await, actors, CSP: একই সমস্যার চার উত্তর
Concurrency Models: Threads, Async/Await, Actors, CSP
শত কাজ একসাথে চালানোর একটাই সমস্যা, ভাষাগুলোর চারটা ভিন্ন উত্তর — প্রতিটা উত্তর আসলে 'কোথায় block করা যাবে, কে state দেখবে, ভুল হলে কে মরবে' প্রশ্নের একটা ভিন্ন সিদ্ধান্ত-সেট।
আগে এটা বুঝি
একটা 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 আসলে তিনটা প্রশ্নের উত্তর:
- State কে দেখবে? সবাই একই memory (shared) নাকি প্রত্যেকে নিজের (isolated)?
- কখন থামতে পারবে? OS যেকোনো instruction-এ preemption করবে (preemptive), নাকি কাজ নিজে সুযোগ দেবে (cooperative)?
- ভুল হলে কে মরবে? পুরো 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"),
}
}
}তিনটা মূল সত্য এখান থেকে বেরিয়ে আসে:
- “Thamano” কোনো OS concept না — future-টা নিজের state (enum variant) আর
captured local-রা (
a-এর জায়গা) নিজের ভেতরে জমা রাখে। Await point-এর মধ্যের local variable-রাই enum-এর field হয়ে যায় — এজন্যই বড় async function-এর future বড় হয়। - Driver না থাকলে কিছুই চলে না।
pollকেউ ডাকতে হবে — Rust-এ executor (Tokio), JS-এ event loop, Python-এ asyncio।async fncall করলে শুধু machine তৈরি হয়, চালায় না। - 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-এর একটা চিরচেনা উৎস।
- async fnawait দেখে compiler state machine-এর পরের state-এ সরিয়ে Pending ফেরত দেয়
- Executor/event loopfuture-টার waker নিয়ে IO-র সাথে রেজিস্টার করে, অন্য ready task চালায় — এক OS thread
- epoll/kqueuesocket ready হলে event — io-models লেসনের readiness model
- 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 │
└──────────────────────┘ └──────────────────────┘নিজে চালিয়ে দেখুন
১০০ concurrent task, চার উপায়ে — wall time বনাম memory
ধাপ ১ — একই 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 asyncioTypical 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" # macOSruntime.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 নির্বাচন।
নিজে বানান
Cooperative scheduler — virtual clock, channel, determinism
- Task wrapper বানান — generator মোড়ানো, পরের yield-এ value ফেরত পাঠানোর জায়গা সহ
- Event loop লিখুন — ready deque + timer heap (virtual tick), wall-clock কোথাও না
- Channel primitive যোগ করুন — buffered, sender/receiver parking queue সহ (rendezvous)
- ৩ producer + ২ consumer + closer চালান — poison pill দিয়ে সুশৃঙ্খল shutdown
- 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 নেই।
নিজে বাড়ান:
seedবদলে দেখুন interleaving বদলায় কিন্তু ফলাফল invariant থাকে (৯টা item, সব consumed) — এটাই সঠিক concurrent program-এর সংজ্ঞা।- Consumer একটা মুছে দিন — closer এখন ২ poison পাঠায় কিন্তু receiver একজন: দ্বিতীয় poison কোথায় আটকায়? Deadlock-এর message-টা দেখুন।
('join', other_task)request যোগ করুন — task completion-এর অপেক্ষা, WaitGroup-এর হাতে-লেখা রূপ।- একটা 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 সহজ, নির্মূল না।
বুঝেছেন কি না দেখুন
1async function-কে compiler state machine-এ রূপান্তর করে — সেই machine-এর
তিনটা অপরিহার্য অংশ কী কী? poll কে ডাকে, আর IO complete হওয়ার পরে future
কীভাবে আবার চলে আসে?
স্মরণ
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 কোড মৃত।
2JS snippet: setTimeout(f,0); Promise.resolve().then(g); console.log('x');
— কোন ক্রমে চলে? কেন?
প্রয়োগ
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-এর চেয়ে
“তাৎক্ষণিক” করে তোলে।
3Go-তে লাখো 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 লিখলেও দুই জগতে ব্যর্থতার ধরন আকাশ-পাতাল।
4Image-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