معماری Single-thread و Event-driven نود جیاس برای عملیات I/O (شبکه، دیتابیس، سیستمفایل) بینظیر و فوقالعاده بهینهست. به لطف مکانیزم libuv و Event Loop، نود جی اس میتونه هزاران کانکشن همزمان رو با کمترین مصرف RAM مدیریت کنه. اما پاشنه آشیل این معماری کجاست؟ دقیقاً جایی که پای پردازشهای سنگین پردازنده (CPU-intensive) وسط میاد.
وقتی شما یک تسک سنگین (مثل رمزنگاری بلوک های بزرگ با الگوریتمهای پیچیده، پردازش تصویر، یا حلقههای طولانی ریاضی) رو در Main Thread اجرا میکنید، Call Stack کاملاً اشغال میشه. از اونجایی که نود جیاس فقط یک ترد اجرایی اصلی داره، Event Loop عملاً قفل (Block) میشه. نتیجه اینه که تا زمان پایان اون پردازش سنگین، سرور هیچ ریکوئست دیگهای رو هندل نمیکنه و سیستم در حالت فریز قرار میگیره.
راهحل مدرن: معرفی Worker Threads
تا قبل از نسخه ۱۰.۵، مهندسهای بک اند برای حل این مشکل مجبور بودن از ماژول child_process (مثل fork) استفاده کنن. مشکل child_process اینه که یک Process کاملاً جدید در سطح سیستمعامل میسازه. این یعنی تخصیص یک حافظه کاملاً جداگانه و بوت کردن یک نمونه مستقل از موتور V8 که بهشدت پرهزینه است و RAM زیادی رو میبلعه.
در مقابل، ماژول worker_threads اجازه میده چندین Thread رو داخل همان Process اصلی ایجاد و مدیریت کنید.
زیر کاپوت Workerها چه میگذره؟
وقتی یک Worker جدید میسازید، هر کدام از آنها منابع زیر را در اختیار دارند:
- V8 Isolate اختصاصی: هر Worker محیط اجرای جاوا اسکریپت خودش رو داره و Garbage Collector آن با بقیه تداخلی نداره.
- Event Loop مستقل: Workerها ایونت لوپ اختصاصی خودشون رو دارن؛ بنابراین میتونن کارهای غیرهمزمان (Async) خودشون رو بدون مسدود کردن Main Thread پیش ببرن.
- Context مستقل: متغیرهای گلوبال (Global Variables) بین آنها به اشتراک گذاشته نمیشه (مگر اینکه با ابزارهای خاصی این کار رو بکنید).
پیادهسازی پایه (نحوه ارتباط Main Thread و Worker)
بیاید پیادهسازی خام این معماری رو ببینیم. ما یک تسک سنگین ریاضی رو از ترد اصلی به یک Worker میسپاریم.
فایل اول: main.js (مدیریتکننده)
این فایل ترد اصلی ماست که تسک رو به Worker میفرسته و خودش آزاد میمونه تا به بقیه ریکوئستها جواب بده.
const { Worker } = require('worker_threads');
const path = require('path');
function runHeavyMathTask(workerData) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(__dirname, 'worker.js'), {
workerData // ارسال دیتا به Worker
});
worker.on('message', resolve); // دریافت نتیجه
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker با کد ${code} متوقف شد.`));
});
});
}
async function start() {
console.log('--- شروع پردازش در Main Thread ---');
console.time('Execution Time');
try {
// محول کردن تسک سنگین به Worker
const result = await runHeavyMathTask(10000000);
console.log(`طول آرایه محاسبه شده: ${result.length}`);
} catch (err) {
console.error('خطا:', err);
}
console.timeEnd('Execution Time');
console.log('--- Main Thread در این مدت مسدود نشد ---');
}
start();
فایل دوم: worker.js (عملگر)
اینجا جاییه که پردازش CPU-Bound اتفاق میفته.
const { workerData, parentPort } = require('worker_threads');
function heavyComputation(limit) {
const result = [];
for (let i = 2; i <= limit; i++) {
let isPrime = true;
for (let j = 2; j < i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) result.push(i);
}
return result;
}
const limit = workerData;
const result = heavyComputation(limit);
// ارسال نتیجه به ترد اصلی
parentPort.postMessage(result);
سطح پیشرفته: چالش کپی کردن دیتا و SharedArrayBuffer
در معماری بالا، وقتی از postMessage استفاده میکنیم، نود جیاس از الگوریتم Structured Clone استفاده میکنه. یعنی دیتای خروجی به صورت کامل در حافظه کپی میشه و به Main Thread میرسه. اگر دیتای شما یک آرایه چند مگابایتی باشه، خودِ این فرآیندِ کپی کردن، CPU و RAM رو به شدت درگیر میکنه.
راهحل Zero-copy: استفاده از SharedArrayBuffer. با این قابلیت، شما یک بلاک از حافظه (Memory) رو مستقیماً بین Main Thread و Worker به اشتراک میذارید. هر دو طرف میتونن این حافظه رو بخوانند و در آن بنویسند بدون اینکه حتی یک بایت دیتا کپی (Clone) بشه. این تکنیک پرفورمنس خالص به شما میده؛ اما نیازمند استفاده از آبجکت Atomics برای مدیریت دسترسی همزمان و جلوگیری از Race Conditions است.
چالش مقیاسپذیری: چرا ساخت Worker به ازای هر درخواست اشتباه است؟
یک اشتباه مهندسی رایج اینه که توسعهدهندهها برای هر ریکوئستِ سنگینی که به سرور میرسه، یک new Worker ایجاد میکنن. تخصیص حافظه برای یک V8 Isolate جدید، بوت کردن محیط و ایجاد پورتهای IPC ارتباطی بهشدت زمانبر و پرهزینه است. اگر در لحظه ۱۰۰ درخواست همزمان داشته باشید، ساختن ۱۰۰ ترد جدید باعث میشه سیستمعامل درگیر مشکل Context Switching بشه، RAM سرور پر بشه و اپلیکیشن Crash کنه.
راهحل مهندسی: الگوی Worker Pool
به جای ساخت و تخریب مداوم تردها، ما از الگوی Thread Pool استفاده میکنیم. در زمان بوت شدن سرور، تعداد مشخصی Worker (معمولاً معادل تعداد هسته های منطقی پردازنده) ایجاد شده و در یک Pool در حالت آمادهباش (Idle) قرار میگیرن. تسک های سنگین وارد یک Task Queue میشن و منیجر سیستم این تسک ها رو به Workerهای بیکار پاس میده. وقتی کار Worker تمام شد، بدون اینکه Destroy بشه، مجدداً به حالت Idle برمیگرده تا تسک بعدی رو بگیره.
پیادهسازی استخر تردها با Piscina
کتابخانه piscina (توصیه شده توسط تیم کُر Node.js) این معماری رو به بهینهترین شکل برای شما هندل میکنه.
۱. فایل worker.js (ساده و بدون درگیری با پورتها):
module.exports = (data) => {
// یک پردازش سنگین فرضی
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += data.value * 2;
}
return result;
};
۲. فایل main.js (مدیریت صف و تردها):
const path = require('path');
const os = require('os');
const Piscina = require('piscina');
const pool = new Piscina({
filename: path.resolve(__dirname, 'worker.js'),
minThreads: 2,
maxThreads: os.cpus().length, // سقف تردها بر اساس هستههای CPU
maxQueue: 100 // ظرفیت صف تسکها
});
async function processData() {
try {
console.log(`سیستم آماده با ${pool.threads.length} ترد اکتیو.`);
// ارسال همزمان چندین تسک به صف Worker Pool
const tasks = [
pool.run({ value: 10 }),
pool.run({ value: 20 }),
pool.run({ value: 30 })
];
// Main Thread مسدود نمیشه و کارهای I/O خودش رو انجام میده
const results = await Promise.all(tasks);
console.log('نتایج:', results);
} catch (err) {
console.error('خطا در پردازش:', err);
}
}
processData();
۳ قانون طلایی در استفاده از Workerها
- هرگز برای کارهای I/O از Worker استفاده نکنید: Workerها برای کارهای شبکه، دیتابیس یا سیستمفایل طراحی نشدن. مکانیزم Async خودِ نود جیاس برای این کارها هزار بار بهینهتر از ساختن یک Thread جدیده.
- از Thread Pool استفاده کنید: همیشه کارهای سنگین رو پشت یک صف قرار بدید و تعداد تردها رو بهینه نگه دارید تا از Memory Leak و Overload شدن CPU جلوگیری کنید.
- دیتا رو سبک جابجا کنید: تا جای ممکن حجم دیتای تبادلی بین Worker و Main Thread رو پایین نگه دارید، مگر اینکه معماری شما بر پایه SharedArrayBuffer طراحی شده باشه.
جمعبندی: چه زمانی به سراغ Worker Threads برویم؟
اگر بخواهیم خیلی خلاصه و ساختاریافته موضوع را جمع بندی کنیم، worker_threads ابزاری نیست که بخواهید در تمام پروژهها یا برای هر کار کوچکی از آن استفاده کنید. این قابلیت یک راهحل تخصصی برای حل گلوگاههای پردازشی (CPU-Bound) در لایههای زیرین Node.js است.
یک چکلیست سریع برای تصمیمگیری معماری:
- عملیات I/O-Bound: کارهایی مثل هندل کردن درخواستهای HTTP، کوئریهای دیتابیس، روتینگ یا خواندن و نوشتن فایل، اصلاً نیازی به Worker ندارند. مکانیزم غیرهمزمان (Async) خود Node.js و ساختار libuv این کارها را در بهینهترین حالت ممکن انجام میدهند.
- عملیات CPU-Bound: کارهایی مثل محاسبات سنگین ریاضی، هش کردن پسوردها، فشردهسازی ویدیو یا پردازش تصویر، دقیقاً جاهایی هستند که باید تسک را از ترد اصلی جدا کنید و به Worker Threads بسپارید تا سرور دچار فریز یا تاخیر (Latency) نشود.
کلام آخر برای پیادهسازی در محیط Production:
در پروژههای واقعی و زیر بار ترافیک بالا، هیچوقت به صورت خام و مستقیم با new Worker کد نزنید. ایجاد و نابودی مداوم Threadها، سیستمعامل و موتور V8 را درگیر چالش خستهکنندهٔ تخصیص حافظه و Context Switching میکند.
بهترین پپشفرض مهندسی، استفاده از یک Worker Pool (مثل کتابخانه piscina) است تا تعداد تردهای فعال را همیشه متناسب با هسته های واقعی پردازنده سرور نگه دارید، تسکها را در صف (Queue) مدیریت کنید و با ابزار هایی مثل SharedArrayBuffer جابجایی دیتای حجیم را به صفر برسانید. با این فرمول، عملاً نقطه ضعف بزرگ نود جیاس را به نقطه قوتش تبدیل میکنید.
نظرات کاربران (0)