لغات البرمجة عملياً الأدلة التقنية

التزامن في JavaScript: الوعود Promises والعمال Workers

مئة طلب HTTP نزلت من 5.6 ثانية إلى 284 ميلي ثانية بالوعود وحدها، وحساب ثقيل لم يتحرك إلا بالعمال: نموذج التزامن في Node.js 26 بقياسات حقيقية، مع سباق وجمود حقيقيين وإصلاحهما.

التزامن في JavaScript: الوعود Promises والعمال Workers

لنفرض أن خدمة الطلبات في شركة ناشئة تبيع القهوة والتمور مكتوبة بـ Node.js، وفي أسبوع واحد ظهرت فيها ثلاث شكاوى لا علاقة ظاهرة بينها. الأولى من فريق العمليات: مهمة ليلية تسأل شريك التوصيل عن حالة مئة شحنة، وتأخذ دقائق مع أن كل سؤال يرجع في عشرات الميلي ثواني. والثانية من العملاء: الموقع كله يتجمد ثواني كلما أصدر المحاسب دفعة فواتير موقعة، لأن نقطة التوقيع تحسب هاشات كثيرة. والثالثة من المخزن: منتج بقيت منه عشر علب بيع منه عشرون في دقيقة عرض واحدة، مع أن الكود يفحص المخزون قبل كل طلب. والمطور القادم من Java يقرأ الشكاوى الثلاث فيقترح الحل المعتاد: خيوط أكثر Threads. والإجابة في Node.js مختلفة لكل شكوى، فالأولى لا تحتاج أي خيط إضافي، والثانية لا تحلها إلا خيوط إضافية، والثالثة حدثت أصلاً بدون أي خيط إضافي.

وهذا المقال من مرحلة JavaScript وTypeScript، وهو يبني على مقالين منشورين: مقال حلقة الأحداث Event Loop الذي شرح ترتيب المهام والوعود ومراحل الحلقة في Node.js بالتفصيل، ومقال الخيوط والأقفال وasync للمبرمجين الذي شرح المفاهيم العامة في كل اللغات. فلن نعيد شرح الحلقة، وإنما سوف نقيس الشكاوى الثلاث بنفس المهمتين اللتين نقيس بهما كل لغات المسار حتى تقارن بينها: مئة طلب HTTP إلى خدمة محلية ترد بعد 50 ميلي ثانية، وهاش SHA-256 لألفي نص كل منها ألفي مرة، تسلسلياً ثم بالتزامن، ثم نصنع سباقاً حقيقياً وجموداً حقيقياً ونصلحهما. وكل الأرقام من تشغيل داخل Containers من node:26.13.0-slim، والملفات كاملة في مجلد lab.

وسوف نناقش في هذا المقال ما يلي:

  • نموذج التزامن في Node.js: الخيط الرئيسي، ومجموعة خيوط libuv، والعمال worker_threads، ومتى يعمل كل منها.
  • الوعد Promise وasync/await من الداخل، ودوال الجمع Promise.all وallSettled وany وrace.
  • التجربة الأولى: مئة طلب HTTP تسلسلياً، ثم معاً، ثم بمجموعات محدودة، ولماذا كانت المجموعة أسرع من المئة معاً.
  • التجربة الثانية: أربعة ملايين هاش على الخيط الرئيسي، ثم بالوعود، ثم بعامل واحد حتى ثمانية، مع حد المعالج في Docker.
  • السباق Race Condition بين await وآخر على خيط واحد، والسباق في الذاكرة المشتركة بين العمال، وإصلاح كل منهما.
  • الجمود Deadlock بقفلين، ولماذا خرج Node.js برمز 13 دون أي رسالة خطأ.
  • الأخطاء الشائعة، وجدول قرار لكل نوع من العمل.

نموذج التزامن Concurrency Model في Node.js

قبل الأرقام نحتاج تفريقاً واحداً يتكرر في كل المقال، وهو الفرق بين التزامن Concurrency والتوازي Parallelism. فالتزامن أن يكون لديك عدة أعمال قيد التنفيذ في نفس الفترة، حتى لو كان المنفذ واحداً ينتقل بينها، والتوازي أن تنفذ عدة أعمال في نفس اللحظة فعلاً على عدة أنوية. وقد شرحنا الفرق بالمعالج والأنوية في مقال المعالج والخيوط والتزامن. وNode.js مصمم للتزامن بدون توازي في كودك: خيط رئيسي واحد Main Thread ينفذ كل JavaScript تكتبه، ولكنه لا ينتظر أبداً، فعندما ترسل طلب شبكة يسجل الـ Socket عند نظام التشغيل، مثل epoll في Linux، ويعود الخيط إلى عمل آخر، وعندما يصل الرد تضعه الحلقة في الطابور.

وحول الخيط الرئيسي يوجد نوعان من الخيوط الأخرى، والصورة التالية تبين الثلاثة:

ثلاثة صناديق: الخيط الرئيسي الذي ينفذ كل JavaScript وينتظر الشبكة عبر epoll بدون خيط إضافي، ومجموعة خيوط libuv بأربعة خيوط افتراضياً للملفات وdns.lookup وcrypto.pbkdf2 وzlib، والعمال worker_threads التي تنشئها أنت ولكل منها V8 وHeap وحلقة خاصة، وتحتها قاعدة أن عمل I/O يكفيه Promise.all وعمل CPU يحتاج خيوطاً أخرى
كودك على خيط واحد، والانتظار عند نظام التشغيل، والحساب الثقيل على عمال
  • مجموعة خيوط libuv Thread Pool: أربعة خيوط افتراضياً، كما في توثيق libuv، تستخدمها مكتبات Node.js الداخلية للعمليات التي لا يوفر لها نظام التشغيل واجهة غير متزامنة جيدة: قراءة الملفات، وdns.lookup، والدوال غير المتزامنة الثقيلة في crypto مثل pbkdf2 وscrypt، والضغط في zlib. ولا تكتب فيها أي كود، ولكن حجمها يؤثر عليك، فإذا أرسلت مئة عملية pbkdf2 معاً فأربع فقط تعمل في كل لحظة، ويمكن تغيير الحجم بمتغير البيئة UV_THREADPOOL_SIZE.
  • العمال worker_threads: خيوط تنشئها أنت، ولكل عامل نسخة V8 خاصة وHeap خاص وحلقة أحداث خاصة، فلا يشارك أي متغير مع الخيط الرئيسي، وإنما يتواصلان برسائل تنسخ، أو بذاكرة مشتركة صريحة من نوع SharedArrayBuffer. وهذا تصميم مقصود، فأغلب أخطاء الخيوط في Java وC# تأتي من متغيرات يشاركها خيطان دون أن ينتبه أحد، وهنا لا يمكن أن يحدث ذلك إلا إذا طلبته.

ولذلك إذا فتحت قائمة الخيوط لعملية Node.js على Linux فلن تجد خيطاً واحداً، وإنما أكثر من عشرة: الخيط الرئيسي، وخيوط libuv الأربعة، وخيوط مساعدة يستخدمها V8 نفسه لجمع القمامة بالتوازي وللترجمة في الخلفية، ويتحكم في عددها الخيار --v8-pool-size. فعبارة «Node.js يعمل بخيط واحد» صحيحة عن كودك أنت فقط، وليست عن العملية كلها، وهذا يفسر لماذا ترى استهلاك المعالج للعملية أعلى من 100% أحياناً حتى بدون أي عامل.

والقاعدة التي تخرج من هذا النموذج، ويشرحها دليل لا تحجز حلقة الأحداث في توثيق Node.js، أن كل طلب يصل إلى السيرفر يعالج على نفس الخيط، فكل ميلي ثانية يقضيها كودك في حساب متواصل هي ميلي ثانية ينتظرها كل الطلبات الأخرى. والعمل ينقسم إلى نوعين: عمل ينتظر I/O-bound، مثل طلبات الشبكة وقاعدة البيانات، وهذا يكفيه خيط واحد مهما كثر، وعمل يحسب CPU-bound، مثل الهاش والضغط وتحويل الصور، وهذا يجب أن يخرج من الخيط الرئيسي. والتجربتان التاليتان تقيسان النوعين.

