لو endpoint بسيط في تطبيقك بياخد 4 ثواني يرد، وفتحت htop ولقيت CPU 30% بس، Node.js مش بطيء وسيرفرك مش متخوم. فيه سطر واحد جوّا الـ request handler بيقفل الـ event loop ومحدش يقدر يرد على أي طلب تاني لحد ما يخلّص. المقال ده بيوريك ازاي تكتشف العملية دي بـ perf_hooks وتنقلها لـ Worker Thread في 30 سطر.
Event Loop في Node.js: المشكلة الحقيقية ورا التجمّد
المشكلة باختصار
Node.js بيشغّل JavaScript على thread واحد. لمّا الـ thread ده بياخد عملية CPU-heavy (توقيع PDF، parse JSON كبير، bcrypt بـ rounds عالية، regex معقّد) كل الـ requests التانية بتستنّى في طابور. الـ CPU مش بيوصل 100% لأن core واحد فقط شغّال — والباقي قاعد. ده اللي بيخلّي السيرفر يبان "متجمّد" مع إن المراقبة بتقول إنه فاضي.
مثال المخبز — يفهمه أي مبتدئ
تخيّل مخبز فيه فرّان واحد بس. الزبون الأول طلب كرواسون (دقيقتين). الفرّان قعد يفرد العجين بإيده. خلال الدقيقتين دول، 12 زبون دخلوا وكل واحد عايز قهوة (10 ثواني بس). محدش هيتخدم. الفرّان مش كسلان، هو شغّال — بس على مهمة واحدة طويلة قفلت "نظام الخدمة" كله.
Node.js نفس الفكرة. الـ event loop هو نظام الخدمة. اللحظة اللي تشغّل فيها JSON.parse على ملف 18 ميجا، الـ thread اللي بيخدم كل الزباين قاعد بيقرأ الـ JSON. أي طلب تاني بيستنى في الطابور، حتى لو هو طلب بسيط مثل /health.
التعريف العلمي — Event Loop و libuv
Node.js مبني على V8 (لتشغيل JavaScript) و libuv (للـ I/O غير المتزامن). الـ event loop هو حلقة في libuv بتتنقل بين 6 phases: timers، pending callbacks، idle/prepare، poll، check، close callbacks. كل phase فيها queue من الـ callbacks الجاهزة للتنفيذ. JavaScript بيشتغل في مرحلة منهم بشكل synchronous.
الافتراض الأساسي: لازم كل callback تخلّص بسرعة (≤ 10ms كقاعدة). لو callback أخدت 200ms، كل الـ phases التانية وكل الـ I/O callbacks اللي وصلت من kernel استنّت 200ms. بالظبط زي ما توثيق Node.js الرسمي بيقول: "you cannot fix what you cannot see"، فأول خطوة هي القياس.
libuv كمان فيه thread pool مساعد بـ 4 threads افتراضيًا (يتغير عبر UV_THREADPOOL_SIZE). الـ pool ده بيستخدمه fs و crypto.pbkdf2 و zlib. لكن — ركز هنا — أي JavaScript بتكتبه إنت بيتنفّذ على main thread، مش على الـ thread pool. ده مصدر اللخبطة الأكبر.
ازاي تقيس Event Loop Delay فعلاً
الطريقة الشائعة الغلط: تشغّل setInterval وتقيس الفرق. ده بيخفي 90% من الحقيقة. الطريقة الصح من توثيق Node.js: perf_hooks.monitorEventLoopDelay().
// monitor.js — شغّله مرة واحدة على بدء التطبيق
const { monitorEventLoopDelay } = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
const p99 = Math.round(histogram.percentile(99) / 1e6); // ms
const max = Math.round(histogram.max / 1e6);
console.log(`event_loop_delay p99=${p99}ms max=${max}ms`);
histogram.reset();
}, 5000);
القراءة الطبيعية: P99 تحت 10ms، max تحت 50ms. لو شفت P99 = 380ms على endpoint بيدّعي إنه بسيط، عندك blocking call مدفون.
الحل: Worker Threads — مش Cluster ومش child_process
لمّا الـ operation حقيقي CPU-bound، الحل بنقلها لـ Worker Thread، اللي هو Node.js runtime منفصل بـ event loop خاص بيه ومساحة ذاكرة منفصلة. الـ main thread بيرجع يخدم باقي الطلبات في الميكروثانية اللي بعد ما يبعت المهمة.
// pdf-worker.js — يشتغل في thread منفصل
const { parentPort } = require('node:worker_threads');
const { signPdf } = require('./sign');
parentPort.on('message', async ({ buffer, key }) => {
const signed = await signPdf(buffer, key); // CPU-heavy
parentPort.postMessage({ signed }, [signed.buffer]);
});
// server.js — pool بسيط، 4 workers
const { Worker } = require('node:worker_threads');
const path = require('node:path');
const pool = Array.from({ length: 4 }, () =>
new Worker(path.join(__dirname, 'pdf-worker.js')));
let next = 0;
function runOnWorker(payload) {
return new Promise((resolve, reject) => {
const w = pool[next++ % pool.length];
w.once('message', resolve);
w.once('error', reject);
w.postMessage(payload, [payload.buffer.buffer]);
});
}
app.post('/sign', async (req, res) => {
const result = await runOnWorker({ buffer: req.body, key: KEY });
res.send(result.signed);
});
الأرقام المقاسة
قياسات على endpoint توقيع PDF بحجم 2.4MB، 50 طلب متزامن، Node.js 22.7 على Hetzner CPX31 (4 vCPU). الافتراض: التوقيع بياخد 380ms من CPU خالص.
- قبل (main thread): P95 لـ
/health= 4,180ms، CPU core 0 = 100%، باقي الـ cores = 8%. - بعد (4 workers): P95 لـ
/health= 92ms، CPU عبر الـ 4 cores = 71%، throughput من 11 req/s لـ 38 req/s.
الـ Trade-offs الحقيقية
بتكسب: main thread ثابت تحت أي load، استغلال multi-core فعلي، عزل crash للـ worker بدون ما يقع السيرفر كله. بتخسر:
- تكلفة بدء الـ worker: كل worker بياخد 30–50ms يفتح و 35MB RAM. عشان كده pool ثابت أحسن من spawn-on-demand.
- نقل البيانات: الـ
postMessageبيعمل structured clone، اللي بياخد ms على objects كبيرة. استخدمTransferable(الـArrayBufferفي المثال فوق) لو ممكن. - تعقيد debug: stack traces بتقطع عند حدود الـ worker. لازم logging مع correlation id.
- مش shared memory افتراضيًا: لو محتاج state مشترك،
SharedArrayBuffer+Atomics— لكن ده مستوى متقدّم وفيه فخاخ race conditions.
متى لا تستخدم هذه الطريقة
Worker Threads مش الحل لكل مشكلة بطء. ما تستخدمهاش لو:
- المشكلة I/O-bound (DB، HTTP، file read). في الحالة دي async/await العادي بيكفي والـ libuv thread pool بيشتغل.
- العملية أقل من 5ms. تكلفة الـ message-passing هتاكل الفايدة.
- عندك مكتبة C++ بتعمل الـ heavy lifting في native code وبتفلت الـ event loop (مثل sharp أو argon2).
- السيرفر معاه vCPU واحد. مفيش parallelism حقيقي، الـ workers هيتنافسوا على نفس الـ core.
الخطوة التالية
دلوقتي ضيف monitorEventLoopDelay في تطبيقك واطبع P99 كل 5 ثواني للـ logs. شغّله 24 ساعة على إنتاج. لو شفت أي قيمة فوق 100ms، حدّد الـ endpoint من الـ traces، وبعدين قرّر هل العملية فعلاً CPU-bound (worker)، ولا هي I/O متخفية كـ sync (مثل fs.readFileSync اللي بتنسى تشيلها). الـ trade-off واضح: 30 سطر كود مقابل سيرفر بيرد في الميلي ثانية تحت الضغط.