Skip to content

لا تحجب حلقة الأحداث (أو مجموعة العمل)

هل يجب عليك قراءة هذا الدليل؟

إذا كنت تكتب شيئًا أكثر تعقيدًا من مجرد نص برمجي موجز لسطر الأوامر، فإن قراءة هذا الدليل ستساعدك في كتابة تطبيقات ذات أداء أعلى وأكثر أمانًا.

تمت كتابة هذه الوثيقة مع وضع خوادم Node.js في الاعتبار، ولكن المفاهيم تنطبق أيضًا على تطبيقات Node.js المعقدة. حيثما تختلف التفاصيل الخاصة بنظام التشغيل، تركز هذه الوثيقة على Linux.

ملخص

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

إليك قاعدة عامة جيدة للحفاظ على سرعة خادم Node.js الخاص بك: يكون Node.js سريعًا عندما يكون العمل المرتبط بكل عميل في أي وقت معين "صغيرًا".

ينطبق هذا على عمليات الاستدعاء في حلقة الأحداث والمهام في مجموعة العمل.

لماذا يجب أن أتجنب حظر حلقة الأحداث ومجموعة العمل؟

يستخدم Node.js عددًا صغيرًا من سلاسل العمليات للتعامل مع العديد من العملاء. في Node.js ، يوجد نوعان من سلاسل العمليات: حلقة أحداث واحدة (تُعرف أيضًا باسم الحلقة الرئيسية، أو السلسلة الرئيسية، أو سلسلة الأحداث، وما إلى ذلك) ، ومجموعة من k من العمال في مجموعة عمل (تُعرف أيضًا باسم مجمع سلاسل العمليات).

إذا كانت سلسلة العمليات تستغرق وقتًا طويلاً لتنفيذ عملية استدعاء (حلقة الأحداث) أو مهمة (عامل)، فإننا نسميها "محظورة". بينما يتم حظر سلسلة العمليات التي تعمل نيابة عن عميل واحد، لا يمكنها التعامل مع الطلبات من أي عملاء آخرين. يوفر هذا دافعين لحظر حلقة الأحداث أو مجموعة العمل:

  1. الأداء: إذا كنت تمارس بانتظام نشاطًا ثقيل الوزن على أي من النوعين من سلاسل العمليات، فسوف يتأثر الإنتاجية (الطلبات / الثانية) لخادمك.
  2. الأمان: إذا كان من الممكن أنه بالنسبة لبعض المدخلات، قد يتم حظر إحدى سلاسل العمليات الخاصة بك، فيمكن لعميل ضار إرسال هذا "الإدخال الشرير"، وجعل سلاسل العمليات الخاصة بك محظورة، ومنعها من العمل على عملاء آخرين. سيكون هذا هجوم حجب الخدمة.

مراجعة سريعة لـ Node

يستخدم Node.js بنية تعتمد على الأحداث: لديه حلقة أحداث للتنسيق ومجموعة عاملة للمهام المكلفة.

ما هو الكود الذي يتم تشغيله على حلقة الأحداث؟

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

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

باختصار، تنفذ حلقة الأحداث ردود اتصال JavaScript المسجلة للأحداث، وهي مسؤولة أيضًا عن تلبية الطلبات غير المتزامنة غير المحظورة مثل إدخال/إخراج الشبكة.

ما هو الكود الذي يتم تشغيله على مجموعة العاملة؟

يتم تنفيذ مجموعة العاملة في Node.js في libuv (وثائق)، والذي يعرض واجهة برمجة تطبيقات إرسال المهام العامة.

يستخدم Node.js مجموعة العاملة للتعامل مع المهام "المكلفة". يتضمن ذلك الإدخال/الإخراج الذي لا يوفر له نظام التشغيل إصدارًا غير محظور، بالإضافة إلى المهام التي تتطلب وحدة معالجة مركزية مكثفة بشكل خاص.