الوعد Promise مع async/await: ما الذي يحدث فعلاً؟

الوعد كائن يمثل نتيجة لم تصل بعد، وله ثلاث حالات: معلق Pending، ثم إما محقق Fulfilled بقيمة، أو مرفوض Rejected بخطأ، ولا يتغير بعد ذلك. والدالة المعلمة بـ async ترجع وعداً دائماً، والكلمة await داخلها تقول: «أوقف هذه الدالة هنا، وأعد الخيط إلى الحلقة، وعندما يستقر الوعد أكمل من هذا السطر». والنقطة التي يخطئ فيها القادمون من لغات أخرى أن await لا يوقف الخيط، وإنما يوقف الدالة فقط، فالخيط يذهب ليعالج طلبات أخرى، ولهذا يستطيع Node.js أن يخدم آلاف الاتصالات بخيط واحد. والوجه الآخر لنفس الحقيقة أن await داخل حلقة يجعل الطلبات تنتظر بعضها واحداً بعد الآخر، حتى لو كانت مستقلة تماماً، وهذا ما سوف تقيسه التجربة الأولى.

ولتشغيل عدة وعود معاً تعطيك اللغة أربع دوال، ولكل منها معنى مختلف عند الفشل:

  • Promise.all: ينتظر الكل، ويرجع مصفوفة النتائج بنفس الترتيب، ولكنه يرفض فوراً عند أول فشل، والوعود الباقية تستمر في العمل دون أن ينتظرها أحد.
  • Promise.allSettled: ينتظر الكل دائماً، ويرجع لكل وعد حالته وقيمته أو خطأه، وهو المناسب عندما تريد معرفة ما نجح وما فشل، مثل حالة مئة شحنة يفشل بعضها.
  • Promise.any: أول نجاح يكفي، وإذا فشل الكل يرمي AggregateError، ويناسب السؤال من عدة مصادر متشابهة.
  • Promise.race: أول وعد يستقر، نجاحاً أو فشلاً، ويستخدم غالباً مع مهلة زمنية، وإن كان الأفضل اليوم أن تمرر AbortSignal.timeout إلى fetch مباشرة، لأنه يلغي الطلب فعلاً بدلاً من أن يتجاهل نتيجته.

من الـ Callbacks إلى async/await: لماذا تبدو الصيغة هكذا؟

إذا قرأت كود Node.js قديماً، أو مكتبة كتبت قبل 2017، فسوف ترى شكلاً مختلفاً تماماً عن await، ومن المفيد أن تعرف من أين جاء، لأن آثاره ما زالت في التوثيق وفي أسماء الدوال. ففي السنوات الأولى كانت كل دالة غير متزامنة في Node.js تأخذ دالة رد Callback كآخر معامل، وتستدعيها عند الانتهاء بمعاملين، الخطأ أولاً ثم النتيجة، وهذا ما يسمى أسلوب الخطأ أولاً Error-first Callback، كما في pbkdf2(password, salt, iterations, keylen, digest, (err, key) => {...}) الذي سوف نستخدمه بعد قليل. والمشكلة ظهرت عندما تحتاج عدة خطوات متتالية: اقرأ الطلب، ثم اقرأ المنتج، ثم احفظ، ثم أرسل البريد، فكل خطوة داخل رد الخطوة السابقة، ويتحرك الكود إلى اليمين مع كل مستوى، وكل مستوى يجب أن يفحص err بنفسه، ونسيان فحص واحد يعني خطأ يضيع بصمت. وكان المطورون يسمون هذا الشكل «جحيم الردود» Callback Hell.

ثم جاءت الوعود في ES2015 كقيمة تمثل النتيجة المستقبلية، فصارت الخطوات سلسلة .then().then().catch() على مستوى واحد، وصار الخطأ ينتقل تلقائياً عبر السلسلة حتى أول catch، ثم جاءت async وawait في ES2017 لتجعل نفس السلسلة تكتب كأنها كود متزامن عادي، مع try/catch العادية. والمهم أن تفهم أن await ليست شيئاً جديداً تحت الغطاء، وإنما صيغة أجمل لنفس الوعود، فكل ما يصح على الوعود يصح عليها، بما في ذلك أنها لا تنشئ أي خيط. وأغلب واجهات Node.js الحديثة لها نسخة بالوعود، مثل node:fs/promises وnode:timers/promises، وللدوال القديمة القائمة على الردود توجد الدالة util.promisify التي تحولها إلى دالة ترجع وعداً، فلا تكتب ردوداً جديدة في مشروع جديد.

التجربة الأولى Lab: مئة طلب HTTP إلى خدمة بطيئة I/O-bound

نبدأ بالشكوى الأولى. نحتاج خدمة محلية ترد بعد 50 ميلي ثانية بالضبط، وتعد كم طلباً يصلها في نفس اللحظة، حتى نرى التزامن من جهة السيرفر وليس من جهة العميل فقط:

// Local slow endpoint: answers every request after 50 ms.  PORT=8080 node slow-server.mjs
import { createServer } from 'node:http';
let inFlight = 0, maxInFlight = 0, total = 0;
createServer((req, res) => {
  if (req.url === '/stats') { res.end(JSON.stringify({ total, maxInFlight })); maxInFlight = 0; total = 0; return; }
  inFlight++; total++; maxInFlight = Math.max(maxInFlight, inFlight);
  setTimeout(() => { inFlight--; res.setHeader('content-type', 'application/json'); res.end('{"price_halalas":990}'); }, 50);
}).listen(process.env.PORT || 8080, () => console.log('slow server on', process.env.PORT || 8080));

ولاحظ أن الرد يكتب بـ res.end مرة واحدة، الرأس والجسم معاً، والسبب عملي: السيرفر الذي يكتب الرأس في كتابة والجسم في كتابة ثانية على اتصال Keep-Alive قد يصطدم بخوارزمية Nagle مع التأكيد المتأخر Delayed ACK في TCP، فيضيف حوالي 40 ميلي ثانية لكل طلب، ويصبح القياس كله خاطئاً. لذلك أول ما يطبعه ملف العميل هو زمن خمسة طلبات منفردة، حتى نتأكد أن الطلب الواحد قريب من 50 فعلاً قبل أن نقيس أي شيء. وملف العميل يجرب خمس طرق لنفس المئة طلب:

// I/O-bound task: 100 HTTP calls to an endpoint that takes 50 ms each.  BASE=http://slow:8080 node io.mjs
const BASE = process.env.BASE || 'http://127.0.0.1:8080';
const N = 100;
const call = async (i) => { const r = await fetch(`${BASE}/price/${i}`); return (await r.json()).price_halalas; };
const stats = async () => (await fetch(`${BASE}/stats`)).json();
async function timeIt(label, fn) {
  await stats();
  const t0 = performance.now();
  const out = await fn();
  const ms = performance.now() - t0;
  const s = await stats();
  console.log(label.padEnd(30), `${ms.toFixed(0).padStart(6)} ms`, `results=${out.length}`, `server saw max ${s.maxInFlight} at once`);
}
await call(0);   // warm up the connection pool
for (let i = 0; i < 5; i++) { const t0 = performance.now(); await call(i); console.log(`single call ${i + 1}: ${(performance.now() - t0).toFixed(1)} ms`); }
await timeIt('sequential (await in loop)', async () => { const out = []; for (let i = 0; i < N; i++) out.push(await call(i)); return out; });
await timeIt('Promise.all (100 at once)', () => Promise.all(Array.from({ length: N }, (_, i) => call(i))));
async function pool(limit) {
  const out = new Array(N); let next = 0;
  const worker = async () => { while (next < N) { const i = next++; out[i] = await call(i); } };
  await Promise.all(Array.from({ length: limit }, worker));
  return out;
}
for (const limit of [5, 10, 25]) await timeIt(`pool of ${limit}`, () => pool(limit));
const settled = await Promise.allSettled([call(1), fetch('http://127.0.0.1:1/'), call(2)]);
console.log('allSettled:', settled.map(s => s.status).join(', '));

