Skip to content

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

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

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

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

ملخص

تشغّل 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. [نظام الملفات][/api/fs]: جميع واجهات برمجة تطبيقات نظام الملفات باستثناء fs.FSWatcher() وتلك التي هي متزامنة بشكل صريح تستخدم مجموعة مؤشرات الترابط libuv.
  2. كثيفة وحدة المعالجة المركزية
    1. التشفير: crypto.pbkdf2()، crypto.scrypt()، crypto.randomBytes()، crypto.randomFill()، crypto.generateKeyPair().
    2. Zlib: جميع واجهات برمجة تطبيقات zlib باستثناء تلك التي هي متزامنة بشكل صريح تستخدم مجموعة مؤشرات الترابط libuv.

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

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

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

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

في الحقيقة، لا تحافظ حلقة الأحداث على قائمة انتظار فعلية. بدلاً من ذلك، لديها مجموعة من مُوصِفات الملفات التي تطلب من نظام التشغيل مراقبتها، باستخدام آلية مثل 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

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

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

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

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

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

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

مثال REDOS

فيما يلي مثال على تعبير عادي ضعيف يكشف خادمًا عن REDOS:

javascript
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)
})

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

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

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

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

توجد بعض الأدوات للتحقق من تعبيراتك العادية بحثًا عن السلامة، مثل:

ومع ذلك، لن يكتشف أي من هذين التعبيرين العاديين جميع التعبيرات العادية الضعيفة.

نهج آخر هو استخدام محرك تعبير عادي مختلف. يمكنك استخدام وحدة node-re2، التي تستخدم محرك التعبير العادي RE2 السريع من جوجل. ولكن احذر، 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 الإصدار 9.

حجب حلقة الأحداث: هجوم إنكار الخدمة باستخدام JSON

JSON.parse و JSON.stringify هما عمليتان أخريان قد تكونان مكلفَتين. بينما هاتان العمليتان من رتبة O(n) في طول المدخل، إلا أنهما قد تستغرقان وقتًا طويلًا بشكل مفاجئ لـ n كبيرة.

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

مثال: حجب JSON. نقوم بإنشاء كائن obj بحجم 2^21 و JSON.stringify له، ونشغل indexOf على السلسلة، ثم JSON.parse له. سلسلة JSON.stringify هي 50 ميجابايت. يستغرق تحويل الكائن إلى سلسلة 0.7 ثانية، و 0.03 ثانية لـ indexOf على السلسلة التي يبلغ حجمها 50 ميجابايت، و 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)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('indexOf الخالص استغرق ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse استغرق ' + took)

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

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

الحسابات المعقدة دون حجب حلقة الأحداث

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

التقسيم

يمكنك تقسيم حساباتك بحيث يتم تشغيل كل منها على حلقة الأحداث ولكنها تنتج بانتظام (تمنح دورات لـ) الأحداث المعلقة الأخرى. في جافا سكريبت، من السهل حفظ حالة المهمة الجارية في إغلاق، كما هو موضح في المثال 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('متوسط 1-n: ' + avg)
})

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

تفريغ الحمل

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

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

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

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

لا يجب عليك ببساطة إنشاء Child Process لكل عميل. يمكنك تلقي طلبات العميل بشكل أسرع من إنشاء الأطفال وإدارتهم، وقد يصبح خادمك قنبلة شوك.

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

للمشاكل المتعلقة بالتسلسل، راجع قسم JSON DOS.

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

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

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

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

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

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

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

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

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

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

لا تحجب مجموعة العاملين

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

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

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

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

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

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

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

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

لنفترض أن الخادم الخاص بك يجب أن يقرأ الملفات من أجل التعامل مع بعض طلبات العميل. بعد الرجوع إلى واجهات برمجة تطبيقات نظام الملفات في Node.js [/api/fs]، اخترت استخدام 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 (المقسم تلقائيًا).

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

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

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

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

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

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

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

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

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

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

مخاطر وحدات npm

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

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

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

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

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

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

الخاتمة

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

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