Course-এর সূচি

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.parseMain 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.randomBytesWorker Poolনা
Async zlib CompressionWorker 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 লাগেই না।

Node.js Process
Client
OS Kernelepoll
Main Threadতোমার JavaScript
libuv Pool৪টা Thread
ধীর কাজDisk, নাম খোঁজা, Hashing
ধাপ 1 / 6
1

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, PrepareNode-এর ভেতরের হিসাবনিকাশনা
৪PollKernel-কে জিজ্ঞেস করে কী Ready, আর I/O Callback চালায়। কাজ না থাকলে এখানেই ঘুমায়Server-এর জীবনের বেশিরভাগ সময়
৫ChecksetImmediate-এর Callbackহ্যাঁ
৬Close Callbacksclose 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-এ সেটা দেখবে।

১. Timers
২. Pending
৩. Idle, Prepare
৪. Pollepoll_wait
৫. Check
৬. Close

এই Phase-এ চলে

PhaseCallback
timersযেসব setTimeout আর setInterval-এর সময় হয়ে গেছে, তাদের Callback
ধাপ 1 / 7
1

Loop ঘড়ি দেখে, মেয়াদ ফুরোনো সব Timer চালায়। Timer-এর সময় মানে সর্বনিম্ন সময়, কোনো নিশ্চয়তা না।

#Microtask, আর এরপর কে চলবে

Phase-গুলোর বাইরে Node-এর আরও দুটো Queue আছে, আর ক্রমের ধাঁধাগুলো মজার হয় এদের জন্যই:

  • process.nextTick Queue, এটা Node-এর নিজের।
  • Promise Microtask Queue, এটা JavaScript Engine-এর: .then Callback, 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

এখন চলছে

main script

nextTick Queue

আগে খালি হয়

Promise Microtask

এরপর খালি হয়

Timers Phase

Poll Phase

Check Phase

Console Output

Start
ধাপ 1 / 6
1

পুরো 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-র কাজে ভরা async Function অন্য যেকোনো 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.gzipSyncDisk বা CPU-র সময়Startup-এ ক্ষতি নেই, Handler-এ সর্বনাশ
Main Thread-এ Password Hashingইচ্ছে করেই প্রায় ১০০ msAsync সংস্করণ আছে, আর সেটা 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 লাগে। কেউ কারো জন্য অপেক্ষা করে না।

0123456এক ঘর = ১০০ ms (asyncio Tab-এ ৫০০ ms)Main ThreadRequest ARequest BRequest CHealth Check
ছোট CallbackThread আটকে রাখাঅপেক্ষাউত্তর গেছেWorker Threadলম্বা কাজের এক টুকরো
ধাপ 1 / 3
1

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/sSerialization-সহ সাধারণ 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 DriverAsync সংস্করণ আছে এমন সবকিছুPool-এর জন্য লড়তে হয়
Worker Threadnode:worker_threads, প্রায়ই Piscina-র মতো Pool Library দিয়েযে CPU-র কাজের উত্তর একই Request-এ দিতে হয়: ছবি ছোট করা, বড় ParseThread-এর মধ্যে Data Copy হয়; প্রতি Worker-এর নিজের Engine, Memory লাগে
টুকরো করে ছাড় দেওয়াঅল্প একটু কাজ, তারপর await new Promise(setImmediate)তোমার নিজের লম্বা Loopএখনো এক Core; কাজ বেশি সময় নেয়; Library-র একটা বড় Call-এ অকেজো
আলাদা Service বা QueueJob Queue (Chapter 30–32) বা আলাদা ServiceVideo, Report, Machine Learning, যা সেকেন্ডে মাপা হয়জিনিসপত্র বাড়ে; উত্তরও Asynchronous হয়ে যায়
Input সীমিত করাBody-র আকারের সীমা, Streaming Parser, Backtracking ছাড়া Regular Expression EngineClient যা নিয়ন্ত্রণ করেকিছু 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, একটু গুছিয়ে:

BlockingNon-blockingAsynchronous
যে ডাকে সে কী দেখেফলাফল তৈরি হওয়া পর্যন্ত অপেক্ষা করেসাথে সাথে ফেরে, হয়তো “এখনো হয়নি, পরে দেখো” (EAGAIN) নিয়েকাজ শুরু করে, শেষ হলে খবর পায়
Threadঘুমায়চলতে থাকেImplementation-এর ওপর নির্ভর করে
কে জানতে পারেযে ডেকেছে, জেগে উঠেযে ডেকেছে, আবার জিজ্ঞেস করে বা Readiness API দিয়েকেউ একজন খবর দেয়: Callback, Promise, Future
Code-এর ধরনসহজ, পরপরতুলনায় জটিলCallback, Future, Promise, await
কোথায় ব্যবহারসহজ I/O, Script, Thread PoolHigh-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-এর দিক থেকে এর মানে কী, সেটা এখানে।