والدالة pool هي أبسط طريقة لتحديد عدد الطلبات المتزامنة دون أي مكتبة: ننشئ عدداً محدوداً من «العمال» غير المتزامنين، وكل واحد يأخذ رقم الطلب التالي من عداد مشترك حتى تنتهي القائمة. والعداد next++ آمن هنا لأن كل JavaScript تعمل على خيط واحد، فلا يمكن أن يقرأ عاملان نفس القيمة، وسوف ترى بعد قليل متى لا يكون هذا الأمان كافياً. وشغلنا الخدمة في Container والعميل في Container آخر على نفس شبكة Docker:

docker network create slj-js-net
docker run -d --rm --name slj-js-slow --network slj-js-net -v "$PWD:/lab:ro" -w /lab node:26.13.0-slim node slow-server.mjs
docker run --rm --network slj-js-net -e BASE=http://slj-js-slow:8080 -v "$PWD:/lab:ro" -w /lab node:26.13.0-slim node io.mjs

والمخرج سوف يكون كما يلي:

single call 1: 60.3 ms
single call 2: 58.4 ms
single call 3: 55.0 ms
single call 4: 54.9 ms
single call 5: 55.1 ms
sequential (await in loop)       5571 ms results=100 server saw max 1 at once
Promise.all (100 at once)         357 ms results=100 server saw max 99 at once
pool of 5                        1059 ms results=100 server saw max 5 at once
pool of 10                        563 ms results=100 server saw max 10 at once
pool of 25                        284 ms results=100 server saw max 25 at once
allSettled: fulfilled, rejected, fulfilled

وكررنا التشغيل مرتين أخريين، فكانت الأرقام كما يلي، وكل الأرقام بالميلي ثانية:

الطريقةالتشغيل 1التشغيل 2التشغيل 3أقصى عدد معاً عند السيرفر
await داخل الحلقة5571604354281
Promise.all للمئة35756437499، ثم 84، ثم 99
مجموعة 51059133410775
مجموعة 1056375260010
مجموعة 2528438231225
أعمدة أفقية للتشغيل الأول: await داخل الحلقة 5571 ms بطلب واحد في كل لحظة، ومجموعة 5 بـ 1059 ms، ومجموعة 10 بـ 563 ms، ومجموعة 25 بـ 284 ms، وPromise.all للمئة بـ 357 ms مع 99 طلباً معاً، وتحتها ملاحظات عن زمن الطلب الواحد 55 ms وعن الاتصالات الجديدة
نفس الخيط ونفس المئة طلب، والفرق كله في عدد الطلبات التي تنتظر معاً

وهذا المخرج يقرأ كما يلي:

  • الطلب الواحد 55 ميلي ثانية: 50 من الخدمة، والباقي شبكة Docker بين Containerين، والطلب الأول أبطأ قليلاً لأن المحرك لم يترجم الكود بعد. فلا توجد مشكلة Nagle، والقياس صالح.
  • التسلسل 5.6 ثانية: مئة ضرب 55 تقريباً، والسيرفر لم ير أكثر من طلب واحد في أي لحظة. وهذه هي الشكوى الأولى في القصة بالضبط، فالكود كتب await داخل for لأن هذا ما يبدو طبيعياً لمن جاء من لغة متزامنة، والخيط كان فارغاً 99% من الوقت.
  • المجموعات تنزل بالتناسب: مع 5 طلبات معاً صار الزمن خمس الزمن تقريباً، ومع 10 عشره، ومع 25 أقل من 300 ميلي ثانية، أي أربع دفعات من 25 كل منها 55 ميلي ثانية تقريباً، مع وقت تنظيم صغير.
  • Promise.all للمئة لم يكن الأسرع: في التشغيلات الثلاثة كان أبطأ من مجموعة 25، مع أنه يرسل كل شيء معاً. والسبب أن fetch في Node.js مبني على مكتبة undici التي تحتفظ بالاتصالات المفتوحة لتعيد استخدامها، والمجموعة المحدودة تعيد استخدام 25 اتصالاً مفتوحاً، أما المئة معاً فتجبر العميل على فتح عشرات اتصالات TCP جديدة دفعة واحدة، والسيرفر على قبولها، وهذا له ثمن. ولاحظ أيضاً أن السيرفر رأى 84 فقط في التشغيل الثاني، أي أن بعض الطلبات وصلت بعد أن انتهى غيرها.
  • allSettled: الطلب الثاني كان إلى منفذ مغلق، فرفض، ومع ذلك وصلت نتيجتا الطلبين الآخرين، ولو كان Promise.all لرفض كله وضاعت النتيجتان.

وانظر إلى نفس التجربة من جهة السيرفر، فهو أيضاً Node.js بخيط واحد، ومع ذلك كان لديه 99 طلباً مفتوحاً في نفس اللحظة، وأجاب عنها كلها بعد 50 ميلي ثانية تقريباً، لأن كل طلب عنده ليس إلا مؤقتاً في الحلقة ينتظر دوره. ولو كان كل طلب يحجز خيطاً من نظام التشغيل كما في السيرفرات التقليدية، لاحتاج 99 خيطاً بمكدساتها لنفس النتيجة. وهذا بالضبط ما يجعل Node.js مناسباً لخدمات الواجهات Gateways والخدمات التي أغلب عملها انتظار لخدمات أخرى: التكلفة الحقيقية للطلب المعلق صغيرة جداً، ما دام لا أحد يحسب شيئاً ثقيلاً على نفس الخيط.

وقد يتساءل البعض: إذا كان التزامن يسرع بهذا الشكل، فلماذا لا نرسل ألف طلب معاً دائماً؟ والإجابة أن الطرف الآخر له حدود. فشريك التوصيل في القصة يحدد في الغالب عدد الطلبات في الثانية Rate Limit ويرد بالرمز 429 عند تجاوزه، وقاعدة البيانات لها مجموعة اتصالات Pool محدودة، وكل طلب مفتوح يستهلك ذاكرة واتصالاً عند الطرفين. لذلك فإن الشكل الصحيح في الإنتاج هو التزامن المحدود: عدد ثابت من الطلبات معاً يختار بحسب حدود الطرف الآخر، مع مهلة زمنية لكل طلب، وليس Promise.all على قائمة لا تعرف طولها. والمكتبات المعروفة لهذا مثل p-limit تفعل ما تفعله دالة pool أعلاه بنفس الفكرة.

ونفس الفكرة تعمل داخل الخدمة نفسها دون أن تكتبها، فمكتبة pg التي سوف نستخدمها مع PostgreSQL تحتفظ بمجموعة اتصالات Pool بحد افتراضي عشرة، فإذا أرسل كودك مئة استعلام بـ Promise.all فعشرة فقط تصل إلى قاعدة البيانات في كل لحظة، والتسعون الباقية تنتظر في طابور داخل المكتبة. وهذا جيد لأنه يحمي قاعدة البيانات، ولكنه يعني أن زمن الطلب الذي تراه في السجلات يشمل وقت الانتظار في هذا الطابور، فإذا ارتفع زمن الاستعلامات فجأة تحت الضغط فراجع حجم المجموعة وعدد الطلبات المتزامنة قبل أن تبحث عن فهرس ناقص.

التجربة الثانية Lab: حساب ثقيل CPU-bound على خيط واحد وعلى عمال

