3.6
Event Loop ও Virtual Thread
Node.js আর Nginx কীভাবে অল্প thread-এ অনেক client সামলায়, কী loop আটকে দেয়, আর virtual thread-এর বিকল্প পথ।
40 মিনিট পড়া + Animation
টিপস: ডটেড-আন্ডারলাইন করা যেকোনো শব্দে ট্যাপ বা Hover করলে তার অর্থ দেখবে।
এই Lesson শেষে তুমি পারবে
- Event Loop কী, তা বলতে; আর বোঝাতে, এক Thread কীভাবে হাজারো Connection সামলায়, যতক্ষণ সে অপেক্ষা করে না আর কোনো কাজে বেশিক্ষণ আটকে থাকে না।
- Node.js Process-এর কোন কাজ Main Thread-এ চলে, কোনটা libuv-এর Worker Pool-এ আর কোনটা Kernel-এ, তা বলতে; আর ভরে যাওয়া Pool চিনতে।
- Node-এর ছয়টা Phase ধরে এগোতে, আর
process.nextTick, Promise,setTimeoutওsetImmediateমেশানো Code-এর Output আগে থেকে বলতে। - Loop কীসে আটকায় তা চিনতে, Event Loop Lag দিয়ে সেটা মাপতে, আর Worker Thread, টুকরো করা বা আলাদা Service দিয়ে সারাতে।
- Blocking, Non-blocking আর Asynchronous আলাদা করতে।
- Nginx আর Redis আসলে Event Loop কীভাবে ব্যবহার করে, আর “Single-threaded” তকমাটা দুটোর ক্ষেত্রে কোথায় ভুল বলে, তা বোঝাতে।
- Green Thread, Goroutine আর Java Virtual Thread কী, Pinning কী, আর এরা কীভাবে ভেঙে পড়ে, তা বোঝাতে।
- একটা Workload-এর জন্য Event Loop, Thread Pool আর Virtual Thread-এর মধ্যে বেছে নিতে, আর Interview-তে সেই সিদ্ধান্তের পক্ষে যুক্তি দিতে।
#এই Lesson কোথায় বসে
Lesson ১.২-এ দেখেছি একটা Server কত উপায়ে অনেক Client সামলাতে পারে, আর Lesson ১.১৩-এ দেখেছি Nginx-এর Master আর Worker। Lesson ২.৫ আর Lesson ২.৬ দেখিয়েছে, দীর্ঘস্থায়ী Connection-এর জন্য কেন একেকটা Thread-এর চেয়ে সস্তা কিছু দরকার। এই Chapter-এই Lesson ৩.১-এ Thread কী তা জেনেছি, Lesson ৩.২-এ Context Switch-এর দাম দেখেছি, Lesson ৩.৩-এ Thread Pool, আর Lesson ৩.৫-এ দেখেছি epoll কীভাবে একটা Program-কে জানায় কোন Socket Ready।
এই Lesson দাঁড়িয়ে আছে এই সবকিছুর ওপর। epoll শুধু বলে “কী Ready?”। তার ওপরে যে Program-এর কাঠামোটা বসে, সেটাই Event Loop: কী চলবে, কোন ক্রমে চলবে, আর কিছু বেশি সময় নিলে কী হবে। তারপর একই সমস্যার অন্য উত্তরটা দেখব, Virtual Thread, আর শিখব কীভাবে বেছে নিতে হয়। Code-এ Race আর Lock আসবে Chapter 14-এ।
#Event Loop কী
Event Loop এমন একটা Programming Model, যেখানে একটা Loop একটার পর একটা Event নিতে থাকে, আর প্রতিটার জন্য Callback বা Task চালায়। JavaScript আর Node.js, Python-এর asyncio, আর আরও অনেক Event-driven Runtime এর ওপরই তৈরি। নোটে ছবিটা এমন:
Event created
|
v
Event queue
|
v
Event loop
|
v
Execute callback
|
v
Wait for events
|
+---- repeat
Code-এ ধারণাটা শুধু এটুকুই:
while there is still work pending:
events = ask the kernel what is ready, sleeping if nothing is
for each event:
run its callback to completion
এখান থেকে দুটো নিয়ম বেরিয়ে আসে, আর এই Lesson-এর বাকিটা আসলে এই দুটো নিয়েই:
১. অপেক্ষার খরচ প্রায় নেই। Data-র অপেক্ষায় থাকা একটা Connection হলো Kernel-এ একটা ছোট হিসাব আর তোমার Program-এ একটা ছোট Object, কোনো Thread না। তাই এক Thread-ই হাজার হাজার চুপচাপ Connection ধরে রাখতে পারে।
২. কাজ করা মানে সবাইকে আটকানো। প্রতিটা Callback শেষ পর্যন্ত চলে, মাঝখানে কেউ তাকে থামাতে পারে না। একটা চলার সময় ওই Loop-এর বাকি সব Connection অপেক্ষা করে।
তাই Event Loop দেয় Concurrency, Parallelism না: অনেক কাজ চলমান, কিন্তু একই সময়ে চলে একটাই (Lesson ৩.২)। এটা মানায় I/O-bound কাজে, যেখানে একটা Request-এর বেশিরভাগ সময় কাটে অপেক্ষায়। CPU-bound কাজে একে মানায় না, কারণ সেখানে অপেক্ষার কিছু নেই, কাজটা করেই শেষ করতে হয়।
#সবচেয়ে ছোট Node.js Server
নোটের উদাহরণটা একটা পুরো Server:
const http = require("http");
const server = http.createServer((req, res) => {
res.end("Hello World");
});
server.listen(3000);
এই Code-এ কোনো Loop নেই, কোনো Thread-ও তৈরি হয়নি। server.listen একটা Socket নিবন্ধন করে, আর Script শেষ হয়ে যায়, কিন্তু Process বন্ধ হয় না, কারণ Runtime-এর Event Loop-এর হাতে এখন অপেক্ষা করার মতো কিছু আছে। তোমার Arrow Function চলে পরে, প্রতি Request-এ একবার করে।
#“Single-threaded” কথাটা অর্ধেক সত্যি
তোমার JavaScript সাধারণত চলে একটা Main Thread-এ। তাই বলে Node.js একেবারে Single-threaded System না। একটা চলন্ত Node Process-এ কয়েকটা Thread থাকে, আর কাজ করে তিন পক্ষ:
| কাজ | কে করে | Main Thread কি অপেক্ষা করে? |
|---|---|---|
তোমার JavaScript: Handler, Callback, JSON.parse | Main Thread | এটাই তো Main Thread-এর কাজ |
| Network Socket (HTTP, Database Driver, Redis) | Kernel, খবর দেয় epoll, kqueue বা IOCP দিয়ে | না |
File System Call (fs.*) | libuv-এর Worker Pool | না |
dns.lookup, ডিফল্টভাবে http.get যেটা ব্যবহার করে | Worker Pool | না |
Async crypto.pbkdf2, crypto.scrypt, crypto.randomBytes | Worker Pool | না |
Async zlib Compression | Worker Pool | না |
| Garbage Collection (আংশিক) আর Code Compile করা | V8 Engine-এর ভেতরের সহায়ক Thread | বেশিরভাগ সময় না, তবে কিছু GC বিরতিতে Main Thread থামে |
libuv হলো Node.js-এর নিচের C Library, যা Event Loop আর Worker Pool দেয়। Pool-এ ডিফল্টে ৪টা Thread থাকে, Process চালুর সময় UV_THREADPOOL_SIZE Environment Variable দিয়ে বদলানো যায়। Pool দরকার কেন? সাধারণ File-এর কাজের কোনো Readiness সংকেত নেই: সাধারণ File সবসময় “Ready” বলে (Lesson ৩.৫), অথচ Read তবু Disk-এ আটকে যেতে পারে। তাই libuv Pool-এর Thread-এ একটা সাধারণ Blocking Call চালায়, আর শেষ হলে খবর দেয়। getaddrinfo-র Non-blocking সংস্করণ নেই, তাই সেখানেও একই কৌশল।
এই Table-এর সবচেয়ে কাজের কথা: Network Socket-এ Pool ব্যবহার হয় না। দশ হাজার HTTP Connection একটাও Pool Thread নেয় না। কিন্তু চারটা ধীর File Read, বা আটকে যাওয়া চারটা Name Lookup, পুরো Pool ভরে ফেলে।
JavaScript-এর Thread একটা, কিন্তু সব কাজ একটা Thread করে না
একটা Network Request, একটা File পড়া, আর Pool-এর Thread ফুরিয়ে যাওয়ার দুটো ঘটনা ধরে এগোও। প্রতিবার দেখো, আসলে অপেক্ষাটা করছে কে।
পরিস্থিতি
নোটের http.createServer Server। Socket মানে Network I/O, তাই এখানে Pool-এর কোনো Thread লাগেই না।
server.listen(3000) Listening Socket-টা Kernel-এর কাছে নিবন্ধন করে। তারপর Script শেষ, কিন্তু Process বেঁচে থাকে, কারণ খোলা Server মানে Loop-এর হাতে এখনো কাজ বাকি।
#Node-এর Event Loop, Phase ধরে ধরে
নোটে ছয়টা Phase আছে, আর প্রতিটার নিজের Callback Queue আছে:
| # | Phase | কী চালায় | তোমার কাজে লাগে? |
|---|---|---|---|
| ১ | Timers | মেয়াদ ফুরোনো setTimeout আর setInterval-এর Callback | হ্যাঁ |
| ২ | Pending Callbacks | আগের চক্কর থেকে পিছিয়ে দেওয়া কিছু System Callback, যেমন কিছু TCP Error | কালেভদ্রে |
| ৩ | Idle, Prepare | Node-এর ভেতরের হিসাবনিকাশ | না |
| ৪ | Poll | Kernel-কে জিজ্ঞেস করে কী Ready, আর I/O Callback চালায়। কাজ না থাকলে এখানেই ঘুমায় | Server-এর জীবনের বেশিরভাগ সময় |
| ৫ | Check | setImmediate-এর Callback | হ্যাঁ |
| ৬ | Close Callbacks | close Event, যেমন socket.on("close", …) | মাঝেমধ্যে |
তিনটা জিনিস মনে রাখার মতো:
- Poll কতক্ষণ ঘুমায়। কোনো
setImmediateঅপেক্ষায় থাকলে Poll ঘুমায়ই না। নইলে পরের Timer-এর সময় পর্যন্ত ঘুমায়, আর Timer না থাকলে যতক্ষণ না কোনো Event আসে। এজন্যই অলস Node Process-এ প্রায় CPU লাগে না। - Timer-এর সময় মানে সর্বনিম্ন সময়।
setTimeout(fn, 100)মানে “১০০ ms-এর আগে না”। ওই মুহূর্তে কোনো Callback চললে Timer তার জন্য অপেক্ষা করে। - Process কখন শেষ হয়। প্রতি চক্করের পরে Loop দেখে, কোনো জীবন্ত Handle (Listening Server, খোলা Socket, Timer) বা বাকি Request আছে কি না। না থাকলে Process নিজে থেকেই শেষ।
timer.unref()Loop-কে বলে, এই Timer-টাকে হিসাবে ধরো না।
ছবিগুলোতে সাধারণত Timer আগে দেখানো হয়। libuv 1.45 (Node.js 20) থেকে Timer চলে শুধু Poll-এর পরে, আগের মতো Poll-এর আগে-পরে দুবার না; Node.js-এর Documentation-এ এটা লেখা আছে। চক্কর একই থাকে, তাই নিচের ক্রমের সব নিয়ম ঠিক থাকে, শুধু প্রথম চক্করটা পুরনো বর্ণনার চেয়ে সামান্য আলাদা।
Node.js Event Loop: ছয়টা Phase-এ এক চক্কর
প্রতিটা Phase-এর নিজের Callback Queue আছে। একটা স্বাভাবিক চক্কর, কাজহীন Loop, দেরিতে চলা Timer আর Process শেষ হওয়ার মুহূর্তটা ধাপে ধাপে দেখো।
পরিস্থিতি
প্রতিটা Callback-এর মাঝে Loop Microtask Queue-ও খালি করে; পরের Animation-এ সেটা দেখবে।
এই Phase-এ চলে
| Phase | Callback |
|---|---|
| timers | যেসব setTimeout আর setInterval-এর সময় হয়ে গেছে, তাদের Callback |
Loop ঘড়ি দেখে, মেয়াদ ফুরোনো সব Timer চালায়। Timer-এর সময় মানে সর্বনিম্ন সময়, কোনো নিশ্চয়তা না।
#Microtask, আর এরপর কে চলবে
Phase-গুলোর বাইরে Node-এর আরও দুটো Queue আছে, আর ক্রমের ধাঁধাগুলো মজার হয় এদের জন্যই:
process.nextTickQueue, এটা Node-এর নিজের।- Promise Microtask Queue, এটা JavaScript Engine-এর:
.thenCallback,await-এর পরের Code, আরqueueMicrotask।
নোটে দুটোকে একসাথে বলা হয়েছে, “আলাদা Scheduling Behaviour” বলে। ঠিক নিয়মটা এই:
১. চলতি Synchronous Code শেষ পর্যন্ত চালাও।
২. nextTick Queue পুরো খালি করো।
৩. Promise Microtask Queue পুরো খালি করো। কোনো Microtask যদি নতুন Tick যোগ করে, আবার ২ নম্বরে ফেরো।
৪. এবার পরের Callback চালাও, এই Phase-এর হোক বা পরের Phase-এর।
Node.js 11 থেকে এই যাচাই হয় প্রতিটা Callback-এর পরে, শুধু দুই Phase-এর মাঝে না। Browser-ও তা-ই করে।
#নোটের উদাহরণ
console.log("Start");
setTimeout(() => {
console.log("Timer");
}, 0);
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("End");
Start
End
Promise
Timer
আগে Synchronous Code, তাই Start আর End। তারপর Microtask হিসেবে Promise। শেষে Timer, কারণ তার জন্য Loop-কে একটা Phase-এ পৌঁছাতে হয়। Delay ০ মানে “এখনই” না: Node একে ১ ms করে নেয়, আর যা-ই হোক, Stack খালি হওয়া পর্যন্ত তাকে অপেক্ষা করতেই হয়।
এরপর কে চলবে? Call Stack, Microtask আর Loop-এর Queue
প্রতিটা Scenario-তে সত্যিকারের Code চলছে। Next চাপার আগে Output আন্দাজ করো। এখানকার সব ক্রম Node.js 24-এ চালিয়ে মেলানো।
পরিস্থিতি
নোটের উদাহরণ: ০ ms-এর Timer-ও কেন Promise-এর কাছে হেরে যায়।
console.log("Start");setTimeout(() => {console.log("Timer");}, 0);Promise.resolve().then(() => {console.log("Promise");});console.log("End");
Call Stack
এখন চলছে
nextTick Queue
আগে খালি হয়
Promise Microtask
এরপর খালি হয়
Timers Phase
Poll Phase
Check Phase
Console Output
পুরো Script Call Stack-এ একটাই Synchronous কাজ। Start সাথে সাথে Print হয়।
#process.nextTick Promise-এর আগে চলে, সাধারণত
ওই Script-এ process.nextTick(() => console.log("Tick")) যোগ করো। node order.js দিয়ে চালালে সাধারণ Script-এ এটা End আর Promise-এর মাঝখানে Print হয়, Promise আগে Schedule হলেও। তার মানে দুটো এক Queue না; নোটের সংশোধন এটাই।
একটা ফাঁদ আছে। File-এর নাম order.mjs করো, এবার Tick-এর আগে Promise Print হবে। JavaScript Module-এর Top-level Code নিজেই Microtask Queue-র ভেতর থেকে চলে, তাই Node তার Tick Queue দেখার আগেই ওই Queue খালি হয়ে যায়। দুটো ফলই ঠিক। এই ক্রমের ওপর দাঁড়ানো Code ভঙ্গুর, তাই এমন Code লিখো না। “একটু পরে” বোঝাতে queueMicrotask ব্যবহার করো, Node.js-এর Documentation-ও তা-ই বলে।
#setImmediate বনাম setTimeout(0)
| কোথায় ডাকছ | কোনটা আগে চলে | কেন |
|---|---|---|
| Main Script-এ | যেকোনোটা | ০ আসলে ১ ms। Loop শুরুর সময় সেটা পেরিয়ে গেলে Timer আগে, না পেরোলে Immediate। Machine-এর চাপের ওপর নির্ভর করে |
| I/O Callback-এর ভেতরে | সবসময় setImmediate | তুমি এখন Poll-এ, পরের Phase Check। Timer আসবে পরের চক্করে |
“এই দফার I/O শেষ হলে” বোঝাতে, আর লম্বা কাজ ভাঙতে setImmediate ব্যবহার করো, কারণ এতে মাঝখানে I/O চলতে পারে। নিজেকে বারবার ডাকা nextTick বা Promise-এর শিকল Loop-কে না খাইয়ে রাখে: অন্য কিছু চলার আগে এদের Queue খালি হতে হয়, আর যে Queue নিজেকে ভরতে থাকে, সে যতক্ষণ চলে ততক্ষণ Timer আর I/O আটকে থাকে।
#await কোনো Thread না
await কিছু Block করে না, কোনো Thread-ও চালু করে না। Function-এর অবস্থা সেভ করে, চলতি কাজ শেষ করে, আর বাকিটা পরের জন্য Schedule করে। দুই await-এর মাঝের সবকিছু একটানা চলে। এর দুটো ফল মানুষকে প্রায়ই বিপদে ফেলে:
- CPU-র কাজে ভরা
asyncFunction অন্য যেকোনো Function-এর মতোই Loop আটকায়।asyncকাজকে কোথাও সরায় না। - Thread নেই বলে এক Callback-এর ভেতরে Data Race হয় না, কিন্তু
await-এর দুই পাশে Logic Race তবু হতে পারে:const x = await read(); await write(x + 1);এই দুই লাইনের ফাঁকে আরেকটা Request ঢুকে পড়লে একটা Update হারিয়ে যেতে পারে। Chapter 14-এ এটা আসবে।
নিজেকে যাচাই করোপ্রশ্ন
একটা সাধারণ .js File-এ Script পরপর ডাকে setTimeout(a, 0), তারপর Promise.resolve().then(b), তারপর process.nextTick(c), তারপর console.log(d)। কোন ক্রমে চলবে?
#কীসে Loop আটকায়
নোটের উদাহরণটা চরম ঘটনা:
while (true) {
// infinite loop
}
অন্য Request সামলানো হয় না, Timer চলে না, Callback আটকে থাকে। একেই বলে Event Loop Block করা। বাস্তবের Incident খুব কমই while (true) হয়। বেশিরভাগ দেখতে এমন:
| কারণ | সাধারণ খরচ | মানুষ কেন অবাক হয় |
|---|---|---|
বড় Document-এ JSON.parse বা JSON.stringify | কয়েক দশ MB-তে মোটামুটি ১০০ ms বা বেশি | দেখতে সামান্য একটা Call। Client যত বড় পাঠায়, খরচ তত বাড়ে |
| ভয়ংকর Regular Expression | কয়েক সেকেন্ড বা বেশি | Test-এ ঠিক থাকে, আর আক্রমণকারী এটা চালু করতে পারে: ReDoS |
Synchronous API: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync | Disk বা CPU-র সময় | Startup-এ ক্ষতি নেই, Handler-এ সর্বনাশ |
| Main Thread-এ Password Hashing | ইচ্ছে করেই প্রায় ১০০ ms | Async সংস্করণ আছে, আর সেটা Pool ব্যবহার করে |
| বিশাল Array Sort বা Loop, বড় Template, Server-side Rendering | কয়েক দশ থেকে কয়েকশো ms | অল্প Data-তে চলে, Data বাড়লে ধীর হয় |
| লম্বা Garbage Collection বিরতি | কয়েক দশ ms বা বেশি | বড় Heap-এ বেশি হয়, আর তোমার কোনো লাইনের দোষ নেই |
Loop Block করা: একটা খারাপ Callback-এর দাম দেয় সব Request
ওপরের Lane হলো সেই Thread, যেটা JavaScript চালায়। নিচের Lane-গুলো Request: ধূসর মানে ওই Thread-এর জন্য অপেক্ষা। সুস্থ Loop, ধীর Callback, অসীম Loop আর সমাধানগুলো মিলিয়ে দেখো।
পরিস্থিতি
প্রতিটা Request-এ Thread-এর মাত্র ~৫ ms লাগে। কেউ কারো জন্য অপেক্ষা করে না।
Thread বেশিরভাগ সময় Poll-এ ঘুমন্ত। Socket আর Database-এর অপেক্ষায় তার কোনো খরচ নেই।
#এক Thread-এর হিসাব
Event Loop-এর ক্ষমতার নিয়ম খুব সহজ। প্রতিটা Request-এ যদি Main Thread-এর C Millisecond লাগে, তাহলে এক Loop সেকেন্ডে সর্বোচ্চ 1000 / C Request করতে পারে:
| প্রতি Request-এ Main Thread-এর সময় | প্রতি Loop-এ সর্বোচ্চ | মানে কী |
|---|---|---|
| ০.২ ms | ৫,০০০ req/s | পাতলা Proxy বা ছোট JSON API |
| ২ ms | ৫০০ req/s | Serialization-সহ সাধারণ Business Logic |
| ২০ ms | ৫০ req/s | ভারী Rendering; ১৬ Core-এর Machine-এও ১৬টা Process লাগবে |
Database-এর অপেক্ষার সময় C-তে ধরা হয় না। পুরো লাভটাই এখানে। আর সীমার কাছে গেলে Latency ধীরে বাড়ে না, লাফিয়ে বাড়ে, কারণ নতুন Request একে অন্যের পেছনে Queue-তে দাঁড়ায়। তাই Loop-কে সীমার অনেক নিচে রাখো, Peak-এ মোটামুটি ৬০–৭০% ব্যস্ত, যাতে হঠাৎ চাপ সামলানোর জায়গা থাকে।
Block হওয়া Callback সবার Latency-ও বাড়ায়: সেকেন্ডে ১,০০০ Request আসছে, আর একটা Callback ৪০০ ms Thread ধরে রাখলে, ৪০০টা Request জমে যায়, পরে সেগুলো শেষ করতে হয়।
#কীভাবে সারাবে
| সমাধান | কীভাবে | কোথায় | দাম |
|---|---|---|---|
| Async API ব্যবহার | fs.promises, crypto.pbkdf2, Non-blocking Driver | Async সংস্করণ আছে এমন সবকিছু | Pool-এর জন্য লড়তে হয় |
| Worker Thread | node:worker_threads, প্রায়ই Piscina-র মতো Pool Library দিয়ে | যে CPU-র কাজের উত্তর একই Request-এ দিতে হয়: ছবি ছোট করা, বড় Parse | Thread-এর মধ্যে Data Copy হয়; প্রতি Worker-এর নিজের Engine, Memory লাগে |
| টুকরো করে ছাড় দেওয়া | অল্প একটু কাজ, তারপর await new Promise(setImmediate) | তোমার নিজের লম্বা Loop | এখনো এক Core; কাজ বেশি সময় নেয়; Library-র একটা বড় Call-এ অকেজো |
| আলাদা Service বা Queue | Job Queue (Chapter 30–32) বা আলাদা Service | Video, Report, Machine Learning, যা সেকেন্ডে মাপা হয় | জিনিসপত্র বাড়ে; উত্তরও Asynchronous হয়ে যায় |
| Input সীমিত করা | Body-র আকারের সীমা, Streaming Parser, Backtracking ছাড়া Regular Expression Engine | Client যা নিয়ন্ত্রণ করে | কিছু Request ফিরিয়ে দিতে হয় |
| Process বাড়ানো | cluster, PM2, বেশি Container | সব Core ব্যবহার করতে | একটা Request-এর Block ছোট হয় না, শুধু ক্ষতিটা ওই Process-এ সীমিত থাকে |
Worker Thread আর Process দুটো আলাদা হাতিয়ার। cluster ক্ষমতা বাড়ায়, প্রতি Core-এ একটা করে Loop চালিয়ে। Worker Thread Loop-কে বাঁচায় ভারী কাজ থেকে। সুস্থ একটা Node Service প্রায়ই দুটোই ব্যবহার করে।
#Event Loop Lag: যে Metric-এ চোখ রাখবে
Loop সুস্থ কি না, CPU দেখে বোঝা যায় না। ১৬ Core-এর Machine-এ আটকে থাকা এক Thread মানে মোট CPU-র প্রায় ৬%। সরাসরি মাপকাঠি হলো Event Loop Lag, যাকে Event Loop Delay-ও বলে: একটা Timer নির্ধারিত সময়ের চেয়ে কত দেরিতে চলল। প্রতি ২০ ms-এ চলার কথা এমন Timer যদি ৩২০ ms পরে চলে, তাহলে Loop প্রায় ৩০০ ms ব্যস্ত ছিল, আর তখন আসা যেকোনো Request-ও ততক্ষণই অপেক্ষা করেছে।
Node-এ এটা আগে থেকেই আছে:
const { monitorEventLoopDelay, performance } = require("node:perf_hooks");
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let last = performance.eventLoopUtilization();
setInterval(() => {
const now = performance.eventLoopUtilization();
const busy = performance.eventLoopUtilization(now, last).utilization;
last = now;
console.log(
`lag p99 ${(delay.percentile(99) / 1e6).toFixed(1)} ms, ` +
`busy ${(busy * 100).toFixed(0)}%`,
);
delay.reset();
}, 1000);
দুটো সংখ্যা একসাথে পড়ো:
- Lag (Histogram-এ Nanosecond-এ) বলে একটা Callback কতক্ষণ অপেক্ষা করেছে। অলস Process-এও এটা শূন্য না, Timer Resolution-এর কাছাকাছি থাকে, তাই ওই ভিত্তির ওপরে বাড়ছে কি না দেখো। ভিত্তির ওপরে টানা কয়েক দশ ms-এর p99 সতর্কতা, আর কয়েকশো ms মানে Incident; ঠিক সীমাটা তোমার Latency লক্ষ্যের ওপর নির্ভর করে।
- Event Loop Utilization হলো Loop-এর কতটা সময় কাজে গেছে, Poll-এ ঘুমিয়ে না। Saturation আগে থেকে বোঝায় CPU-র চেয়ে ভালো, কারণ CPU-তে GC আর অন্য Thread-ও ধরা থাকে।
- Utilization কম, কিন্তু মাঝে মাঝে Lag লাফিয়ে ওঠে: একটা খারাপ Callback। Utilization বেশি, Lag বাড়ছে: চাপ বেশি, Process বাড়াও।
তারপর দোষীকে খোঁজো CPU Profile দিয়ে (node --cpu-prof, বা তোমার APM-এর Flame Graph)। Production-এ Lag-কে Metric হিসেবে পাঠাও, তাতে Alert দাও, আর Lag বেশি হলে চাপ কমানোর কথা ভাবো: সবাইকে অপেক্ষায় রাখার চেয়ে তাড়াতাড়ি 503 ফেরত দেওয়া ভালো।
#Blocking, Non-blocking আর Asynchronous
এই তিনটা শব্দ এক জিনিস না। নোটের Table, একটু গুছিয়ে:
| Blocking | Non-blocking | Asynchronous | |
|---|---|---|---|
| যে ডাকে সে কী দেখে | ফলাফল তৈরি হওয়া পর্যন্ত অপেক্ষা করে | সাথে সাথে ফেরে, হয়তো “এখনো হয়নি, পরে দেখো” (EAGAIN) নিয়ে | কাজ শুরু করে, শেষ হলে খবর পায় |
| Thread | ঘুমায় | চলতে থাকে | Implementation-এর ওপর নির্ভর করে |
| কে জানতে পারে | যে ডেকেছে, জেগে উঠে | যে ডেকেছে, আবার জিজ্ঞেস করে বা Readiness API দিয়ে | কেউ একজন খবর দেয়: Callback, Promise, Future |
| Code-এর ধরন | সহজ, পরপর | তুলনায় জটিল | Callback, Future, Promise, await |
| কোথায় ব্যবহার | সহজ I/O, Script, Thread Pool | High-concurrency I/O, Event Loop-এর নিচে | আধুনিক Application Code |
Non-blocking আর Asynchronous এক জিনিস না। Non-blocking Call সাথে সাথে ফেরে, কাজ হোক বা না হোক। Asynchronous Operation শেষ হওয়ার খবর দেয় পরে। Node-এর fs.readFile তোমার Code-এর চোখে Asynchronous, অথচ ভেতরে সেটা Pool Thread-এ একটা Blocking Call। Node-এ Socket নিচে Non-blocking, আর তোমার কাছে Asynchronous। তাই “Asynchronous” বলে তোমার Code কেমন Interface দেখছে, আর “Non-blocking” বলে একটা System Call-এর কথা। Kernel-এর দিকটা আছে Lesson ৩.৫-এ।
#Event Loop কোথায় ভালো, কোথায় খারাপ
ভালো:
- অনেক Concurrent Connection, যেগুলো বেশিরভাগ চুপচাপ: Chat, WebSocket, API Gateway, Proxy, Streaming।
- প্রতি Connection-এ Memory কম, কয়েক Kilobyte, তাই অনেক Connection রাখা সাধ্যের মধ্যে।
- Resource-এর ভালো ব্যবহার, কারণ একটা Request I/O-র অপেক্ষায় থাকলে Thread অন্যদের সেবা দেয়।
- তোমার Data ছোঁয় একটাই Thread, তাই এক Process-এর ভেতরে Memory-র জিনিসে Lock লাগে না।
খারাপ:
- CPU-ভারী কাজ, ওপরে যেমন দেখলে।
- সবাইকে সহযোগিতা করতে হয়। একটা Library Block করলে ওই Process-এর সব Request-এর ক্ষতি, তাই বিচ্ছিন্নতা কম।
- একটা Uncaught Error পুরো Process আর তার সব Connection ফেলে দিতে পারে।
- Callback-এর ধরনে এক কাজ কয়েকটা Function-এ ভাগ হয়ে যায়।
async/awaitএর বেশিরভাগ ঢেকে দেয়, তবু Stack Trace আর Context বয়ে নেওয়া Blocking Code-এর চেয়ে কঠিন।
#asyncio: Python-এ একই ধারণা
Python-এর asyncio একইভাবে কাজ করে: এক Thread, এক Loop, Coroutine যারা প্রতি await-এ Thread ফিরিয়ে দেয়। Linux-এ নিচে epoll, আর uvloop বাড়তি গতির জন্য libuv-কেই বসিয়ে দেয়। যে নিয়মে বেশিরভাগ মানুষ আটকায়:
import asyncio
import time
async def bad(name):
time.sleep(1) # blocks the thread, and the loop with it
print(name)
async def good(name):
await asyncio.sleep(1) # gives the thread back while waiting
print(name)
async def main():
start = time.perf_counter()
await asyncio.gather(good("a"), good("b"), good("c"))
print(f"{time.perf_counter() - start:.1f} s") # about 1.0, with bad() about 3.0
asyncio.run(main())
তাই async def-এর ভেতরে লুকানো Blocking Call, যেমন requests.get বা Synchronous Database Driver, সব Coroutine-কে থামিয়ে দেয়। Async Library ব্যবহার করো, নয়তো await asyncio.to_thread(func) দিয়ে Call-টা সহায়ক Thread-এ পাঠাও। Development-এ asyncio.run(main(), debug=True) দিয়ে চালালে asyncio ১০০ ms-এর বেশি সময় নেওয়া Callback Log করে। Python-এ CPU-র কাজে Thread আরও আটকায় GIL-এর কারণে (Lesson ৩.২), তাই সাধারণ উত্তর হলো Process Pool।
#আরও Event Loop: Nginx আর Redis
নোটে Node.js, Nginx আর Redis-কে একসাথে “Single-threaded Architecture” বলা হয়েছে। তিনটাই Event Loop ব্যবহার করে, কিন্তু গড়ন আলাদা, আর Design-এ এই পার্থক্য গুরুত্বপূর্ণ।
#Nginx: প্রতি Worker Process-এ একটা Loop
Nginx একটা Event Loop না। এটা প্রতি Worker Process-এ একটা Event Loop, সাধারণত প্রতি Core-এ একটা Worker (worker_processes auto;)। Master আর Configuration Lesson ১.১৩-এ দেখেছি; এখানে আরও তিনটা কথা:
- একটা Connection সারাজীবন এক Worker-এর। Request-এর কোনো সাধারণ Queue নেই, তাই Worker-দের Lock প্রায় লাগে না, আর Core বাড়লে ক্ষমতাও প্রায় সমানুপাতে বাড়ে।
- Nginx কোনো Application Code চালায় না, শুধু দ্রুত Parse, Route আর Copy করে, তাই তার Callback স্বভাবতই ছোট। ধীর Upstream মানে শুধু একটা Socket
epoll-এ একটু বেশিক্ষণ থাকা। - দুর্বল জায়গা Disk। Page Cache-এ নেই এমন File পড়তে গেলে পুরো Worker Block হয়।
aio threads;এই Read-গুলো একটা Thread Pool-এ পাঠায়। অন্য Product-এ হুবহু libuv-এর কৌশল।
Nginx: প্রতি Worker Process-এ একটা Event Loop
পুরো Server-এ একটা Loop না: প্রতি Worker-এ একটা, আর প্রত্যেকে তার Connection-এর দায়িত্ব শুরু থেকে শেষ পর্যন্ত নেয়।
পরিস্থিতি
Lesson ১.১৩-এ Master আর Worker দেখেছি। Loop-এর দিক থেকে এর মানে কী, সেটা এখানে।
Kernel TCP Handshake শেষ করে Connection-টা এমন Listening Socket-এ রাখে, যা সব Worker Master-এর কাছ থেকে পেয়েছে।
#Redis: Command চালায় এক Thread
“Redis Single-threaded” কথাটা খাটে Command চালানোয়, আর কোথাও না:
| কাজ | Thread |
|---|---|
| Command চালানো আর Data ছোঁয়া | এক Main Thread |
| Socket পড়া-লেখা, Parse | Main Thread, বা Redis 6.0 থেকে ঐচ্ছিক I/O Thread (io-threads) |
বড় Value ছাড়া (UNLINK), Append-only File Flush, File বন্ধ করা | Background Thread |
| Snapshot আর Log Rewrite | Fork করা Child Process (Copy-on-write, Lesson ৩.৪) |
এক Execution Thread কেন? Command সব Memory-তে আর খুব দ্রুত, তাই CPU কমই বাধা হয়; Lock নেওয়ার কিছু নেই, আর প্রতিটা Command বিনা চেষ্টায় Atomic। সাধারণ Operation এক Core-এ প্রতি Instance-এ সেকেন্ডে প্রায় ১ লাখের ঘরে পৌঁছায়। বেশি Core ব্যবহার করতে হলে বেশি Instance চালাও, আর Redis Cluster দিয়ে Key ভাগ করো।
Event Loop-এর নিয়ম এখানে হুবহু খাটে: একটা ধীর Command সব Client-কে আটকায়। KEYS *, বিশাল Key-তে DEL, বড় SMEMBERS, বা লম্বা Lua Script, সবই সবকিছু থামিয়ে দেয়। তাই SCAN, UNLINK আর ছোট Batch ব্যবহার করো, আর SLOWLOG দেখো। Cache হিসেবে Redis আসবে Chapter 23-এ।
Redis: সব Command চালায় একটাই Thread
এই এক Thread-এর কারণে Command সহজেই Atomic হয়, আবার একটা ধীর Command হয়ে যায় সবার সমস্যা।
পরিস্থিতি
দুই Client একই মুহূর্তে একই Counter বাড়ায়।
Client A-এর Command এল। Event Loop দেখে Socket Ready, আর Command চালায়।
#অন্য উত্তর: সস্তা Thread
Event Loop অপেক্ষার দাম মেটায়, কিন্তু দাম নেয় Code-এ: Callback বা async/await, আর নিয়ম যে কিছুই Block করা চলবে না। আরেকটা পথ আছে। Thread দামি Kernel যেভাবে বানায় তার জন্য, তাহলে Runtime নিজে বানালে কেমন হয়?
এই মাত্রায় OS Thread কেন এত দামি (Lesson ৩.১, Lesson ৩.৫):
- প্রতিটার বড় একটা Stack সংরক্ষিত থাকে (৬৪-bit Linux-এ Java Thread-এর সাধারণত ১ MB Address Space, যার ছোঁয়া অংশটা Memory-তে থেকেই যায়)।
- তৈরি করতে একটা System Call লাগে, মোটামুটি কয়েক দশ Microsecond।
- এদের মধ্যে Switch হয় Kernel Scheduler দিয়ে, আর CPU Cache নষ্ট হয়।
Green Thread, মানে User-level Thread, চালায় ভাষার Runtime, Kernel না। অনেকগুলো চলে অল্প কয়েকটা OS Thread-এর ওপর, একে বলে M:N Scheduling। ধারণাটা পুরনো: একদম শুরুর Java-তে Green Thread ছিল, পরে বাদ দেওয়া হয়, কারণ সবগুলো চলত এক OS Thread-এ, আর কয়েকটা CPU ব্যবহার করতে পারত না। এখন যা বদলেছে: আধুনিক সংস্করণ M:N Scheduling-এর সাথে Runtime-এর ভেতরে একটা Non-blocking I/O স্তর জুড়ে দেয়।
আসল কথাটা এখানে: নিচে এই Runtime-গুলোও Event Loop। Goroutine বা Virtual Thread যখন Socket পড়ে, Runtime সেটা epoll-এ নিবন্ধন করে অন্য কিছু চালায়। পার্থক্য হলো থামা আর আবার চালু হওয়াটা কে লেখে। async/await-এ প্রতিটা থামার জায়গা তুমি নিজে চিহ্নিত করো। এখানে করে Runtime, তাই সাধারণ চেহারার Code-ই চলে।
এতে “রঙিন Function”-এর সমস্যাও যায়: JavaScript বা Python-এ async আর সাধারণ Function দুই রকম, সহজে মেশে না, আর গভীরে একটা Async Call ঢুকলে ওপরের সব Caller-কে বদলাতে হয়। Goroutine আর Virtual Thread-এ সব Function একই রঙের।
#Goroutine
Go-তে প্রতিটা go f() একটা Goroutine চালু করে। এই ভাষায় Concurrency-র মডেল এই একটাই:
- ছোট শুরু। Goroutine-এর Stack শুরু হয় প্রায় ২ KB দিয়ে, দরকারে Copy করে বাড়ে।
- M:N Scheduling। Runtime Goroutine-গুলো Thread-এ চালায়, একসাথে Go Code চালায়
GOMAXPROCS-সংখ্যক Thread। ডিফল্ট হলো CPU-র সংখ্যা, আর Go 1.25 থেকে Linux-এ ডিফল্টটা Container-এর CPU Limit-ও মানে; Throttling-এর একটা পুরনো কারণ এতে মিটেছে। - Work Stealing। প্রতি Logical Processor-এর নিজের Run Queue আছে, ফাঁকা জন ব্যস্ত জনের কাছ থেকে কাজ নিয়ে নেয়।
- Network I/O যায় Runtime-এর Poller (
epoll,kqueue, IOCP) দিয়ে, তাই অপেক্ষারত Goroutine কোনো Thread নেয় না। - Blocking System Call ঠিকই একটা Thread ধরে রাখে, তাই Runtime Processor-টা অন্য Thread-কে দিয়ে দেয়, দরকারে নতুন বানায়।
- Preemption। Go 1.14 থেকে লম্বা চলা Goroutine-কে প্রায় ১০ ms পরে Signal দিয়ে থামানো হয়, তাই টানা Loop একটা Thread দখল করে রাখতে পারে না। Event Loop-এর Callback-এর এই সুরক্ষা নেই।
এক Event Loop-এর সাথে পার্থক্য: এটা এক Program দিয়েই সব Core ব্যবহার করে। বদলে Goroutine সত্যিই Parallel-এ চলে, তাই Shared Data-তে Lock বা Channel লাগে, আর Data Race বাস্তব (Chapter 14)।
Goroutine: অল্প OS Thread-এ অনেক সস্তা কাজ
তুমি সাধারণ Blocking Code লেখো। Go Runtime Goroutine-কে সরিয়ে রেখে নিচের Thread আবার ব্যবহার করে।
পরিস্থিতি
দেখতে Blocking, এমন Network Read, যা কোনো Thread-কে Block করে না।
go func() {resp, err := http.Get(url)if err != nil { return }defer resp.Body.Close()results <- resp.StatusCode}()
Thread ১ (Go চালাচ্ছে)
Thread ২ (Go চালাচ্ছে)
Run Queue
তৈরি Goroutine
Network Poller
epoll
System Call-এ আটকে
সরিয়ে রাখা
Channel বা Lock
দুটো Thread দুটো Goroutine চালাচ্ছে; G3 আর G4 তৈরি, অপেক্ষায়। কয়টা Thread একসাথে Go Code চালাবে তা ঠিক করে GOMAXPROCS, সাধারণত CPU-র সংখ্যা।
#Java Virtual Thread
Virtual Thread পাকা হয়েছে Java 21-এ, JEP 444 দিয়ে (সেপ্টেম্বর ২০২৩), Java 19 আর 20-তে Preview-এর পরে। এই লেখার সময় Java 25 চলতি দীর্ঘমেয়াদি Support-এর Release, আর Java 21 এখনো ব্যাপকভাবে চলছে। এর লক্ষ্য: পুরনো Blocking Code রেখে দাও, শুধু অপেক্ষার সময় সেটা আর OS Thread আটকে রাখবে না:
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VT {
public static void main(String[] args) {
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
}
System.out.println("done in " + (System.currentTimeMillis() - start) + " ms");
}
}
এটা একসাথে ১ লাখটা এক সেকেন্ডের ঘুম চালায়, আর Laptop-এ শেষ হয় প্রায় ২ সেকেন্ডে। প্রতিটায় একটা করে OS Thread হলে বেশিরভাগ Machine সবগুলো চালুই করতে পারত না।
কীভাবে চলে:
- Virtual Thread Mount হয় একটা Carrier-এ, মানে ছোট একটা
ForkJoinPool-এর সাধারণ OS Thread-এ, ডিফল্টে প্রতি Core-এ একটা Carrier। - JDK-র এমন কোনো Call-এ Block হলে (Socket, Sleep, Lock, Queue), JVM তার Stack Heap-এ Copy করে, আর সে Unmount হয়। Carrier অন্য Virtual Thread তুলে নেয়।
- অপেক্ষা শেষ হলে সে আবার চলার উপযোগী হয়, আর হয়তো অন্য Carrier-এ Mount হয়।
Framework-গুলো এখন একটা Switch দিয়েই এটা চালু করতে দেয়, যেমন Spring Boot 3.2 ও পরের সংস্করণে spring.threads.virtual.enabled=true। এই Design থেকে চারটা নিয়ম আসে:
১. Virtual Thread-এর Pool বানিও না। Pool থাকে Thread দামি বলে; এগুলো দামি না। প্রতি Task-এ একটা বানাও।
২. বদলে দুর্লভ জিনিসটা সীমিত করো। ২০০ Thread-এর Pool কাকতালীয়ভাবে Database Call-এরও সীমা ছিল ২০০। Virtual Thread-এ তোমাকে Explicit Semaphore বা সীমিত Connection Pool দিতে হবে, নইলে ঢেউ সোজা Database-এ গিয়ে পড়বে।
৩. Pinning থেকে সাবধান। নিচে দেখো।
৪. ThreadLocal Cache-এ সাবধান। কয়েকশো Thread ধরে প্রতিটায় বড় Buffer রাখার Code ১০ লাখ Thread-এ বিপুল Memory খেতে পারে।
Pinning মানে Virtual Thread Unmount হতে পারছে না, তাই তার Carrier-ও Block:
| পরিস্থিতি | Java ২১–২৩ | JDK 24 ও পরে (JEP 491) |
|---|---|---|
synchronized-এর ভেতরে Block | Pinned | Pinned না |
ReentrantLock-এ Block | Pinned না | Pinned না |
| Native Code দিয়ে Call (বা Foreign Function) | Pinned | এখনো Pinned |
| File I/O-র অপেক্ষা | Carrier ধরে রাখে; Runtime হয়তো সাময়িক একটা বাড়ায় | একই |
Carrier অল্প বলে Pinning গুরুতর: সবগুলো Pin হলে সব Virtual Thread থামে, অথচ CPU ফাঁকা। Java 21-এ JFR Event jdk.VirtualThreadPinned দিয়ে এগুলো খুঁজে বের করো, আর Blocking Call-এর চারপাশে synchronized-এর বদলে ReentrantLock দাও, অথবা JDK 24+-এ যাও।
Java Virtual Thread: Mount, Unmount, Pin
Virtual Thread চলে Carrier Thread-এর একটা ছোট Pool-এ। অপেক্ষার সময় Carrier ছেড়ে দেয়, যদি না কিছু তাকে আটকে (Pin) রাখে।
পরিস্থিতি
সবসময় লেখা সেই একই Blocking Code, শুধু অপেক্ষার সময় Thread ছাড়া থাকে।
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {ex.submit(() -> {var body = httpGet(url); // waitssave(body);});}
Carrier ১
OS Thread
Carrier ২
OS Thread
চলার জন্য তৈরি
Heap-এ সরিয়ে রাখা
Stack Object হয়ে জমা
Database Connection
সীমা ৫০, আঁকা ৩টা
Permit-এর অপেক্ষায়
প্রতিটা Task পায় নতুন Virtual Thread; এদের Pool-এর মাপ ঠিক করতে হয় না। V1 আর V2 দুই Carrier-এ Mount করা। ডিফল্টে Carrier থাকে Core-এর সমান।
Virtual Thread অপেক্ষাকে সস্তা করে; হিসাবকে দ্রুত করে না। এদের মধ্যে আজ Time Slicing নেই, তাই CPU-bound Task রাখো Core-এর মাপের Fixed Pool-এ (Lesson ৩.৩)।
#আরও যারা আছে
| Runtime | Model | কথা |
|---|---|---|
| Rust (Tokio) | async/await, সাথে Multi-threaded Work-stealing Runtime | যে Task কখনো await করে না, সে একটা Worker আটকাতে পারে; Blocking Call-এর জন্য spawn_blocking |
| C# / .NET | async/await, Compile হয়ে Thread Pool-এ State Machine | Task-এ Block করলে (.Result) Pool উপবাসে পড়তে পারে, Synchronization Context থাকলে Deadlock-ও |
| Kotlin | Coroutine, suspend Function | রঙিন, তবে JVM-এর ওপর; Virtual Thread-এও চলতে পারে |
| Erlang, Elixir | হালকা Process, Preemptive Scheduler | করা কাজের হিসাবে থামানো হয়, তাই একটা ব্যস্ত Process অন্যদের আটকাতে পারে না; বিপুল Connection-এর জন্যই বানানো |
| Netty, Vert.x, Project Reactor | JVM-এ কয়েকটা Event Loop | High-performance Java-র নিচে; সাধারণ Service-এ Virtual Thread Reactive ধরনের প্রয়োজন কমায় |
| Envoy, HAProxy | প্রতি Worker Thread-এ একটা Event Loop | Nginx-এর মতোই গড়ন, শুধু Process-এর বদলে Thread |
#কীভাবে বেছে নেবে
| Thread Pool | Event Loop | Virtual Thread বা Goroutine | |
|---|---|---|---|
| অপেক্ষারত এক Request-এর খরচ | এক Thread: কয়েক দশ KB থেকে প্রায় ১ MB | কয়েক KB | কয়েক KB, দরকারে বাড়ে |
| এক Machine-এ বাস্তবসম্মত একসাথে অপেক্ষা | কয়েকশো থেকে কয়েক হাজার | কয়েক দশ হাজার বা লাখ | কয়েক লাখ থেকে ১০ লাখের ওপরে |
| CPU-ভারী কাজ | ভালো; সব Core ব্যবহার করে | খারাপ; Worker লাগে | ভালো; সব Core ব্যবহার করে (Thread-এর চেয়ে দ্রুত না) |
| সব Core ব্যবহার | নিজে থেকেই | প্রতি Core-এ এক Process | নিজে থেকেই |
| Code-এর ধরন | সহজ, পরপর | async/await বা Callback | সহজ, পরপর |
| খারাপ Task কী করে | এক Thread আটকে রাখে | ওই Loop-এর সবাইকে থামায় | বড়জোর এক Thread আটকে রাখে; আলাদা ঝুঁকি Pinning বা Blocking |
| Memory-তে Shared Data | Lock লাগে | এক Process-এর ভেতরে Lock লাগে না | Lock বা Channel লাগে |
| নিজে থেকে চাপ সীমিত করে | হ্যাঁ: Pool-এর আকার | না | না: সীমা নিজে দিতে হয় |
| সাধারণ ব্যর্থতা | Pool ফুরিয়ে যাওয়া | Block হওয়া Loop | Pinning, Leak, Database ডুবিয়ে দেওয়া |
| মানায় | চলতি CPU আর Blocking Workload | Proxy, Gateway, WebSocket Hub, Node.js-এর দল | নতুন Go Service; I/O-র অপেক্ষায় থাকা Java Service |
বাস্তবে টিকে যাওয়া সিদ্ধান্তের পথ:
১. বেশিরভাগ CPU? Thread Pool (বা Process), প্রতি Core-এ মোটামুটি এক Thread। Virtual Thread আর Event Loop কাজে আসে না।
২. বেশিরভাগ অপেক্ষা, Connection অনেক? সস্তা যেকোনোটাই চলবে। তোমার দল আর Library যেটা আগে থেকে ব্যবহার করে সেটা নাও, Benchmark দেখে না, কারণ এই স্তরে প্রায় সবসময় আগে আটকায় Database।
৩. মেশানো? I/O রাখো Loop বা Virtual Thread-এ, আর CPU-র কাজ পাঠাও সীমিত Pool-এ। CPU-র কাজ নিজে আলাদা Scale করার মতো বড় হলে আলাদা Service করো।
৪. একসাথে মাত্র কয়েকশো Request? সাধারণ Thread Pool-ই যথেষ্ট। যে Thread-এর অভাব নেই, তা বাঁচাতে চলতি Service আবার লিখো না।
বাস্তব Architecture প্রায়ই তিনটাই ব্যবহার করে: সামনে Nginx (Event Loop), পেছনে Node.js বা Java Virtual Thread-এর API, আর Queue-র পেছনে ছবি প্রসেসিংয়ের জন্য CPU-মাপের Worker Pool।
#Interview-তে
- কেউ বললে “Node Scale করে না, কারণ Single-threaded”, তাহলে Concurrency আর Parallelism আলাদা করো, সীমাটা বলো (প্রতি Process-এ 1000 / C), আর বলো Load Balancer-এর পেছনে প্রতি Core-এ একটা Process চালাবে।
- ১০ লাখ চুপচাপ Connection-এর WebSocket Hub-এ Event Loop বা Goroutine নাও, তারপর আসল সীমাগুলো বলো: প্রতি Connection-এ Memory, File Descriptor আর Fan-out-এর কাজ (Lesson ২.৬)।
- কেউ Performance বাড়াতে Virtual Thread প্রস্তাব করলে জিজ্ঞেস করো Workload কী। এটা অপেক্ষার কাজে Throughput বাড়ায়, CPU-র কাজে Latency না, আর Thread Pool যে অলিখিত সীমাটা দিত, সেটা তুলে দেয়।
- Redis কেন “Single-threaded হয়েও দ্রুত” জিজ্ঞেস করলে বলো: Memory-তে, Lock নেই, epoll, আর Command ছোট। তারপর বলো কীসে ভাঙে: একটা ধীর Command সব Client-কে থামায়।
- সবসময় বলো কী মাপবে: Event Loop Lag আর Utilization, Pool-এর Queue-র গভীরতা, Pinned Thread-এর Event, Goroutine-এর সংখ্যা।
#নিজে করে দেখো
এখানকার সবকিছুর জন্য শুধু Node.js লাগবে। নিচের ক্রম আর Pool-এর ফল Node.js 24-এ চালিয়ে মেলানো।
১. আগে আন্দাজ, তারপর চালাও। order.js নামে সেভ করে node order.js চালাও, তারপর order.mjs নামে Copy করে সেটা চালাও। বদলাবে শুধু Tick-এর জায়গা।
console.log("Start");
setTimeout(() => console.log("Timer"), 0);
Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("Tick"));
console.log("End");
২. Pool-এর সীমা দেখো। pool.js নামে সেভ করো। চারটা Hash প্রায় একসাথে শেষ হবে, পঞ্চমটায় লাগবে প্রায় দ্বিগুণ। তারপর চালাও UV_THREADPOOL_SIZE=5 node pool.js (Linux বা macOS), যথেষ্ট Core থাকলে পঞ্চমটা ধরে ফেলবে।
const crypto = require("node:crypto");
const start = Date.now();
for (let i = 1; i <= 5; i++) {
crypto.pbkdf2("secret", "salt", 500_000, 64, "sha512", () => {
console.log(`hash ${i} done at ${Date.now() - start} ms`);
});
}
৩. Server Block করে Lag দেখো। lag.js নামে সেভ করে চালাও।
const http = require("node:http");
const { monitorEventLoopDelay } = require("node:perf_hooks");
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
setInterval(() => {
console.log(`lag max ${(delay.max / 1e6).toFixed(0)} ms`);
delay.reset();
}, 1000);
http
.createServer((req, res) => {
if (req.url === "/block") {
const end = Date.now() + 3000;
while (Date.now() < end) {} // blocks everything
}
res.end("Hello World\n");
})
.listen(3000);
দ্বিতীয় Terminal-এ নিচের প্রথম Command চালাও, আর সাথে সাথে তৃতীয় Terminal-এ দ্বিতীয়টা। নিরীহ / Request-এ লাগবে প্রায় ৩ সেকেন্ড, আর Lag লাফিয়ে উঠবে প্রায় ৩,০০০ ms-এ।
curl http://localhost:3000/block
curl -w "took %{time_total}s\n" http://localhost:3000/
৪. Loop-কে না খাইয়ে রাখো। এটা চালাও, তারপর চিহ্নিত লাইনটা বদলে মিলিয়ে দেখো। nextTick-এর শিকল শেষ না হওয়া পর্যন্ত Timer চলে না; setImmediate দিলে প্রায় সাথে সাথে চলে।
setTimeout(() => console.log("timer ran"), 0);
let n = 0;
function again() {
if (++n < 5e6) process.nextTick(again); // now try setImmediate(again)
else console.log("done, n =", n);
}
again();
৫. ১ লাখ Thread। JDK 21 বা পরের সংস্করণে ওপরের Java উদাহরণ VT.java নামে সেভ করে java VT.java চালাও। তারপর newVirtualThreadPerTaskExecutor() বদলে newThreadPerTaskExecutor(Thread.ofPlatform().factory()) করে দেখো, তোমার Machine কতদূর যায়। আগে কাজ সেভ করে নিও, Machine হাঁসফাঁস করতে পারে।
#সারসংক্ষেপ
মূল কথা
- Event Loop Event নেয় আর Callback শেষ পর্যন্ত চালায়। অপেক্ষার খরচ প্রায় নেই; কাজ করা মানে সবাইকে আটকানো। এটা দেয় Concurrency, Parallelism না, আর মানায় I/O-bound কাজে।
- Node.js তোমার JavaScript চালায় এক Thread-এ, কিন্তু Socket সামলায় Kernel, আর libuv-এর Worker Pool (ডিফল্ট ৪ Thread,
UV_THREADPOOL_SIZE) সামলায় File Call,dns.lookup, কিছু Crypto আরzlib। ভরা Pool Event Loop Lag-এ ধরা পড়ে না। - Phase: Timers, Pending, Idle/Prepare, Poll, Check, Close। Server-এর জীবন আর ঘুম দুটোই Poll-এ।
- ক্রম: Synchronous Code, তারপর
nextTickQueue, তারপর Promise Microtask, প্রতি Callback-এর পরে। JavaScript Module-এর Top-level-এ Promise Queue আগে। I/O Callback-এর ভেতরেsetTimeout(0)-কে হারিয়েsetImmediateআগে চলে; Main Script-এ যেকোনোটা আগে চলতে পারে। - Loop Block হলে সব Client থমকে যায়: বড়
JSON.parse, খারাপ Regular Expression, Sync API। প্রতি Loop-এর ক্ষমতা সেকেন্ডে প্রায় 1000 / C। সমাধান Async API, Worker Thread,setImmediateদিয়ে টুকরো করা, বা আলাদা Service। - Event Loop Lag আর Utilization মাপো, CPU না।
- Blocking, Non-blocking আর Asynchronous আলাদা জিনিস।
fs.readFileতোমার কাছে Asynchronous, Pool Thread-এ Blocking। - Nginx-এ প্রতি Worker Process-এ একটা Loop। Redis Command চালায় এক Thread-এ, ৬.০ থেকে ঐচ্ছিক I/O Thread আছে, আর ধীর Memory ছাড়ার জন্য Background Thread; একটা ধীর Command সব Client-কে থামায়।
- Goroutine আর Java Virtual Thread (Java 21) হলো Runtime-এর লিখে দেওয়া Event Loop: পরপর লেখা Code, M:N Scheduling, সব Core। এদের ঝুঁকি Pinning (JDK 24 আর JEP 491 পর্যন্ত
synchronized, Native Code এখনো), Leak, আর চাপের কোনো নিজস্ব সীমা না থাকা। - Workload দেখে বাছো: CPU-র কাজ Core-মাপের Pool-এ, অনেক অপেক্ষা Event Loop বা Virtual Thread-এ, আর দুর্লভ জিনিসটার সীমা সবসময় নিজে দাও।
আগে নিজে উত্তর দাও, তারপর ব্যাখ্যাটা পড়ো। ঠিক হলেও পড়ো, কারণ ভুল Option-গুলো কেন ভুল, সেটাও ওখানে আছে।
নিজেকে যাচাই করোপ্রশ্ন
Event Loop কীসের জন্য ভালো, তা ঠিক করে দেয় এর কোন দুটো বৈশিষ্ট্য?
নিজেকে যাচাই করোপ্রশ্ন
একটা Node.js API-তে ২০,০০০ HTTP Connection খোলা। শুধু খোলা থাকার কারণে এগুলো libuv Pool-এর কয়টা Thread দখল করে?
নিজেকে যাচাই করোপ্রশ্ন
শুধু File পড়া Request-এ Latency বাড়ছে। Event Loop Lag সমান, CPU কম, Disk ঠিক। এদিকে Service অনেক http.get করছে, আর DNS Resolver ধীর হয়ে গেছে। সবচেয়ে সম্ভাব্য কারণ কী?
নিজেকে যাচাই করোপ্রশ্ন
Node.js Service-এ এই DNS আর File Read-এর সমস্যার দুটো ভালো সমাধান বাছো।
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
ডিফল্ট Setting-এ ৮ Core-এর Machine-এ পাঁচটা crypto.pbkdf2 একসাথে শুরু হলো। প্রতিটায় প্রায় ০.৭ s লাগে। কী দেখবে?
নিজেকে যাচাই করোপ্রশ্ন
একটা Node.js Server জীবনের বেশিরভাগ সময়, অলস অবস্থাতেও, কোন Phase-এ কাটায়?
নিজেকে যাচাই করোপ্রশ্ন
setTimeout(fn, 100) দেওয়া আছে। ৯৫ ms-এ একটা File Callback শুরু হয়ে ১০ ms CPU খরচ করে। fn কখন চলবে?
নিজেকে যাচাই করোপ্রশ্ন
Rolling Deploy-এর সময় তোমার Service বন্ধ হচ্ছে না। server.close() শেষ, তবু SIGKILL পর্যন্ত Process চালু থাকছে। সবচেয়ে সম্ভাব্য কারণ কী?
নিজেকে যাচাই করোপ্রশ্ন
fs.readFile-এর Callback-এর ভেতরে তুমি setTimeout(a, 0) আর setImmediate(b) ডাকলে। কোনটা আগে চলবে, আর কেন?
নিজেকে যাচাই করোপ্রশ্ন
Node.js-এ process.nextTick নিয়ে কোন দুটো কথা সত্যি?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
সাধারণ Script-এ এটা চলছে: setTimeout(() => { console.log(1); Promise.resolve().then(() => console.log(2)); }, 0); setTimeout(() => console.log(3), 0); Node.js 24 কী Print করবে?
নিজেকে যাচাই করোপ্রশ্ন
এটা কী Print করবে? async function f() { console.log(1); await null; console.log(2); } f(); console.log(3);
নিজেকে যাচাই করোপ্রশ্ন
তোমার সহকর্মী বলল: “ছবি Resize-টা async Function-এ ভরে দিয়েছি, এখন আর Loop আটকাবে না।” Resize-টা পুরোপুরি JavaScript Loop, ভেতরে কোনো await নেই। আসলে কী হয়?
নিজেকে যাচাই করোপ্রশ্ন
একটা Node.js Service-এ প্রতি Request-এ Main Thread-এর ৪ ms লাগে, আর Database-এ অপেক্ষা ৮০ ms। এক Process-এর মোটামুটি সর্বোচ্চ Throughput কত?
নিজেকে যাচাই করোপ্রশ্ন
একটা Endpoint Main Thread-এ ৪০ MB JSON Body Parse করে, প্রতিবার ৪০০ ms লাগে। মোট Traffic সেকেন্ডে ১,০০০ Request। এক Parse-এর সময়ে মোটামুটি কয়টা Request জমে?
নিজেকে যাচাই করোপ্রশ্ন
১৬ Core-এর Machine-এ একটা Node.js Process-এর Callback অসীম Loop-এ আটকে গেছে। Machine-এর মোট CPU Graph সম্ভবত কী দেখাবে?
নিজেকে যাচাই করোপ্রশ্ন
Client বলছে Connection হচ্ছে, কিন্তু Request ঝুলে থাকছে। Process এক Core-এর ১০০%-এ। TCP Connection তবু হচ্ছে কেন?
নিজেকে যাচাই করোপ্রশ্ন
Node.js Event Loop বিপদে আছে, এটা সবচেয়ে সরাসরি বলে কোন দুটো Metric?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
একটা Endpoint-কে একই Request-এ Resize করা ছবি ফেরত দিতে হবে। প্রতিটা Resize-এ ৩০০ ms CPU লাগে। Node.js Service-এ সবচেয়ে ভালো Design কী?
নিজেকে যাচাই করোপ্রশ্ন
কোনটা শব্দগুলোকে ঠিকভাবে আলাদা করে?
নিজেকে যাচাই করোপ্রশ্ন
Python-এ async def-এর ভেতরে একটা Coroutine time.sleep(5) ডাকল। ওই Loop-এর অন্য Coroutine-গুলোর কী হবে?
নিজেকে যাচাই করোপ্রশ্ন
Nginx-এর কোন বর্ণনাটা ঠিক?
নিজেকে যাচাই করোপ্রশ্ন
একটা Nginx Server-এ মাঝে মাঝে এক Worker-এর সব Connection থমকে যায়, শুধু ব্যস্ত Disk থেকে কম ব্যবহৃত বড় File দেওয়ার সময়। কীসে সারবে?
নিজেকে যাচাই করোপ্রশ্ন
Redis-এর Thread নিয়ে কোন দুটো কথা সত্যি?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
Peak Traffic-এর সময় ৩ কোটি সদস্যের একটা Redis Set মুছতে হবে। কী চালাবে?
নিজেকে যাচাই করোপ্রশ্ন
async/await Event Loop আর Goroutine বা Virtual Thread-এর মধ্যে Programming-এর প্রধান পার্থক্য কী?
নিজেকে যাচাই করোপ্রশ্ন
একটা Java 21 Service Virtual Thread-এ গেল। চাপ এলে থমকে যায়, CPU প্রায় শূন্য। Thread Dump-এ দেখা যায় Carrier-গুলো synchronized Block-এর ভেতরের Call-এ আটকে। কী হচ্ছে, আর সমাধান কী?
নিজেকে যাচাই করোপ্রশ্ন
একটা দল ২০০ Thread-এর Pool বদলে Virtual Thread দিল, আর কিছু বদলাল না। Traffic-এর ঢেউয়ে Database ধসে গেল। কেন?
নিজেকে যাচাই করোপ্রশ্ন
Goroutine নিয়ে কোন কথাটা ঠিক?
নিজেকে যাচাই করোপ্রশ্ন
এগুলোর মধ্যে কোন দুটো Workload-এ Core-সংখ্যার কাছাকাছি মাপের সাধারণ Thread Pool সবচেয়ে মানানসই?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
Interviewer বললেন: “Virtual Thread-এ চলো, এতে সব দ্রুত হয়।” সবচেয়ে ভালো উত্তর কোনটা?