فيما يلي واجهات برمجة تطبيقات وحدة Node.js التي تستخدم مجموعة العاملة هذه:

  1. كثافة الإدخال/الإخراج
    1. DNS:‏ dns.lookup()،‏ dns.lookupService().
    2. نظام الملفات: تستخدم جميع واجهات برمجة تطبيقات نظام الملفات باستثناء fs.FSWatcher() وتلك المتزامنة بشكل صريح مجموعة سلاسل libuv.
  2. كثافة وحدة المعالجة المركزية
    1. Crypto:‏ crypto.pbkdf2()،‏ crypto.scrypt()،‏ crypto.randomBytes()،‏ crypto.randomFill()،‏ crypto.generateKeyPair().
    2. Zlib: تستخدم جميع واجهات برمجة تطبيقات zlib باستثناء تلك المتزامنة بشكل صريح مجموعة سلاسل libuv.

في العديد من تطبيقات Node.js، تكون واجهات برمجة التطبيقات هذه هي المصادر الوحيدة للمهام لمجموعة العاملة. يمكن للتطبيقات والوحدات التي تستخدم إضافة C++ إرسال مهام أخرى إلى مجموعة العاملة.

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

كيف يقرر Node.js أي كود سيتم تشغيله بعد ذلك؟

بشكل مجرد، يحتفظ كل من حلقة الأحداث (Event Loop) ومجموعة العمال (Worker Pool) بقوائم انتظار للأحداث المعلقة والمهام المعلقة، على التوالي.

في الواقع، لا تحتفظ حلقة الأحداث فعليًا بقائمة انتظار. بدلاً من ذلك، لديها مجموعة من واصفات الملفات التي تطلب من نظام التشغيل مراقبتها، باستخدام آلية مثل epoll (Linux)، kqueue (OSX)، منافذ الأحداث (Solaris)، أو IOCP (Windows). تتوافق واصفات الملفات هذه مع مآخذ الشبكة وأي ملفات تتم مراقبتها وما إلى ذلك. عندما يقول نظام التشغيل أن أحد واصفات الملفات هذه جاهز، تترجم حلقة الأحداث ذلك إلى الحدث المناسب وتستدعي الاستدعاءات المرتبطة بهذا الحدث. يمكنك معرفة المزيد حول هذه العملية هنا.

على النقيض من ذلك، تستخدم مجموعة العمال قائمة انتظار حقيقية تكون إدخالاتها مهامًا يجب معالجتها. يسحب العامل مهمة من قائمة الانتظار هذه ويعمل عليها، وعند الانتهاء يرفع العامل حدث "تم الانتهاء من مهمة واحدة على الأقل" لحلقة الأحداث.

ماذا يعني هذا لتصميم التطبيق؟

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

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

هذا جزء من سبب قدرة Node.js على التوسع بشكل جيد، ولكنه يعني أيضًا أنك مسؤول عن ضمان جدولة عادلة. تتحدث الأقسام التالية عن كيفية ضمان الجدولة العادلة لحلقة الأحداث ولمجموعة العمال.

لا تحظر حلقة الأحداث

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

يجب التأكد من عدم حظر حلقة الأحداث مطلقًا. بعبارة أخرى، يجب أن يكتمل كل رد اتصال JavaScript بسرعة. ينطبق هذا بالطبع أيضًا على await و Promise.then وما إلى ذلك.

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

مثال 1: رد اتصال بوقت ثابت.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200);
});

مثال 2: رد اتصال O(n). سيتم تشغيل رد الاتصال هذا بسرعة بالنسبة لـ n الصغيرة وببطء أكثر بالنسبة لـ n الكبيرة.

js
app.get('/countToN', (req, res) => {
  let n = req.query.n;
  // n تكرار قبل إعطاء شخص آخر دورًا
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`);
  }
  res.sendStatus(200);
});

مثال 3: رد اتصال O(n^2). سيظل رد الاتصال هذا يعمل بسرعة بالنسبة لـ n الصغيرة، ولكن بالنسبة لـ n الكبيرة، سيتم تشغيله ببطء أكثر بكثير من المثال السابق O(n).

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n;
  // n^2 تكرار قبل إعطاء شخص آخر دورًا
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`);
    }
  }
  res.sendStatus(200);
});

ما مدى الحرص الذي يجب أن تكون عليه؟

يستخدم Node.js محرك Google V8 لـ JavaScript، وهو سريع جدًا بالنسبة للعديد من العمليات الشائعة. الاستثناءات لهذه القاعدة هي regexps وعمليات JSON، والتي تمت مناقشتها أدناه.

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

حظر حلقة الأحداث: REDOS