الآن الشكوى الثانية، حيث يتجمد الموقع كله أثناء توقيع الفواتير. والمهمة التي نقيس بها كل لغات المسار: ألفا نص، كل نص يمر على SHA-256 ألفي مرة، كل مرة على ناتج المرة السابقة، أي أربعة ملايين عملية هاش. والملف يقيس ست طرق، ومع كل طريقة يبدأ مؤقتاً مدته 10 ميلي ثانية في لحظة بدء العمل، ويسجل متى نفذ فعلاً، وهذا الرقم هو بالضبط ما سيشعر به أي طلب HTTP يصل إلى السيرفر في نفس اللحظة:

// CPU-bound task: SHA-256 of 2,000 strings, each hashed 2,000 rounds.  node cpu.mjs
import { createHash, hash } from 'node:crypto';
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { availableParallelism } from 'node:os';
const STRINGS = 2000, ROUNDS = 2000;
const work = (from, to, oneShot = false) => {
  let x = 0;
  for (let s = from; s < to; s++) {
    let h = `order-${s}`;
    for (let r = 0; r < ROUNDS; r++) h = oneShot ? hash('sha256', h) : createHash('sha256').update(h).digest('hex');
    x ^= parseInt(h.slice(0, 8), 16);
  }
  return x;
};
if (!isMainThread) { parentPort.postMessage(work(workerData.from, workerData.to)); }
else {
  console.log('availableParallelism', availableParallelism());
  const t = async (label, fn) => {
    // A 10 ms timer started with the work: when does it really fire? That is the wait any HTTP request would see.
    const t0 = performance.now(); let fired = -1;
    setTimeout(() => { fired = performance.now() - t0; }, 10);
    const r = await fn(); const ms = performance.now() - t0;
    await new Promise((ok) => setTimeout(ok, 20));
    console.log(label.padEnd(28), `${ms.toFixed(0).padStart(6)} ms`, `checksum=${r >>> 0}`, `10 ms timer fired after ${fired.toFixed(0)} ms`);
  };
  await t('sequential, main thread', async () => work(0, STRINGS));
  await t('sequential, crypto.hash()', async () => work(0, STRINGS, true));
  await t('Promise.all, main thread', async () => {
    const parts = await Promise.all(Array.from({ length: 4 }, (_, i) => (async () => work(i * 500, (i + 1) * 500))()));
    return parts.reduce((a, b) => a ^ b, 0);
  });
  for (const n of [1, 2, 4, 8]) {
    await t(`worker_threads x${n}`, () => Promise.all(Array.from({ length: n }, (_, i) => new Promise((ok, fail) => {
      const per = STRINGS / n;
      const w = new Worker(new URL(import.meta.url), { workerData: { from: i * per, to: (i + 1) * per } });
      w.once('message', ok); w.once('error', fail);
    }))).then(parts => parts.reduce((a, b) => a ^ b, 0)));
  }
}

ولاحظ أن نفس الملف يعمل كبرنامج رئيسي وكعامل، فـ isMainThread تفرق بينهما، والعامل يأخذ نطاق النصوص في workerData، ويحسب، ويرسل الناتج برسالة واحدة. والقيمة checksum هي XOR لأول 32 بت من كل هاش نهائي، وفائدتها أن تتأكد أن كل الطرق حسبت نفس الشيء بالضبط، فلا تقارن سرعة طريقة أخطأت في الحساب. وشغلنا الملف مرتين بلا حد على المعالج، ومرة ثالثة بالخيار --cpus=2:

--- run 1 (no CPU limit)
availableParallelism 8
sequential, main thread       22406 ms checksum=2348160800 10 ms timer fired after 22420 ms
sequential, crypto.hash()      9997 ms checksum=2348160800 10 ms timer fired after 9997 ms
Promise.all, main thread      29726 ms checksum=2348160800 10 ms timer fired after 29744 ms
worker_threads x1             27868 ms checksum=2348160800 10 ms timer fired after 17 ms
worker_threads x2             11945 ms checksum=2348160800 10 ms timer fired after 10 ms
worker_threads x4              9882 ms checksum=2348160800 10 ms timer fired after 35 ms
worker_threads x8              4757 ms checksum=2348160800 10 ms timer fired after 21 ms
--- run 2 (no CPU limit)
availableParallelism 8
sequential, main thread       19559 ms checksum=2348160800 10 ms timer fired after 19560 ms
sequential, crypto.hash()      9860 ms checksum=2348160800 10 ms timer fired after 9861 ms
Promise.all, main thread      27266 ms checksum=2348160800 10 ms timer fired after 27283 ms
worker_threads x1             24156 ms checksum=2348160800 10 ms timer fired after 10 ms
worker_threads x2             15911 ms checksum=2348160800 10 ms timer fired after 20 ms
worker_threads x4              8120 ms checksum=2348160800 10 ms timer fired after 13 ms
worker_threads x8              5901 ms checksum=2348160800 10 ms timer fired after 18 ms
--- run 3 (--cpus=2)
availableParallelism 8
sequential, main thread       26849 ms checksum=2348160800 10 ms timer fired after 26887 ms
sequential, crypto.hash()     10956 ms checksum=2348160800 10 ms timer fired after 10957 ms
Promise.all, main thread      24168 ms checksum=2348160800 10 ms timer fired after 24179 ms
worker_threads x1             21464 ms checksum=2348160800 10 ms timer fired after 10 ms
worker_threads x2             12322 ms checksum=2348160800 10 ms timer fired after 17 ms
worker_threads x4             13528 ms checksum=2348160800 10 ms timer fired after 10 ms
worker_threads x8             12438 ms checksum=2348160800 10 ms timer fired after 18 ms
أعمدة أفقية للتشغيل الأول: الخيط الرئيسي مع createHash بـ 22406 ms، ومع crypto.hash بـ 9997 ms، وPromise.all على الخيط الرئيسي بـ 29726 ms، والعمال من واحد إلى ثمانية بـ 27868 و11945 و9882 و4757 ms، وملاحظتان: Promise.all لم يكن أسرع، والمؤقت نفذ بعد 22420 ms على الخيط الرئيسي وبين 10 و35 ms مع العمال
الوعود لا تسرع الحساب، والعمال تسرعه وتحرر الخيط الرئيسي

وقبل قراءة الأرقام يجب أن نقول بوضوح إن الجهاز الذي شغلنا عليه التجربة كان مشغولاً بأعمال أخرى في نفس الوقت، ولذلك تتغير الأرقام بين التشغيلات بنسبة واضحة، فالتسلسل مثلاً أخذ 22.4 ثانية مرة و19.6 مرة أخرى. والمهم هو الترتيب والنسب التي تكررت في كل التشغيلات، وهي كما يلي:

  • Promise.all على الخيط الرئيسي لم يسرع شيئاً: كان أبطأ من التسلسل في التشغيلين الأولين وأسرع قليلاً في الثالث، وهذا ضمن ضجيج الجهاز. والسبب أن الدالة work متزامنة، فوضعها داخل دالة async لا يجعلها تعمل بالتوازي، والأجزاء الأربعة عملت واحداً بعد الآخر على نفس الخيط. وهذا أهم ما في التجربة لمن جاء من لغة فيها Task.Run أو ExecutorService: الوعد ليس خيطاً.
  • المؤقت نفذ بعد 22 ثانية: في كل الطرق التي عملت على الخيط الرئيسي، المؤقت الذي طلب 10 ميلي ثانية نفذ بعد انتهاء العمل كله، أي أن أي طلب HTTP وصل أثناء الحساب انتظر 22 ثانية. وهذه هي الشكوى الثانية في القصة: نقطة التوقيع لم تكن بطيئة وحدها، وإنما جمدت السيرفر كله.
  • مع العمال نفذ المؤقت بعد 10 إلى 35 ميلي ثانية: حتى مع عامل واحد لم يتسرع الحساب، ولكن الخيط الرئيسي بقي حراً يخدم الطلبات الأخرى. وهذا وحده سبب كاف لنقل أي حساب ثقيل إلى عامل، حتى قبل أن تفكر في السرعة.
  • ثمانية عمال أسرع بأربع إلى خمس مرات: من 22.4 ثانية إلى 4.8 في التشغيل الأول، ومن 19.6 إلى 5.9 في الثاني. وليس ثماني مرات، لأن الأنوية الثمانية كانت تشارك أعمالاً أخرى على الجهاز، ولأن إنشاء كل عامل يكلف تحميل V8 جديد.
  • crypto.hash() ضاعف السرعة بدون أي خيط: الدالة الجديدة في وحدة crypto تحسب الهاش بنداء واحد، بدلاً من إنشاء كائن Hash ثم update ثم digest لكل جولة، فنزل الزمن من 22.4 ثانية إلى 10 على نفس الخيط. والدرس أن أول سؤال عند الحساب الثقيل هو: هل أستخدم الأداة الصحيحة؟ قبل أن تسأل: كم خيطاً أضيف؟