Client
KernelListening Socket
Worker 1Core ১
Worker 2Core ২
Disk বা Upstream
নতুন Connection
ধাপ 1 / 4
1

Kernel TCP Handshake শেষ করে Connection-টা এমন Listening Socket-এ রাখে, যা সব Worker Master-এর কাছ থেকে পেয়েছে।

#Redis: Command চালায় এক Thread

“Redis Single-threaded” কথাটা খাটে Command চালানোয়, আর কোথাও না:

কাজThread
Command চালানো আর Data ছোঁয়াএক Main Thread
Socket পড়া-লেখা, ParseMain Thread, বা Redis 6.0 থেকে ঐচ্ছিক I/O Thread (io-threads)
বড় Value ছাড়া (UNLINK), Append-only File Flush, File বন্ধ করাBackground Thread
Snapshot আর Log RewriteFork করা 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
Client B
I/O Threadঐচ্ছিক, ৬.০+
Main ThreadCommand চালায়
BackgroundLazy Free, fsync
INCR hits
ধাপ 1 / 5
1

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 চালাচ্ছে)

G1

Thread ২ (Go চালাচ্ছে)

G2

Run Queue

তৈরি Goroutine

G3G4

Network Poller

epoll

System Call-এ আটকে

সরিয়ে রাখা

Channel বা Lock

ধাপ 1 / 4
1

দুটো 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-এর ভেতরে BlockPinnedPinned না
ReentrantLock-এ BlockPinned না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); // waits
save(body);
});
}

Carrier ১

OS Thread

V1

Carrier ২

OS Thread

V2

চলার জন্য তৈরি

V3V4

Heap-এ সরিয়ে রাখা

Stack Object হয়ে জমা

Database Connection

সীমা ৫০, আঁকা ৩টা

Permit-এর অপেক্ষায়

ধাপ 1 / 4
1

প্রতিটা Task পায় নতুন Virtual Thread; এদের Pool-এর মাপ ঠিক করতে হয় না। V1 আর V2 দুই Carrier-এ Mount করা। ডিফল্টে Carrier থাকে Core-এর সমান।

Virtual Thread অপেক্ষাকে সস্তা করে; হিসাবকে দ্রুত করে না। এদের মধ্যে আজ Time Slicing নেই, তাই CPU-bound Task রাখো Core-এর মাপের Fixed Pool-এ (Lesson ৩.৩)।

#আরও যারা আছে

RuntimeModelকথা
Rust (Tokio)async/await, সাথে Multi-threaded Work-stealing Runtimeযে Task কখনো await করে না, সে একটা Worker আটকাতে পারে; Blocking Call-এর জন্য spawn_blocking
C# / .NETasync/await, Compile হয়ে Thread Pool-এ State MachineTask-এ Block করলে (.Result) Pool উপবাসে পড়তে পারে, Synchronization Context থাকলে Deadlock-ও
KotlinCoroutine, suspend Functionরঙিন, তবে JVM-এর ওপর; Virtual Thread-এও চলতে পারে
Erlang, Elixirহালকা Process, Preemptive Schedulerকরা কাজের হিসাবে থামানো হয়, তাই একটা ব্যস্ত Process অন্যদের আটকাতে পারে না; বিপুল Connection-এর জন্যই বানানো
Netty, Vert.x, Project ReactorJVM-এ কয়েকটা Event LoopHigh-performance Java-র নিচে; সাধারণ Service-এ Virtual Thread Reactive ধরনের প্রয়োজন কমায়
Envoy, HAProxyপ্রতি Worker Thread-এ একটা Event LoopNginx-এর মতোই গড়ন, শুধু Process-এর বদলে Thread

#কীভাবে বেছে নেবে

Thread PoolEvent LoopVirtual 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 DataLock লাগেএক Process-এর ভেতরে Lock লাগে নাLock বা Channel লাগে
নিজে থেকে চাপ সীমিত করেহ্যাঁ: Pool-এর আকারনানা: সীমা নিজে দিতে হয়
সাধারণ ব্যর্থতাPool ফুরিয়ে যাওয়াBlock হওয়া LoopPinning, Leak, Database ডুবিয়ে দেওয়া
মানায়চলতি CPU আর Blocking WorkloadProxy, 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, তারপর nextTick Queue, তারপর 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-এ, আর দুর্লভ জিনিসটার সীমা সবসময় নিজে দাও।