إحدى الطرق الشائعة لحظر حلقة الأحداث بشكل كارثي هي استخدام تعبير نمطي "ضعيف".

تجنب التعبيرات النمطية الضعيفة

يطابق التعبير النمطي سلسلة إدخال مع نمط. عادةً ما نفكر في مطابقة التعبير النمطي على أنها تتطلب تمريرة واحدة عبر سلسلة الإدخال --- O(n) حيث n هو طول سلسلة الإدخال. في كثير من الحالات، تكون التمريرة الواحدة هي كل ما يتطلبه الأمر بالفعل. لسوء الحظ، في بعض الحالات، قد تتطلب مطابقة التعبير النمطي عددًا أسيًا من الرحلات عبر سلسلة الإدخال --- O(2^n). يعني العدد الأسي من الرحلات أنه إذا كان المحرك يتطلب x رحلة لتحديد مطابقة، فسيحتاج إلى 2*x رحلة إذا أضفنا حرفًا واحدًا فقط إلى سلسلة الإدخال. نظرًا لأن عدد الرحلات مرتبط خطيًا بالوقت المطلوب، فسيكون تأثير هذا التقييم هو حظر حلقة الأحداث.

التعبير النمطي الضعيف هو تعبير قد يستغرق محرك التعبير النمطي الخاص بك وقتًا أسيًا، مما يعرضك لـ REDOS على "الإدخال الشرير". سواء كان نمط التعبير النمطي الخاص بك ضعيفًا أم لا (أي أن محرك التعبير النمطي قد يستغرق وقتًا أسيًا عليه) هو في الواقع سؤال يصعب الإجابة عليه، ويختلف اعتمادًا على ما إذا كنت تستخدم Perl أو Python أو Ruby أو Java أو JavaScript وما إلى ذلك، ولكن إليك بعض القواعد الأساسية التي تنطبق على جميع هذه اللغات:

  1. تجنب المحددات الكمية المتداخلة مثل (a+)*. يمكن لمحرك التعبير النمطي V8 التعامل مع بعض هذه المحددات بسرعة، لكن البعض الآخر ضعيف.
  2. تجنب OR مع العبارات المتداخلة، مثل (a|a)*. مرة أخرى، هذه سريعة في بعض الأحيان.
  3. تجنب استخدام الإشارات المرجعية الخلفية، مثل (a.*) \1. لا يمكن لأي محرك تعبير نمطي أن يضمن تقييم هذه في وقت خطي.
  4. إذا كنت تقوم بمطابقة سلسلة بسيطة، فاستخدم indexOf أو ما يعادله محليًا. سيكون أرخص ولن يستغرق أبدًا أكثر من O(n).

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

مثال على REDOS

إليك مثال على تعبير نمطي ضعيف يعرض الخادم الخاص به لـ REDOS:

js
app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath;
  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path');
  } else {
    console.log('invalid path');
  }
  res.sendStatus(200);
});

التعبير النمطي الضعيف في هذا المثال هو طريقة (سيئة!) للتحقق من مسار صالح على Linux. فهو يطابق السلاسل التي تمثل سلسلة من الأسماء مفصولة بـ "/"، مثل "/a/b/c". إنه خطير لأنه ينتهك القاعدة 1: لديه مُكمِّم مُتداخل بشكل مضاعف.

إذا استعلم عميل عن filePath ///.../\n (100 / متبوعة بحرف سطر جديد لن يطابقه "." الخاص بالتعبير النمطي)، فستستغرق حلقة الأحداث إلى الأبد فعليًا، مما يؤدي إلى حظر حلقة الأحداث. يتسبب هجوم REDOS الخاص بهذا العميل في عدم حصول جميع العملاء الآخرين على دور حتى تنتهي مطابقة التعبير النمطي.

لهذا السبب، يجب أن تكون حذرًا من استخدام التعبيرات النمطية المعقدة للتحقق من صحة إدخال المستخدم.

موارد مكافحة REDOS

هناك بعض الأدوات للتحقق من سلامة التعبيرات النمطية الخاصة بك، مثل

ومع ذلك، لن يلتقط أي من هذين التعبيرات النمطية الضعيفة.