مشكلة واجهناها: مقياس التأخير Event Loop Lag الذي أعطى صفراً

في النسخة الأولى من الملف قسنا تأخير الحلقة بمؤقت دوري setInterval كل 10 ميلي ثانية يحسب الفرق بين الموعد المتوقع والموعد الفعلي، وكانت النتيجة للخيط الرئيسي كما يلي:

sequential, main thread       22160 ms checksum=2348160800 max event-loop lag 0 ms
Promise.all, main thread      22315 ms checksum=2348160800 max event-loop lag 0 ms
worker_threads x1             24438 ms checksum=2348160800 max event-loop lag 126 ms

أي أن الخيط الرئيسي المشغول 22 ثانية «لم يتأخر أبداً»، والعمال أخروه 126 ميلي ثانية، وهذا عكس الحقيقة تماماً. والسبب أن المؤقت الدوري نفسه يعمل على الخيط الرئيسي، فلم يستطع أن ينفذ مرة واحدة أثناء الحساب، ثم أوقفناه بـ clearInterval مباشرة بعد انتهاء العمل وقبل أن تتاح له فرصة التنفيذ، فلم يسجل شيئاً. أي أن أداة القياس تعرضت لنفس المشكلة التي تقيسها. والحل في النسخة الحالية أن نبدأ مؤقتاً واحداً مع العمل، وننتظر 20 ميلي ثانية بعد العمل حتى يأخذ دوره، ثم نسجل متى نفذ فعلاً. والدرس للمراقبة في الإنتاج: مقياس تأخير الحلقة يجب أن يقرأ من مكان لا يحجزه نفس العمل، مثل monitorEventLoopDelay الذي يقيس من داخل libuv كما رأينا في مقال حلقة الأحداث، أو من فحص صحة خارجي يرسل طلباً للسيرفر.

ماذا يحدث إذا حددت المعالج بـ --cpus=2؟

التشغيل الثالث بالخيار --cpus=2 يحاكي Container في الإنتاج بحصة معالجين، وهو أكثر ما ستراه في Kubernetes. ولاحظ السطر الأول: availableParallelism 8، أي أن Node.js داخل الـ Container ما زال يرى ثمانية أنوية، لأن الحصة Quota لا تخفي الأنوية، وإنما تحدد كم وقتاً يحصل عليه الـ Container منها في كل فترة. والنتيجة أن عاملين أخذا 12.3 ثانية، وأربعة 13.5، وثمانية 12.4، أي أن كل ما فوق اثنين لم يفد بشيء، لأن الحصة كلها تكفي نواتين. فإذا كان كودك يحسب حجم مجموعة العمال من availableParallelism() وحدها فسوف ينشئ ثمانية عمال في Container بنواتين، يتنافسون على نفس الحصة، ويستهلكون ذاكرة ثمانية Heaps. والقاعدة: حدد عدد العمال من الحصة الفعلية، بمتغير بيئة تضعه مع حد المعالج في ملف النشر، ولا تعتمد على عدد الأنوية الذي يراه البرنامج.

ماذا يحدث عندما تمتلئ مجموعة libuv؟ UV_THREADPOOL_SIZE

قلنا إن بعض دوال crypto غير المتزامنة تعمل على مجموعة خيوط libuv، وهذا يعني أنها لا تحجز الخيط الرئيسي، ولكنها محدودة بحجم المجموعة. فشغلنا ثماني عمليات pbkdf2 معاً، وهي الدالة التي تستخدم لاشتقاق مفاتيح من كلمات المرور، مرة بالحجم الافتراضي ومرة بحجم ثمانية:

// libuv thread pool: 8 async pbkdf2 calls at once.  UV_THREADPOOL_SIZE=4 node pool.mjs
import { pbkdf2 } from 'node:crypto';
const one = (i) => new Promise((ok, fail) => {
  const t0 = performance.now();
  pbkdf2(`pass-${i}`, 'salt', 200_000, 32, 'sha256', (err) => err ? fail(err) : ok(performance.now() - t0));
});
const t0 = performance.now();
const each = await Promise.all(Array.from({ length: 8 }, (_, i) => one(i)));
console.log(`UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE ?? '(default 4)'} total ${(performance.now() - t0).toFixed(0)} ms, each finished after:`, each.map(ms => ms.toFixed(0)).join(' '));

والمخرج سوف يكون كما يلي:

UV_THREADPOOL_SIZE=(default 4) total 781 ms, each finished after: 315 295 391 340 533 542 597 769
UV_THREADPOOL_SIZE=8 total 582 ms, each finished after: 274 258 373 345 410 331 567 580

مع الحجم الافتراضي ترى موجتين: أول أربع عمليات انتهت بين 295 و391 ميلي ثانية، والأربع التالية لم تبدأ إلا بعد أن تحرر خيط، فانتهت بين 533 و769. ومع ثمانية خيوط عملت كلها معاً تقريباً، فنزل المجموع من 781 إلى 582، ولم ينزل إلى النصف لأن الجهاز كان مشغولاً بأعمال أخرى كما ذكرنا. وهذا مهم في خدمة تسجيل دخول تستخدم pbkdf2 أو scrypt أو bcrypt لكلمات المرور: الخيط الرئيسي حر، ولكن الطلب الخامس ينتظر في طابور غير مرئي، وقراءة الملفات وdns.lookup تنتظر في نفس الطابور. فإذا رأيت زمن قراءة ملف بسيط يرتفع وقت ذروة تسجيل الدخول، فهذا هو السبب، ويمكن زيادة الحجم بمتغير البيئة عند بدء العملية، مع تذكر أن الخيوط الإضافية لا تفيد إذا كانت حصة المعالج نفسها لا تكفي.

العمال في الإنتاج: مجموعة ثابتة Worker Pool وليس عاملاً لكل طلب

في التجربة أنشأنا عمالاً جدداً لكل قياس، وهذا مقبول لمهمة تأخذ ثواني، ولكنه خطأ في سيرفر يستقبل طلبات صغيرة، لأن كل عامل جديد يحمل نسخة V8 كاملة ويقرأ الملف ويترجمه، وهذا يكلف عشرات الميلي ثواني وعدة ميجابايتات. والشكل الصحيح هو مجموعة عمال تنشأ عند بدء الخدمة، وطابور مهام يوزع عليها، وهذا ما تفعله مكتبات مثل Piscina. وانتبه أيضاً إلى أن الرسائل بين الخيط الرئيسي والعامل تنسخ بخوارزمية structuredClone، فإرسال مصفوفة كبيرة يكلف نسخها، والبديل إما نقل الملكية Transfer لـ ArrayBuffer فينتقل بدون نسخ ويصبح فارغاً عند المرسل، وإما ذاكرة مشتركة SharedArrayBuffer، وهذه تفتح الباب لنوع جديد من الأخطاء كما سوف نرى.

