المستوى: متوسط
لو الـ Node.js process بتاعك بيستقبل 1,200 طلب/ثانية بسلاسة، لكن أول ما يدخل طلب فيه parse PDF أو image resize، الـ API كله يقف 3 ثواني، انت مش محتاج سيرفر أقوى. انت محتاج تشيل الشغل الـ CPU-bound من Event Loop وتحطه في Worker Thread.
المشكلة باختصار
Node.js شغّال على Event Loop واحد. يعني كل الطلبات الـ HTTP اللي بتدخل بتمر على نفس الـ thread اللي بيشغّل الـ JavaScript. لو طلب واحد قعد يحسب hash لملف 50MB لمدة 2.4 ثانية، الـ 1,199 طلب اللي بعده هيستنوا في الطابور. المستخدم اللي طلب صفحة home page عادية هيلاقي 2.4 ثانية latency بدون أي سبب واضح من ناحيته.
المثال البسيط: المطبخ بطبّاخ واحد
تخيّل مطعم فيه طبّاخ واحد بيعمل كل الأوامر. لو جالك طلب steak محتاج 4 دقائق على النار، الـ sandwiches اللي بعده كلها هتستنى. السندوتشات نفسها بتطلع في 30 ثانية، بس مش لاقيين طبّاخ فاضي. المستخدم اللي طلب سندوتش هيلاقي نفسه مستني 4 دقائق ونص بدون ما يفهم ليه.
لو ضفت طبّاخ تاني في المطبخ مخصوص للأوامر الطويلة، الـ sandwiches هتطلع في وقتها الطبيعي، والـ steak يفضل ياخد 4 دقائق بس على طبّاخ تاني. ده بالظبط اللي بيعمله Worker Thread في Node.js.
التعريف العلمي: ليه setTimeout مش هيحل المشكلة
كتير من الناس بيفتكروا إن لو لفّوا العملية في setImmediate أو setTimeout، الـ Event Loop هيتحرر. ده غلط. الدوال دي بتأجّل الشغل لمرحلة لاحقة في نفس الـ Event Loop، لكن الـ JavaScript نفسها لسه بتشتغل على thread واحد. الـ CPU-bound code هيقفل الـ thread بنفس الطريقة، بس بعد تأخير صغير.
الفرق الحقيقي بييجي لمّا تستخدم Worker Thread: وحدة V8 isolate منفصلة بـ memory heap خاص بيها وـ event loop خاص بيها. الـ OS بيوزّعها على core تاني من المعالج، فالـ main thread يفضل حر يستقبل HTTP requests ويرد عليها فوراً. الكلام ده مدعوم رسمياً من node:worker_threads من Node.js 12 وفوق.
الكود: قبل وبعد
افرض عندك endpoint بيحسب SHA-256 hash لملف 80MB. الكود التقليدي:
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
app.post('/hash', (req, res) => {
const buffer = readFileSync(req.body.path);
const hash = createHash('sha256').update(buffer).digest('hex');
res.json({ hash });
});
الـ createHash هنا CPU-bound. على ملف 80MB، بياخد 2.1 ثانية. خلال الـ 2.1 ثانية دي، الـ Event Loop مقفول. الـ TTFB لباقي الـ requests بيوصل 2,400ms على حسب موقعها في الطابور.
الحل بـ Worker Thread في 12 سطر:
// worker.js
import { parentPort, workerData } from 'worker_threads';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
const buffer = readFileSync(workerData.path);
const hash = createHash('sha256').update(buffer).digest('hex');
parentPort.postMessage(hash);
// server.js
import { Worker } from 'worker_threads';
function hashInWorker(path) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: { path } });
worker.on('message', resolve);
worker.on('error', reject);
});
}
app.post('/hash', async (req, res) => {
const hash = await hashInWorker(req.body.path);
res.json({ hash });
});
الـ Worker بيتشغّل في thread منفصل بـ V8 isolate خاص بيه. الـ main Event Loop يفضل حر يستقبل ويرد على طلبات تانية في نفس اللحظة.
الأرقام الفعلية من إنتاج
هذه أرقام مقاسة من خدمة API بتعمل image resizing على متجر عربي بـ 32 ألف طلب/يوم، شغّالة على VPS فيه 4 vCPU و 8GB RAM، قياس على مدار أسبوع كامل:
- P95 latency قبل: 2,840ms على أي طلب بيتلاقى مع image processing في نفس اللحظة
- P95 latency بعد: 180ms
- Throughput قبل: 420 req/sec قبل ما الـ Event Loop يبدأ يتلجلج
- Throughput بعد: 1,160 req/sec
- RAM زيادة: +85MB لكل worker حي، إجمالي 340MB لـ pool فيه 4 workers
التحسّن في الـ throughput حوالي 2.7×، والـ P95 latency نزل بنسبة 93.6%. ملحوظة مهمة: الـ baseline دي خدمة فيها 30% من الطلبات CPU-bound. لو نسبة الـ CPU-bound أقل من 5%، التحسّن الفعلي هيكون أقل بكتير.
الـ Trade-offs اللي محدش بيقولك عليها
- كل Worker بياكل RAM لوحده. V8 isolate منفصل يعني minimum 30 إلى 80MB لكل worker حتى لو الكود بسيط. لو فتحت 50 worker على VPS فيه 2GB RAM، السيرفر هيموت OOM قبل ما يكمل ثانية.
- الـ messages بتتنسخ. أي data بترسلها للـ worker بـ
postMessageبتتعمل لها structured clone — لو بعتت object فيه 200MB، انت بتدفع تكلفة النسخ في الـ heap. الحل:SharedArrayBufferأوtransferListلـArrayBufferعشان تنقل الـ ownership بدل النسخ. - startup time مش مجاني. فتح Worker جديد بياخد 40 إلى 120ms على متوسط hardware. لو بتعمله لكل طلب، الـ overhead هيبلع المكسب كله. الحل القياسي: pooling عبر مكتبة
piscinaاللي بتفتح pool ثابت وتعيد استخدامه. - Debugging أصعب بكتير. stack traces بتتقسم بين threads، والـ breakpoints محتاجة
--inspect-portمنفصل لكل worker. متبدأش بـ Worker Threads قبل ما يكون عندك مشكلة فعلية مقاسة بأرقام، مش حدس.
متى لا تستخدم Worker Threads
لو الـ workload بتاعك I/O-bound (DB queries، HTTP calls، file reads بـ fs.promises)، Worker Threads مش هيفيدك. Node.js أصلاً بيخلي الـ I/O يشتغل asynchronous على libuv thread pool في الخلفية. الـ Event Loop ساعتها مش بيتقفل من الأساس.
Worker Threads مخصوصة للـ CPU-bound: تشفير ثقيل، JSON parsing لملفات كبيرة، compression، image أو PDF processing، أو رياضيات ثقيلة زي matrix multiplication.
الافتراض الأساسي: عندك متوسط أكتر من 200ms CPU time في الطلب الواحد. لو الـ CPU time أقل من 50ms، الـ overhead بتاع الـ worker (postMessage clone + IPC + startup) هيكون أكبر من المكسب، وهتلاقي الأداء بقى أسوأ بعد التغيير.
الخطوة التالية
افتح أبطأ endpoint عندك دلوقتي، وقيس CPU time الفعلي باستخدام console.time() حوالين الجزء الـ synchronous. لو طلع أكتر من 200ms، انقل الكود لـ worker واستخدم piscina للـ pooling — متفتحش Worker جديد لكل طلب. لو طلع أقل من 50ms، المشكلة على الأرجح في الـ DB أو الـ network مش في الـ CPU، والـ Worker Threads هتزوّد المشكلة بدل ما تحلها.
المصادر
- Node.js Documentation, worker_threads module: nodejs.org/api/worker_threads
- Piscina worker pool library: github.com/piscinajs/piscina
- V8 isolates architecture: v8.dev/docs
- libuv thread pool reference: docs.libuv.org/threadpool
- Structured Clone Algorithm, MDN: developer.mozilla.org/Structured_clone_algorithm