هناك طريقة أخرى وهي استخدام محرك تعبير نمطي مختلف. يمكنك استخدام وحدة node-re2، والتي تستخدم محرك التعبير النمطي RE2 سريع للغاية من Google. ولكن كن حذرًا، RE2 ليس متوافقًا بنسبة 100% مع التعبيرات النمطية V8، لذا تحقق من حالات الانحدار إذا قمت بتبديل وحدة node-re2 للتعامل مع التعبيرات النمطية الخاصة بك. والتعبيرات النمطية المعقدة بشكل خاص غير مدعومة من قبل node-re2.

إذا كنت تحاول مطابقة شيء "واضح"، مثل عنوان URL أو مسار ملف، فابحث عن مثال في مكتبة تعبير نمطي أو استخدم وحدة npm، على سبيل المثال ip-regex.

حظر حلقة الأحداث: وحدات Node.js الأساسية

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

تعتبر واجهات برمجة التطبيقات هذه باهظة الثمن، لأنها تتضمن حسابًا كبيرًا (التشفير والضغط)، أو تتطلب إدخال/إخراج (إدخال/إخراج الملفات)، أو يحتمل أن يكون كلاهما (العملية الفرعية). تهدف واجهات برمجة التطبيقات هذه إلى تسهيل البرمجة النصية، ولكنها غير مخصصة للاستخدام في سياق الخادم. إذا قمت بتنفيذها على حلقة الأحداث، فستستغرق وقتًا أطول بكثير لإكمالها من تعليمات JavaScript النموذجية، مما يؤدي إلى حظر حلقة الأحداث.

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

  • التشفير:
    • crypto.randomBytes (إصدار متزامن)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • يجب عليك أيضًا توخي الحذر بشأن توفير مدخلات كبيرة لروتينات التشفير وفك التشفير.
  • الضغط:
    • zlib.inflateSync
    • zlib.deflateSync
  • نظام الملفات:
    • لا تستخدم واجهات برمجة تطبيقات نظام الملفات المتزامنة. على سبيل المثال، إذا كان الملف الذي تصل إليه موجودًا في نظام ملفات موزعة مثل NFS، فقد تختلف أوقات الوصول اختلافًا كبيرًا.
  • العملية الفرعية:
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

هذه القائمة كاملة إلى حد معقول اعتبارًا من Node.js v9.

حظر حلقة الأحداث: هجوم JSON DOS

تُعدّ JSON.parse و JSON.stringify من العمليات الأخرى التي يُحتمل أن تكون مُكلفة. على الرغم من أن تعقيد هذه العمليات هو O(n) بطول المُدخلات، إلا أنها قد تستغرق وقتًا طويلاً بشكل مُثير للدهشة عندما تكون قيمة n كبيرة.

إذا كان خادمك يتعامل مع كائنات JSON، وخاصةً تلك القادمة من العميل، فيجب أن تكون حذرًا بشأن حجم الكائنات أو السلاسل النصية التي تتعامل معها في حلقة الأحداث.

مثال: حظر JSON. نقوم بإنشاء كائن obj بحجم 2^21، ثم نقوم بتحويله إلى سلسلة نصية باستخدام JSON.stringify، ونقوم بتشغيل indexOf على السلسلة النصية، ثم نقوم بتحليلها باستخدام JSON.parse. السلسلة النصية المُحوّلة باستخدام JSON.stringify يبلغ حجمها 50 ميجابايت. يستغرق تحويل الكائن إلى سلسلة نصية 0.7 ثانية، ويستغرق تشغيل indexOf على السلسلة النصية التي يبلغ حجمها 50 ميجابايت 0.03 ثانية، ويستغرق تحليل السلسلة النصية 1.3 ثانية.

js
let obj = { a: 1 };
let niter = 20;
let before, str, pos, res, took;
for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // يتضاعف الحجم في كل تكرار
}
before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);
before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);
before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);

توجد وحدات npm تقدم واجهات برمجة تطبيقات JSON غير متزامنة. انظر على سبيل المثال:

  • JSONStream، الذي يحتوي على واجهات برمجة تطبيقات تدفق.
  • Big-Friendly JSON، الذي يحتوي على واجهات برمجة تطبيقات تدفق بالإضافة إلى إصدارات غير متزامنة من واجهات برمجة تطبيقات JSON القياسية باستخدام نموذج التقسيم على حلقة الأحداث الموضح أدناه.