السباق Race Condition بدون أي خيط إضافي

الشكوى الثالثة في القصة أغرب الثلاث: مخزون عشر علب بيع منه عشرون، والخدمة كلها خيط واحد، فكيف حدث سباق؟ والإجابة أن السباق لا يحتاج خيطين، وإنما يحتاج نقطة توقف بين القراءة والكتابة، وawait نقطة توقف. والملف التالي يحاكي الكود الذي كان في الخدمة، حيث يقرأ المخزون، ثم ينتظر رد قاعدة البيانات، ثم يكتب القيمة الجديدة:

// Race without threads: check-then-act across an await.  node race-async.mjs [mutex]
const fixed = process.argv[2] === 'mutex';
const db = { stock: 10 };
const query = (ms) => new Promise(r => setTimeout(r, ms));           // stands for a DB round trip
let tail = Promise.resolve();
const withLock = (fn) => { const run = tail.then(fn, fn); tail = run.catch(() => {}); return run; };
async function placeOrder(n) {
  const stock = db.stock;            // SELECT stock
  await query(5 + (n % 3));
  if (stock < 1) return 'rejected';
  db.stock = stock - 1;              // UPDATE stock = <old value - 1>
  return 'ok';
}
const results = await Promise.all(Array.from({ length: 20 }, (_, n) => fixed ? withLock(() => placeOrder(n)) : placeOrder(n)));
console.log(fixed ? 'with mutex:' : 'no lock:   ', 'ok =', results.filter(r => r === 'ok').length, 'rejected =', results.filter(r => r === 'rejected').length, 'stock left =', db.stock);

والمخرج من ثلاثة تشغيلات بدون قفل، ثم تشغيل مع القفل:

no lock:    ok = 20 rejected = 0 stock left = 9
no lock:    ok = 20 rejected = 0 stock left = 9
no lock:    ok = 20 rejected = 0 stock left = 9

with mutex: ok = 10 rejected = 10 stock left = 0

والنتيجة بدون قفل خاطئة بطريقتين في نفس الوقت: كل الطلبات العشرين نجحت، والمخزون بقي 9 وليس سالباً. والسبب أن الطلبات العشرين بدأت معاً، فقرأ كل منها المخزون 10 قبل أن ينتهي أي منها من الانتظار، ثم كتب كل منها 10 ناقص 1، فالكتابة الأخيرة غطت كل ما قبلها، وهذا ما يسمى التحديث المفقود Lost Update. ولاحظ أن النتيجة متطابقة في التشغيلات الثلاثة، وهذا يخدع، فالسباق في JavaScript غالباً ما يكون ثابتاً وقابلاً للتكرار، عكس سباقات الخيوط التي تظهر مرة وتختفي مرة، ولكنه في الإنتاج يعتمد على توقيت ردود قاعدة البيانات فيظهر تحت الضغط فقط.

والإصلاح في الملف قفل Mutex من سطرين: كل عملية تنتظر الوعد الذي قبلها في سلسلة واحدة، فلا تبدأ قراءة قبل أن تنتهي الكتابة السابقة، فنجح عشرة ورفض عشرة والمخزون صفر. ولكن هذا القفل يعمل داخل عملية Node.js واحدة فقط، وفي الإنتاج تعمل الخدمة غالباً بعدة نسخ خلف موزع، فلا يرى قفل نسخة ما يحدث في الأخرى. لذلك فإن الإصلاح الحقيقي للشكوى الثالثة مكانه قاعدة البيانات: قفل الصف بـ SELECT ... FOR UPDATE داخل Transaction، أو تحديث مشروط مثل UPDATE ... SET stock = stock - 1 WHERE stock >= 1، وهذا بالضبط ما سوف نبنيه ونختبره بعشرين طلباً متوازياً في مقال الخدمة الحقيقية. وقد شرحنا أقفال قواعد البيانات في مقال الأقفال Locks والجمود Deadlock في قواعد البيانات.

سباق البيانات Data Race في الذاكرة المشتركة SharedArrayBuffer

قلنا إن العمال لا يشاركون المتغيرات، إلا إذا طلبت ذلك صراحة بـ SharedArrayBuffer، وعندها تحصل على نفس مشكلات Java وC وC#. والملف التالي يجعل أربعة عمال يزيدون نفس العداد مليون مرة لكل منهم:

// A real data race between threads on shared memory.  node race-sab.mjs [atomic]
import { Worker, isMainThread, workerData } from 'node:worker_threads';
const N = 1_000_000, W = 4;
if (!isMainThread) {
  const a = new Int32Array(workerData.sab);
  if (workerData.atomic) for (let i = 0; i < N; i++) Atomics.add(a, 0, 1);
  else for (let i = 0; i < N; i++) a[0] = a[0] + 1;
} else {
  const atomic = process.argv[2] === 'atomic';
  const sab = new SharedArrayBuffer(4);
  const t0 = performance.now();
  await Promise.all(Array.from({ length: W }, () => new Promise(ok => new Worker(new URL(import.meta.url), { workerData: { sab, atomic } }).once('exit', ok))));
  console.log(atomic ? 'Atomics.add:' : 'a[0] = a[0] + 1:', new Int32Array(sab)[0], 'expected', N * W, `(${(performance.now() - t0).toFixed(0)} ms)`);
}

والمخرج من ثلاثة تشغيلات بالزيادة العادية، ثم تشغيل بـ Atomics.add:

a[0] = a[0] + 1: 3980487 expected 4000000 (218 ms)
a[0] = a[0] + 1: 2564215 expected 4000000 (155 ms)
a[0] = a[0] + 1: 3226669 expected 4000000 (151 ms)

Atomics.add: 4000000 expected 4000000 (327 ms)

وهنا السباق يتصرف كما تعرفه من اللغات الأخرى: النتيجة خاطئة، ومختلفة في كل تشغيل، فقد ضاع مرة عشرون ألف زيادة ومرة أكثر من مليون. والسبب أن a[0] = a[0] + 1 ثلاث خطوات، قراءة ثم جمع ثم كتابة، وعاملان على نواتين مختلفتين يقرآن نفس القيمة ويكتبان نفس النتيجة. والدالة Atomics.add من كائن Atomics تجعل الخطوات الثلاث عملية واحدة لا تتجزأ على مستوى المعالج، فكانت النتيجة 4,000,000 بالضبط، ولكن بزمن 327 ميلي ثانية مقابل 151 إلى 218، لأن كل عملية ذرية تجبر الأنوية على التنسيق فيما بينها. والقاعدة: الذاكرة المشتركة للتنسيق القليل بين العمال، مثل عداد تقدم أو علم إيقاف، وليست الطريقة العادية لتبادل البيانات، فالرسائل أبسط وأسلم في أغلب الحالات. والصورة التالية تجمع السباقين مع الجمود الذي سوف نراه بعد قليل:

فوق يمين: السباق بين await وآخر على خيط واحد، 20 طلباً نجحت من مخزون 10 وبقي 9 بدون قفل، ومع Mutex نجح 10 ورفض 10. فوق يسار: ذاكرة مشتركة بين 4 عمال، الزيادة العادية أعطت 3,980,487 و2,564,215 و3,226,669 من 4,000,000، وAtomics.add أعطى 4,000,000 بالضبط. تحت: الجمود بقفلين بترتيب معاكس انتهى برمز الخروج 13
السباق يحتاج نقطة توقف بين القراءة والكتابة، وليس بالضرورة خيطين

الجمود Deadlock: قفلان بترتيب معاكس

وما دام لدينا أقفال، فلدينا جمود. والملف التالي فيه قفلان غير متزامنين، واحد للمخزون وواحد لمحفظة العميل، ودالة الطلب تأخذ المخزون ثم المحفظة، ودالة الاسترجاع Refund تأخذهما بالترتيب المعاكس:

// Two async locks taken in opposite order.  node deadlock.mjs [ordered]
const ordered = process.argv[2] === 'ordered';
class Mutex {
  #tail = Promise.resolve();
  lock() { let release; const next = new Promise(r => (release = r)); const prev = this.#tail; this.#tail = prev.then(() => next); return prev.then(() => release); }
}
const stockLock = new Mutex(), walletLock = new Mutex();
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function order() { const a = await stockLock.lock(); await sleep(10); const b = await walletLock.lock(); console.log('order done'); b(); a(); }
async function refund() {
  const [first, second] = ordered ? [stockLock, walletLock] : [walletLock, stockLock];
  const a = await first.lock(); await sleep(10); const b = await second.lock(); console.log('refund done'); b(); a();
}
await Promise.all([order(), refund()]);
console.log('both finished');

والمخرج للحالتين:

=== deadlock: opposite lock order
Warning: Detected unsettled top-level await at file:///lab/deadlock.mjs:14
await Promise.all([order(), refund()]);
^

exit code: 13

=== fixed: same lock order
order done
refund done
both finished
exit code: 0

والنتيجة الأولى تستحق التوقف. ففي Java يبقى البرنامج المتجمد معلقاً إلى الأبد وتحتاج Thread Dump لتعرف السبب، أما هنا فقد أخذ الطلب قفل المخزون، وأخذ الاسترجاع قفل المحفظة، ثم انتظر كل منهما وعداً لن يحل أبداً، فلم يبق أي مؤقت أو اتصال أو عمل في حلقة الأحداث، فقرر Node.js أن البرنامج انتهى، وخرج برمز 13 الذي يعني في جدول رموز الخروج أن await في أعلى الملف لم يستقر. ولا توجد رسالة خطأ، ولا Stack Trace، فقط تحذير يشير إلى السطر الذي كان ينتظر. وفي سيرفر حقيقي لن ترى حتى هذا، لأن السيرفر نفسه يبقي الحلقة حية، فيبقى الطلبان معلقين بلا نهاية، ويرى العميل مهلة تنتهي بعد دقيقة، ويتراكم في الذاكرة كل ما يحمله الطلبان.

وقد يتساءل البعض: كيف أعرف أن الطلبات المعلقة سببها جمود وليس بطء قاعدة البيانات؟ والإجابة أن تجعل كل انتظار لقفل محدوداً بمهلة ترمي خطأ له اسم واضح، مثل LockTimeoutError مع اسم القفل والمدة، فبدلاً من طلبات معلقة بلا أثر ترى في السجلات خطأ يقول «انتظرت قفل المحفظة خمس ثوان وأنا أملك قفل المخزون»، وهذه الجملة وحدها تكفي لتعرف السبب. وقاعدة PostgreSQL تفعل شيئاً مشابهاً للأقفال على صفوفها، فهي تكتشف الجمود بنفسها وتلغي إحدى المعاملتين بخطأ صريح، أما الأقفال التي تكتبها في JavaScript فلا يراقبها أحد غيرك.

والإصلاح في الحالة الثانية هو القاعدة الكلاسيكية: كل الدوال تأخذ الأقفال بنفس الترتيب، فلا يمكن أن يملك أحد الثاني وينتظر الأول. وهذه نفس القاعدة التي سوف تراها في خدمة الطلبات، حيث نرتب أرقام المنتجات قبل SELECT ... FOR UPDATE، حتى لا يتجمد طلبان يحتويان نفس المنتجين بترتيب مختلف. والأفضل من الترتيب ألا تحتاج قفلين أصلاً، وأن تضع كل مهمة في عملية واحدة بقيود في قاعدة البيانات، وأن تضع مهلة زمنية على كل انتظار، فالطلب المعلق الذي ينتهي بخطأ بعد خمس ثوان أفضل بكثير من طلب معلق إلى الأبد.

الإلغاء والمهلة Timeouts: AbortController في كل انتظار

كل الأمثلة السابقة تفترض أن الطرف الآخر يرد، وفي الإنتاج لا يرد أحياناً. وطلب بدون مهلة إلى خدمة معلقة يبقى مفتوحاً حتى تنتهي مهلة TCP في نظام التشغيل، وهذه قد تكون دقائق، وكل طلب معلق يحتفظ باتصال وذاكرة، وإذا كان داخل مجموعة محدودة فهو يأخذ مكان عامل من عمالها، فتتوقف المجموعة كلها إذا علق عدد كاف منها. والطريقة المعيارية في JavaScript للإلغاء هي AbortController وإشارته AbortSignal، وهي نفس الواجهة في المتصفح وفي Node.js، وتقبلها fetch ومعظم واجهات Node.js الحديثة. والملف التالي يشغل سيرفراً يرد بعد 200 ميلي ثانية، ويطلب منه بمهلة 50:

// Cancel a slow call with AbortSignal.timeout.  node abort.mjs
import { createServer } from 'node:http';
const server = createServer((req, res) => setTimeout(() => res.end('late'), 200)).listen(0);
await new Promise((ok) => server.once('listening', ok));
const url = `http://127.0.0.1:${server.address().port}/`;
const t0 = performance.now();
try { await fetch(url, { signal: AbortSignal.timeout(50) }); }
catch (e) { console.log(`after ${(performance.now() - t0).toFixed(0)} ms -> ${e.name}: ${e.message}`); }
const r = await Promise.allSettled([fetch(url, { signal: AbortSignal.timeout(300) }).then(x => x.text()), fetch(url, { signal: AbortSignal.timeout(50) })]);
console.log(r.map(x => x.status === 'fulfilled' ? `fulfilled(${x.value})` : `rejected(${x.reason.name})`).join(', '));
server.close();

والمخرج سوف يكون كما يلي:

after 94 ms -> TimeoutError: The operation was aborted due to timeout
fulfilled(late), rejected(TimeoutError)

والطلب الأول ألغي بخطأ اسمه TimeoutError، وهذا الاسم هو ما تفحصه في catch لتفرق المهلة عن خطأ الشبكة. ولاحظ أن الإلغاء حدث بعد 94 ميلي ثانية وليس 50، والسبب أن هذا أول fetch في العملية، فـ Node.js يحمل مكتبة undici ويجهزها عند أول استدعاء، والمهلة نفسها مؤقت ينفذ في حلقة الأحداث، فإذا كانت الحلقة مشغولة تأخر الإلغاء أيضاً، وهذا سبب آخر لإبعاد الحساب الثقيل عن الخيط الرئيسي. والسطر الثاني يبين الشكل الذي تريده عند سؤال عدة مصادر: كل طلب بمهلته، وallSettled يجمع ما نجح وما انتهت مهلته دون أن يفشل الكل. والقاعدة في خدمة حقيقية: لا يوجد انتظار بلا مهلة، سواء كان طلب HTTP أو استعلاماً أو قفلاً، والمهلة تحدد بحسب ما يقبله المستخدم الذي ينتظر في الطرف الآخر.

وفي المتصفح؟ Web Workers بنفس الفكرة

كل ما قلناه عن الخيط الرئيسي في Node.js ينطبق على المتصفح بشكل أشد، فالخيط الرئيسي هناك يرسم الصفحة ويستقبل النقرات أيضاً، وحساب يستغرق 200 ميلي ثانية يجعل الصفحة لا تستجيب، وقد قسنا ذلك في مقال حلقة الأحداث بنقرة تنتظر خلف مهمة فهرسة. والمقابل لـ worker_threads في المتصفح هو Web Workers، بنفس النموذج تقريباً: خيط منفصل بلا وصول إلى DOM، يتواصل مع الصفحة برسائل تنسخ، ويمكن أن يشارك ذاكرة بـ SharedArrayBuffer بشرط أن تفعل الصفحة عزل المصدر Cross-Origin Isolation بترويستين خاصتين، وذلك منذ ثغرات Spectre التي جعلت المتصفحات تقيد الذاكرة المشتركة. فإذا كنت تكتب الواجهة والسيرفر بنفس اللغة، فالقرار واحد في المكانين: الانتظار بالوعود، والحساب الثقيل على عامل.

كيف يقارن هذا بلغات أخرى؟ Java وGo وPython

نفس المهمتين سوف تقاس في كل مراحل المسار، ومن المفيد أن تعرف مسبقاً أين يقف Node.js بينها. ففي Java الحديثة تكتب كوداً متزامناً عادياً بلا await، وتشغل كل طلب على خيط افتراضي Virtual Thread رخيص، فيعطيك نفس كفاءة الانتظار التي أعطتها الوعود هنا، ولكن نفس الخيوط تستطيع أيضاً أن تحسب بالتوازي على كل الأنوية، فلا تحتاج نموذجين مختلفين للنوعين. وفي Go تبدأ Goroutine بكلمة واحدة، والمجدول يوزعها على كل الأنوية تلقائياً، فالمهمة الثانية تتوازى دون أي عامل منفصل أو نسخ للرسائل، والثمن أن المتغيرات المشتركة ممكنة في كل مكان، فالسباقات أسهل حدوثاً، ولذلك يأتي Go بكاشف سباقات مدمج. وفي Python يشبه asyncio نموذج Node.js كثيراً في المهمة الأولى، أما في الثانية فالقفل العام GIL منع الخيوط تاريخياً من الحساب بالتوازي، فكان الحل عمليات منفصلة، وهذا يتغير مع إصدارات Python بدون GIL.

والخلاصة في جملة: Node.js ممتاز في الانتظار، ومتوسط في الحساب، لأن التوازي فيه ليس الطريق الافتراضي، وإنما شيء تطلبه صراحة بالعمال، بكل ما يكلفه من نسخ الرسائل وذاكرة V8 لكل عامل. وهذا ليس عيباً في خدمة طلبات أغلب وقتها انتظار لقاعدة البيانات والشبكة، ولكنه سبب وجيه لاختيار لغة أخرى لخدمة أغلب عملها حساب، كما ناقشنا في مقال كيف تختار لغة البرمجة لمشروعك.

أخطاء شائعة Common Mistakes في التزامن