عمليات حسابية معقدة دون حظر حلقة الأحداث

لنفترض أنك تريد إجراء عمليات حسابية معقدة في JavaScript دون حظر حلقة الأحداث. لديك خياران: التقسيم أو التفريغ.

التقسيم

يمكنك تقسيم العمليات الحسابية بحيث يعمل كل منها على حلقة الأحداث ولكنه يستسلم (يعطي الدور) بانتظام للأحداث المعلقة الأخرى. في JavaScript، من السهل حفظ حالة مهمة جارية في إغلاق، كما هو موضح في المثال 2 أدناه.

كمثال بسيط، لنفترض أنك تريد حساب متوسط الأرقام من 1 إلى n.

المثال 1: متوسط غير مُقسّم، يكلف O(n)

js
for (let i = 0; i < n; i++) sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

المثال 2: متوسط مُقسّم، كل خطوة من الخطوات غير المتزامنة n تكلف O(1).

js
function asyncAvg(n, avgCB) {
  // احفظ المجموع الجاري في إغلاق JS.
  let sum = 0;
  function help(i, cb) {
    sum += i;
    if (i == n) {
      cb(sum);
      return;
    }
    // "استدعاء ذاتي غير متزامن".
    // جدولة العملية التالية بشكل غير متزامن.
    setImmediate(help.bind(null, i + 1, cb));
  }
  // ابدأ المساعد، مع CB لاستدعاء avgCB.
  help(1, function (sum) {
    let avg = sum / n;
    avgCB(avg);
  });
}
asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg);
});

يمكنك تطبيق هذا المبدأ على تكرارات المصفوفة وما إلى ذلك.

تفريغ العمل

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

كيفية تفريغ العمل

لديك خياران لمجمع العمال الوجهة الذي ستفرغ إليه العمل.

  1. يمكنك استخدام مجمع العمال المدمج في Node.js عن طريق تطوير إضافة C++. في الإصدارات الأقدم من Node، قم ببناء إضافة C++ الخاصة بك باستخدام NAN، وفي الإصدارات الأحدث استخدم N-API. يوفر node-webworker-threads طريقة JavaScript فقط للوصول إلى مجمع عمال Node.js.
  2. يمكنك إنشاء وإدارة مجمع العمال الخاص بك المخصص للحساب بدلاً من مجمع عمال Node.js ذي الطابع I/O. أسهل الطرق للقيام بذلك هي استخدام العملية الفرعية أو المجموعة.

لا ينبغي عليك ببساطة إنشاء عملية فرعية لكل عميل. يمكنك تلقي طلبات العملاء بسرعة أكبر من إنشاء وإدارة العمليات الفرعية، وقد يصبح الخادم الخاص بك قنبلة تشعب (fork bomb).

عيوب تفريغ العمل عيب طريقة تفريغ العمل هو أنها تتكبد تكاليف عامة في شكل تكاليف اتصال. يُسمح فقط لحلقة الأحداث برؤية "مساحة الاسم" (حالة JavaScript) لتطبيقك. من العامل، لا يمكنك معالجة كائن JavaScript في مساحة اسم حلقة الأحداث. بدلاً من ذلك، عليك تسلسل وإلغاء تسلسل أي كائنات ترغب في مشاركتها. ثم يمكن للعامل العمل على نسخته الخاصة من هذه الكائنات وإرجاع الكائن المعدل (أو "تصحيح") إلى حلقة الأحداث.

فيما يتعلق بمخاوف التسلسل، راجع القسم الخاص بـ JSON DOS.

بعض الاقتراحات لتفريغ العمل

قد ترغب في التمييز بين المهام كثيفة وحدة المعالجة المركزية والمهام كثيفة الإدخال والإخراج لأنها تتميز بخصائص مختلفة بشكل ملحوظ.

لا تحرز المهمة كثيفة وحدة المعالجة المركزية تقدمًا إلا عندما يتم جدولة العامل الخاص بها، ويجب جدولة العامل على أحد النوى المنطقية لجهازك. إذا كان لديك 4 نوى منطقية و 5 عمال، فلا يمكن لأحد هؤلاء العمال إحراز تقدم. نتيجة لذلك، أنت تدفع تكاليف عامة (الذاكرة وتكاليف الجدولة) لهذا العامل ولا تحصل على أي عائد مقابل ذلك.

تتضمن المهام كثيفة الإدخال والإخراج الاستعلام عن مزود خدمة خارجي (DNS، نظام الملفات، إلخ) والانتظار للحصول على رده. بينما ينتظر العامل الذي لديه مهمة كثيفة الإدخال والإخراج رده، ليس لديه ما يفعله ويمكن إلغاء جدولته بواسطة نظام التشغيل، مما يمنح عاملاً آخر فرصة لتقديم طلبه. وبالتالي، ستحرز المهام كثيفة الإدخال والإخراج تقدمًا حتى في حالة عدم تشغيل مؤشر الترابط المرتبط. تم تحسين موفري الخدمة الخارجية مثل قواعد البيانات وأنظمة الملفات بدرجة كبيرة للتعامل مع العديد من الطلبات المعلقة في وقت واحد. على سبيل المثال، سيفحص نظام الملفات مجموعة كبيرة من طلبات الكتابة والقراءة المعلقة لدمج التحديثات المتضاربة واسترداد الملفات بترتيب أمثل.

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

لهذا السبب، قد ترغب في الاحتفاظ بمجمع عمال حسابي منفصل.

تفريغ العمليات: استنتاجات

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

ومع ذلك، إذا كان الخادم الخاص بك يعتمد بشكل كبير على العمليات الحسابية المعقدة، فيجب أن تفكر فيما إذا كانت Node.js مناسبة حقًا. تتفوق Node.js في العمل المرتبط بالإدخال/الإخراج، ولكن بالنسبة للحسابات المكلفة، قد لا تكون الخيار الأفضل.

إذا كنت ستتبع أسلوب تفريغ العمليات، فراجع القسم الخاص بعدم حظر مجموعة العمال.

لا تحظر مجموعة العمال

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

كما نوقش أعلاه، يكمل كل عامل مهمته الحالية قبل الانتقال إلى المهمة التالية في قائمة انتظار مجموعة العمال.

الآن، سيكون هناك تباين في تكلفة المهام المطلوبة للتعامل مع طلبات عملائك. يمكن إكمال بعض المهام بسرعة (مثل قراءة ملفات قصيرة أو مخزنة مؤقتًا، أو إنتاج عدد صغير من وحدات البايت العشوائية)، وستستغرق مهام أخرى وقتًا أطول (مثل قراءة ملفات أكبر أو غير مخزنة مؤقتًا، أو إنشاء المزيد من وحدات البايت العشوائية). يجب أن يكون هدفك هو تقليل التباين في أوقات المهام، ويجب عليك استخدام تقسيم المهام لإنجاز ذلك.

تقليل التباين في أوقات المهام

إذا كانت مهمة العامل الحالية أكثر تكلفة بكثير من المهام الأخرى، فلن يكون متاحًا للعمل على المهام المعلقة الأخرى. بمعنى آخر، كل مهمة طويلة نسبيًا تقلل بشكل فعال من حجم مجموعة العمال بمقدار واحد حتى يتم إكمالها. هذا غير مرغوب فيه لأنه، حتى نقطة معينة، كلما زاد عدد العمال في مجموعة العمال، زادت إنتاجية مجموعة العمال (المهام/ثانية) وبالتالي زادت إنتاجية الخادم (طلبات العميل/ثانية). سيؤدي عميل واحد لديه مهمة باهظة الثمن نسبيًا إلى تقليل إنتاجية مجموعة العمال، وبالتالي تقليل إنتاجية الخادم.

لتجنب ذلك، يجب أن تحاول تقليل التباين في طول المهام التي ترسلها إلى مجموعة العمال. على الرغم من أنه من المناسب معاملة الأنظمة الخارجية التي تصل إليها طلبات الإدخال/الإخراج (DB، FS، إلخ) على أنها صناديق سوداء، إلا أنه يجب أن تكون على دراية بالتكلفة النسبية لطلبات الإدخال/الإخراج هذه، ويجب عليك تجنب إرسال الطلبات التي تتوقع أن تكون طويلة بشكل خاص.

يجب أن يوضح مثالان التباين المحتمل في أوقات المهام.