  • await داخل حلقة لطلبات مستقلة: حول 5.6 ثانية من 0.3 ممكنة في تجربتنا، واستخدم تزامناً محدوداً.
  • Promise.all على قائمة بلا حد: ألف طلب معاً تفتح ألف اتصال وتتجاوز حدود الطرف الآخر، وفي تجربتنا كانت المئة معاً أبطأ من 25.
  • array.forEach(async ...): forEach لا ينتظر الوعود، فتنتهي الدالة قبل أن ينتهي أي عمل، والأخطاء تصبح وعوداً مرفوضة بلا معالج. استخدم for...of مع await للتسلسل، أو map مع Promise.all للتزامن.
  • اعتبار async طريقة للتوازي: الدالة المتزامنة داخل async تبقى على الخيط الرئيسي، كما رأيت في Promise.all للهاش.
  • حساب ثقيل في معالج طلب HTTP: يجمد السيرفر كله، والمؤقت في تجربتنا انتظر 22 ثانية.
  • عامل جديد لكل طلب: كل عامل V8 كامل، فاستخدم مجموعة ثابتة.
  • حجم مجموعة العمال من availableParallelism() في Container محدود: ترى ثمانية أنوية وحصتك اثنتان.
  • فحص ثم تعديل عبر await: سباق حتى بخيط واحد، والإصلاح في قاعدة البيانات وليس في الذاكرة.
  • أقفال بترتيب مختلف وبلا مهلة: جمود صامت يظهر كطلبات معلقة.

جدول قرار Decision Table: أي أداة لأي عمل؟

والصورة التالية تجمع ما قسناه في جدول واحد، كل صف نوع من العمل والأداة المناسبة له والرقم الذي يبرر الاختيار:

جدول قرار من سبعة صفوف: طلبات مستقلة بـ Promise.all مع حد من 5571 إلى 284 ms، وطلبات قد يفشل بعضها بـ allSettled، وحساب ثقيل بـ worker_threads من 22.4 إلى 4.8 ثانية، وcrypto.hash من 22.4 إلى 10 ثوان، وحالة مشتركة عبر await بقفل أو قيد في قاعدة البيانات، وعداد مشترك بـ Atomics، وأكثر من قفل بترتيب ثابت
كل صف يقابل تجربة في المقال

تدريب عملي

  • أضف إلى io.mjs مهلة 30 ميلي ثانية بـ AbortSignal.timeout لكل طلب، وشغله مع Promise.allSettled، وعد كم طلباً انتهى بخطأ TimeoutError.
  • غير slow-server.mjs ليكتب الرأس بـ res.writeHead ثم الجسم بـ res.write ثم res.end() فارغة، وقس زمن الطلب الواحد مرة أخرى، ثم أضف req.socket.setNoDelay(true) وقارن.
  • غير cpu.mjs ليستخدم crypto.hash() داخل العمال أيضاً، وشغله مع --cpus=2، ولاحظ هل يتغير أفضل عدد للعمال.
  • غير race-async.mjs ليكتب db.stock -= 1 بعد await بدلاً من stock - 1، وفسر لماذا صار المخزون سالباً بدلاً من 9.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Node.js ينفذ كودك على خيط واحد، وينتظر الشبكة بدون خيوط، ويستخدم أربعة خيوط من libuv للملفات وبعض دوال crypto، والعمال worker_threads هي الطريقة الوحيدة لتشغيل JavaScript بالتوازي.
  • مئة طلب إلى خدمة 50 ميلي ثانية أخذت 5.6 ثانية تسلسلياً و284 ميلي ثانية بمجموعة من 25، وكانت المئة معاً أبطأ من المجموعة بسبب الاتصالات الجديدة.
  • أربعة ملايين هاش لم تتسرع بـ Promise.all، وجمدت الخيط الرئيسي 22 ثانية، ومع ثمانية عمال نزلت إلى 4.8 ثانية والخيط حر، ومع crypto.hash() نزلت إلى 10 ثوان بدون خيوط.
  • في Container بـ --cpus=2 ما زال Node.js يرى ثمانية أنوية، وكل عامل فوق اثنين لم يفد بشيء، فحدد عدد العمال من الحصة.
  • السباق حدث بخيط واحد عبر await: عشرون طلباً نجحت من مخزون عشرة، والإصلاح الحقيقي في قاعدة البيانات، أما الذاكرة المشتركة بين العمال فتحتاج Atomics.
  • الجمود بقفلين معاكسين أنهى البرنامج برمز 13 دون خطأ، وفي السيرفر يظهر كطلبات معلقة، والعلاج ترتيب ثابت ومهلة لكل انتظار.

والمقال التالي في مرحلة JavaScript وTypeScript يأخذ كل هذا إلى خدمة حقيقية: عشرون طلباً متوازياً على منتج بمخزون عشرة يجب أن ينجح منها عشرة بالضبط، بقفل في PostgreSQL وليس في الذاكرة:

دليلالأنواع Types والذاكرة والأخطاء في JavaScriptلماذا ظهر في الفاتورة 29.700000000000003، ولماذا انهارت الخدمة برسالة heap out of memory، وأين يهرب الخطأ من try/catch: قيم JavaScript وذاكرتها وأخطاؤها بقياسات وتشغيلات حقيقية على Node.js 26.دليلJavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدواتإذا كنت تبرمج بلغة أخرى وانتقلت إلى فريق يعمل بـ Node.js وTypeScript، فهذا يومك الأول: الإصدار الصحيح، والصيغة مقارنة بما تعرفه، وأدوات العمل وهيكل المشروع بأوامر ومخرجات حقيقية.دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على Node.js v26.13.0 داخل Docker Image الرسمي node:26.13.0-slim، بثلاثة تشغيلات لكل قياس.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.