مثال على التباين: عمليات قراءة نظام الملفات طويلة الأمد

لنفترض أن الخادم الخاص بك يجب أن يقرأ الملفات من أجل معالجة بعض طلبات العميل. بعد الرجوع إلى واجهات برمجة تطبيقات نظام الملفات Node.js، اخترت استخدام fs.readFile() من أجل البساطة. ومع ذلك، فإن fs.readFile() (حاليًا) غير مقسم: فهو يقدم مهمة fs.read() واحدة تغطي الملف بأكمله. إذا قرأت ملفات أقصر لبعض المستخدمين وملفات أطول لمستخدمين آخرين، فقد يؤدي fs.readFile() إلى إدخال تباين كبير في أطوال المهام، مما يضر بإنتاجية مجمع العمال.

بالنسبة لسيناريو أسوأ الحالات، افترض أن المهاجم يمكنه إقناع الخادم الخاص بك بقراءة ملف عشوائي (هذه ثغرة أمنية لاجتياز الدليل). إذا كان الخادم الخاص بك يعمل بنظام Linux، فيمكن للمهاجم تسمية ملف بطيء للغاية: /dev/random. لجميع الأغراض العملية، /dev/random بطيء للغاية إلى ما لا نهاية، وكل عامل يُطلب منه القراءة من /dev/random لن ينهي هذه المهمة أبدًا. ثم يرسل المهاجم k من الطلبات، واحد لكل عامل، ولن يحرز أي طلبات عميل أخرى تستخدم مجمع العمال تقدمًا.

مثال على التباين: عمليات التشفير طويلة الأمد

لنفترض أن الخادم الخاص بك يقوم بإنشاء بايتات عشوائية آمنة مشفرة باستخدام crypto.randomBytes(). crypto.randomBytes() غير مقسم: فهو ينشئ مهمة randomBytes() واحدة لإنشاء أكبر عدد ممكن من البايتات التي طلبتها. إذا قمت بإنشاء عدد أقل من البايتات لبعض المستخدمين وعدد أكبر من البايتات لمستخدمين آخرين، فإن crypto.randomBytes() هو مصدر آخر للتباين في أطوال المهام.

تقسيم المهام

يمكن أن تضر المهام ذات التكاليف الزمنية المتغيرة بإنتاجية مجمع العمال. لتقليل التباين في أوقات المهام قدر الإمكان، يجب عليك تقسيم كل مهمة إلى مهام فرعية ذات تكلفة قابلة للمقارنة. عند اكتمال كل مهمة فرعية، يجب أن ترسل المهمة الفرعية التالية، وعندما تكتمل المهمة الفرعية الأخيرة، يجب أن تخطر المرسل.

لمتابعة مثال fs.readFile()، يجب عليك بدلاً من ذلك استخدام fs.read() (تقسيم يدوي) أو ReadStream (مقسم تلقائيًا).

ينطبق نفس المبدأ على المهام المرتبطة بوحدة المعالجة المركزية؛ قد يكون مثال asyncAvg غير مناسب لحلقة الأحداث، ولكنه مناسب تمامًا لمجمع العمال.

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

لاحظ أن عدد المهام الفرعية المكتملة ليس مقياسًا مفيدًا لإنتاجية مجمع العمال. بدلاً من ذلك، اهتم بعدد المهام المكتملة.

تجنب تقسيم المهام

تذكر أن الغرض من تقسيم المهام هو تقليل التباين في أوقات المهام. إذا كان بإمكانك التمييز بين المهام الأقصر والمهام الأطول (على سبيل المثال، جمع مصفوفة مقابل فرز مصفوفة)، فيمكنك إنشاء مجموعة عامل لكل فئة من المهام. يعد توجيه المهام الأقصر والمهام الأطول إلى مجموعات عامل منفصلة طريقة أخرى لتقليل التباين في وقت المهمة.

بدلاً من هذا النهج، يتحمل تقسيم المهام تكاليف إضافية (تكاليف إنشاء تمثيل مهمة لمجموعة العامل ومعالجة قائمة انتظار مجموعة العامل)، وتجنب التقسيم يوفر لك تكاليف رحلات إضافية إلى مجموعة العامل. كما أنه يمنعك من ارتكاب أخطاء في تقسيم مهامك.

الجانب السلبي لهذا النهج هو أن العمال في جميع مجموعات العمال هذه سوف يتحملون تكاليف إضافية من حيث المساحة والوقت وسوف يتنافسون مع بعضهم البعض على وقت وحدة المعالجة المركزية. تذكر أن كل مهمة مرتبطة بوحدة المعالجة المركزية لا تحرز تقدمًا إلا أثناء جدولتها. نتيجة لذلك، يجب عليك فقط التفكير في هذا النهج بعد تحليل دقيق.

مجموعة العمال: استنتاجات

سواء كنت تستخدم مجموعة عمال Node.js فقط أو تحتفظ بمجموعة عمال (مجموعات عمال) منفصلة، يجب عليك تحسين إنتاجية المهام لمجموعة (مجموعات) العمال الخاصة بك.

للقيام بذلك، قلل من التباين في أوقات المهام باستخدام تقسيم المهام.

مخاطر وحدات npm

في حين أن وحدات Node.js الأساسية تقدم لبنات بناء لمجموعة متنوعة من التطبيقات، إلا أنه في بعض الأحيان يكون هناك حاجة إلى شيء أكثر. يستفيد مطورو Node.js بشكل كبير من نظام npm البيئي، مع مئات الآلاف من الوحدات التي تقدم وظائف لتسريع عملية التطوير الخاصة بك.

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

  1. هل تحترم واجهات برمجة التطبيقات الخاصة بها؟
  2. هل يمكن لواجهات برمجة التطبيقات الخاصة بها أن تعيق حلقة الأحداث أو أحد العمال؟ لا تبذل العديد من الوحدات أي جهد للإشارة إلى تكلفة واجهات برمجة التطبيقات الخاصة بها، مما يضر بالمجتمع.

بالنسبة لواجهات برمجة التطبيقات البسيطة، يمكنك تقدير تكلفة واجهات برمجة التطبيقات؛ ليس من الصعب فهم تكلفة معالجة السلاسل. ولكن في كثير من الحالات، ليس من الواضح كم قد تكلف واجهة برمجة التطبيقات.

إذا كنت تستدعي واجهة برمجة تطبيقات قد تفعل شيئًا مكلفًا، فتحقق جيدًا من التكلفة. اطلب من المطورين توثيقها، أو افحص الكود المصدري بنفسك (وقدم طلب سحب لتوثيق التكلفة).

تذكر، حتى إذا كانت واجهة برمجة التطبيقات غير متزامنة، فأنت لا تعرف مقدار الوقت الذي قد تقضيه على عامل أو على حلقة الأحداث في كل قسم من أقسامها. على سبيل المثال، لنفترض في مثال asyncAvg الوارد أعلاه، أن كل استدعاء للدالة المساعدة جمع نصف الأرقام بدلاً من أحدها. ثم ستظل هذه الوظيفة غير متزامنة، لكن تكلفة كل قسم ستكون O(n)، وليس O(1)، مما يجعل استخدامها أقل أمانًا لقيم عشوائية لـ n.

استنتاج

يحتوي Node.js على نوعين من الخيوط: حلقة أحداث واحدة و k من العمال. حلقة الأحداث مسؤولة عن استدعاءات JavaScript والإدخال/الإخراج غير المحظور، وينفذ العامل المهام المطابقة لرمز C++ الذي يكمل طلبًا غير متزامن، بما في ذلك الإدخال/الإخراج المحظور والعمليات المكثفة لوحدة المعالجة المركزية. يعمل كلا النوعين من الخيوط على نشاط واحد فقط في كل مرة. إذا استغرق أي رد اتصال أو مهمة وقتًا طويلاً، فسيتم حظر الخيط الذي يقوم بتشغيله. إذا كان تطبيقك يقوم باستدعاءات أو مهام حظر، فقد يؤدي ذلك إلى انخفاض الإنتاجية (العملاء/الثانية) في أفضل الأحوال، وإنكار كامل للخدمة في أسوأ الأحوال.

لكتابة خادم ويب عالي الإنتاجية وأكثر مقاومة لهجمات DoS، يجب عليك التأكد من أنه في الإدخال الحميد والخبيث، لن يتم حظر حلقة الأحداث أو العمال.