01.09.2022 12 min read

البرمجة غير المتزامنة في JavaScript - لماذا هي مهمة

بواسطة Mirza Leka

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

Lightning over sea

Callback وPromises وAsync Await. نستخدم هذه الكلمات المفتاحية عند جلب البيانات من واجهة برمجة تطبيقات، أو إنشاء تأخيرات، أو انتظار استدعاءات قاعدة البيانات، إلخ. لكن لماذا؟ لماذا نتبنى الأنماط غير المتزامنة أصلًا؟ لنكتشف ذلك!

ماذا تعني كلمة "غير متزامن" (Asynchronous)؟

بالتعريف - هي قطعة من الكود ستبدأ الآن وتنتهي في وقت ما مستقبلًا. من أمثلة الكود غير المتزامن:

  • طلب شبكة (استدعاء واجهة برمجة تطبيقات)
  • مؤقّت (SetTimeout / SetInterval)
  • Promise، إلخ.

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

console.log('Frodo')

setTimeout(() => console.log('Sam'), 1000)

setTimeout(() => console.log('Pippin'), 0)

console.log('Merry')

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

  • طباعة "Frodo"
  • الانتظار 1000 ميلي ثانية (ثانية واحدة) وطباعة "Sam"
  • طباعة "Pippin" وأخيرًا
  • طباعة "Merry"

لكن ما سيحدث فعليًا هو هذا:

[LOG]: "Frodo"

[LOG]: "Merry"

[LOG]: "Pippin"

[LOG]: "Sam" // after waited for whole second

غريب، أليس كذلك؟ ما حدث فعليًا هو:

  • نطبع "Frodo"
  • ثم يأتي "Sam". هنا تلاحظ JavaScript مؤقّتًا وتجدوله لوقت لاحق
  • ثم يأتي مؤقّت آخر بـ"Pippin" وتُعاد جدولته
  • نطبع "Merry"
  • ثم تظهر الاستدعاءات المجدوَلة بترتيب اكتمالها. تُطبع "Pippin" أولًا لأنها استغرقت وقتًا أقل (0 ميلي ثانية) لتكتمل مقارنة بـ"Sam" (1000 ميلي ثانية).
يمكننا أن نرى أن هذه المؤقتات تجدول التنفيذ بدلًا من إيقافه مؤقتًا. ما يحدث فعليًا هنا هو أن JavaScript تعالج الكود غير المتزامن في الخلفية بينما يُنفَّذ باقي الكود. كيف هذا ممكن؟
Abstract bubbles

الخيوط (Threads)

الخيط (Thread) هو مجموعة صغيرة من التعليمات مصمَّمة لتُجدوَل وتُنفَّذ بواسطة المعالج.

يبدأ كل شيء بعملية (process). قد تمتلك العملية خيطًا واحدًا أو أكثر. كل خيط مصمَّم لتنفيذ مهمة واحدة من البداية إلى النهاية.

تخيّل تطبيقًا خلفيًا نموذجيًا يستخدم تعدد الخيوط.

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

إذن يمكن للخيط الأساسي إنشاء خيوط جديدة في كل مرة يصل فيها طلب جديد.

الآن بعد أن فهمنا لماذا نحتاج خيوطًا متعددة، قد تظن أن JavaScript تستخدم نفس الآلية لمعالجة البيانات. لكن، إليك المفاجأة، JavaScript أحادية الخيط (single-threaded)!

لكن لماذا إذن لا تحجب setTimeout أو استدعاء واجهة برمجة تطبيقات تنفيذ البرنامج؟
Abstract bubbles

بيئات وقت التشغيل (Runtime Environments)

قبل أن نجيب على السؤال السابق، لنفهم أولًا ما هي بيئات وقت التشغيل. بيئة وقت التشغيل هي ما يتيح لنا كتابة JavaScript في كل مكان، سواء في المتصفح أو على الخادم. هناك جزآن سنناقشهما:

  • محرك JavaScript
  • WEB API

محرك JS - المحرك مسؤول عن تحويل كود JavaScript إلى كود آلي. وهو ما يتيح لنا تنفيذ JavaScript على أجهزتنا. هناك عدة محركات، لكن الأشهر هو V8، لأنه يشغّل Google Chrome وOpera وMicrosoft Edge.

كومة الاستدعاءات (Call Stack) (يُشار إليها أحيانًا فقط بـ Stack) هي بنية بيانات من نوع stack تخزّن معلومات عن الروتينات الفرعية النشطة لبرنامج حاسوبي. أساسًا، إنها مساحة يوضع فيها كل كود JS لدينا. تساعدنا أيضًا على تتبع مكاننا في الكود.

Call stack diagram

تعمل الـ stack بحيث يُدفَع كل سطر من الكود إلى الـ stack (من الأسفل إلى الأعلى) ويُزال (من الأعلى إلى الأسفل) عند التنفيذ (LIFO). تُنفَّذ العبارة الأخيرة أولًا، ثم التي قبلها، وهكذا حتى تصبح الـ stack فارغة. يمكننا التحقق من ذلك بتشغيل هذا الكود في وضع التصحيح.

function sum(a, b) {
  const result = a + b
  return displayResult(result)
}

function displayResult(result) {
  console.log(result);
}

debugger
sum(2, 3)
Call stack debug

يُرتَّب الكود بترتيب معكوس كما هو متوقع. يبدأ كل شيء بتشغيل دالة main() التي ينشئها المحرك والغرض منها تشغيل كودنا. نحن لا نكتب main() يدويًا. بمجرد أن تصبح الـ call stack فارغة، ستُزال main() أيضًا.

Web API

واجهات Web API هي واجهات مدمجة في وقت التشغيل وتوفّر ميزات أصلية يمكن استخدامها في تطبيق JavaScript. تشمل هذه:

  • نموذج كائن المستند (واجهة برمجة المتصفح)
  • Fetch API (واجهة برمجة المتصفح/الخادم)
  • نظام الملفات (واجهة برمجة الخادم)
  • Workers API (واجهة برمجة المتصفح/الخادم)
  • إلخ.

بما أن هذه ليست مدمجة في اللغة، بل جزء من وقت التشغيل، فهي لا تُعالَج بواسطة خيط JavaScript.

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

تبقى المهام المتزامنة على الخيط الرئيسي (call stack) وتُنفَّذ أولًا. أما المهام غير المتزامنة فتمر بعملية مختلفة ثم تُدفَع إلى الـ stack وتُنفَّذ على الخيط. لهذا نرى "Frodo" و"Merry" تُطبَعان أولًا، بينما تظهر "Sam" و"Pippin" لاحقًا.

console.log('Frodo') // sync
setTimeout(() => console.log('Sam'), 1000) // async
setTimeout(() => console.log('Pippin'), 0) // async
console.log('Merry') // sync

تحدث جدولة المهام هذه على جانب الواجهة الخلفية من JavaScript أيضًا. لهذا فإن أشياء مثل:

  • استدعاءات الشبكة (REST API)
  • المؤقتات (Timeouts)
  • التدفقات (Streams)
  • عمليات الإدخال/الإخراج (القراءة/الكتابة في الملفات/قواعد البيانات)
  • مهام نظام التشغيل المعلَّقة (الاستماع إلى منفذ)

لا تحجب التنفيذ على الخيط الرئيسي. ولهذا رُوِّج لـ Node.js على أنه Non-Blocking I/O.

في حالة Node.js، تُدار واجهات Web API بواسطة مكتبة Libuv. مكتوبة Libuv بلغة C، وهي لغة متعددة الخيوط، ما يعني أن Node.js تستخدم خيوطًا خلف الكواليس. في حالة Deno، تستخدم مكتبة Rust تُدعى Tokio. وكل شيء آخر، مثل الحلقات والشروط والتصريحات، إلخ، يُنفَّذ على خيط JavaScript الرئيسي.

من الواضح أن هناك الكثير مما يحدث هنا، لذا قد تتساءل كيف تُدار العملية بأكملها؟

حلقة الأحداث (The Event Loop)

عند أول تشغيل لتطبيقنا، تنشئ JavaScript خيطًا واحدًا، وcall stack واحدة، ونسخة واحدة من حلقة الأحداث (Event Loop). حلقة الأحداث مسؤولة عن تنفيذ الكود، وجمع ومعالجة الأحداث، وتنفيذ المهام الفرعية في قائمة الانتظار.

مراحل حلقة الأحداث

يمكننا اعتبار حلقة الأحداث حلقة تدور باستمرار وتشغّل عمليات مختلفة في مراحل مختلفة:

  1. Timers: تنفّذ هذه المرحلة الاستدعاءات المجدوَلة بواسطة setTimeout() وsetInterval().
  2. Pending Callbacks: تنفّذ استدعاءات الإدخال/الإخراج المؤجَّلة إلى التكرار التالي للحلقة.
  3. Idle, Prepare: تُستخدَم داخليًا فقط.
  4. Poll: استرجاع أحداث الإدخال/الإخراج الجديدة وتنفيذ الاستدعاءات المرتبطة بها، مثل معالج API app.get('/', (req, res) => {}) أو نظام الملفات fs.readFile(). تُنفَّذ جميع الاستدعاءات تقريبًا في هذه المرحلة، باستثناء Timers وChecks.
  5. Check: تُستدعى استدعاءات setImmediate() هنا.
  6. Close Callbacks، مثل socket.on('close', ...).

يمكننا تصوّر حلقة الأحداث هكذا:

Event loop phases

في كل مرة يصل فيها طلب جديد، ستحدد حلقة الأحداث إلى أي مرحلة ينتمي وتنقله إليها. من المهم معرفة هذا لأن هذه المراحل تُنفَّذ بالترتيب. لذا إذا كان لدينا مؤقّت (setTimeout()) واستدعاء إدخال/إخراج (()fs.readFile)، حتى لو استغرقا نفس الوقت للتنفيذ، سيُنفَّذ المؤقّت أولًا.

ملاحظة: تُسمى دورة واحدة (spin) من حلقة الأحداث Tick.

أمثلة عملية

لنلقِ نظرة مرة أخرى على مثالنا الأول ونحاول فهم كيفية عمل كل شيء خلف الكواليس.

  • نبدأ بظهور Frodo وSam وPippin وMerry على الـ stack.
Event loop step 1
  • ثم نفصل المهام غير المتزامنة وندفعها إلى Web API.
Event loop step 2
  • بعد ذلك ننفّذ سجلات وحدة التحكم (Frodo وMerry)، ثم نستدعي ()main.
Event loop step 3
  • بمجرد أن تصبح call stack فارغة، نبدأ معالجة واجهات Web API. مؤقّت 'Pippin' له تأخير قصير، لذا يوضع في قائمة الانتظار بسرعة نسبيًا.
Event loop step 4
  • الآن تدخل حلقة الأحداث حيز العمل. تتحقق مما إذا كان هناك أي شيء في قائمة انتظار المهام. إذا كانت الـ stack فارغة، تأخذ أول عنصر في قائمة الانتظار وتدفعه إلى الـ stack.
Event loop step 5
  • ثم ننفّذ أول مؤقّت وصل إلى الـ stack. لكن هنا نصل إلى نقطة مثيرة جدًا للاهتمام. أصبحت call stack فارغة، لكن مؤقّت 'Sam' لا يزال قيد المعالجة. ستستمر حلقة الأحداث في الدوران، والتحقق من الـ stack وقائمة الانتظار ذهابًا وإيابًا أثناء انتظار مهمة جديدة تظهر في قائمة الانتظار.
Event loop step 6
  • أخيرًا، يصل المؤقّت الثاني إلى قائمة الانتظار.
Event loop step 7
  • وتعيده حلقة الأحداث إلى الـ stack، حيث يُنفَّذ ثم يُزال.
Event loop step 8
لكن، ماذا سيحدث لو كان لدينا مؤقّتان وكلاهما لهما نفس التأخير؟
Two timeouts scenario
  • سيُدفَع كلاهما إلى Web API.
Two timeouts step 2
  • ثم يوضعان في قائمة انتظار بترتيب اكتمالهما.
Two timeouts step 3
  • ثم يظهر أحدهما في call stack ويُنفَّذ. ثم الآخر.

أساسًا، تُنفَّذ المهام بترتيب وضعها في قائمة الانتظار. يحدث هذا لأن حلقة الأحداث يمكنها دفع شيء واحد فقط في كل مرة إلى الـ stack.

المهام الكبرى والمهام الصغرى (Macrotasks and Microtasks)

ناقشنا حتى الآن الاستدعاءات (Callbacks) بشكل أساسي، لكن ماذا عن الـ Promises؟ في أي مرحلة تُنفَّذ؟

قدّمت ECMAScript 2015 مفهوم قائمة انتظار المهام (Job Queue)، التي تستخدمها Promises وqueueMicrotask وMutation Observer API. إنها طريقة لتنفيذ نتيجة دالة غير متزامنة بأسرع وقت ممكن، بدلًا من وضعها في نهاية call stack.

ستحظى أي مهمة في قائمة انتظار المهام (Microtask) بأولوية أكبر من مهمة قائمة انتظار الاستدعاءات (Macrotask). يمكن تنفيذ المهام الصغرى في كل مرحلة من حلقة الأحداث. في الواقع، لديها أسبقية.

Microtask queue diagram
setTimeout(() => console.log('Callback executed'), 0)
Promise.resolve().then(() => console.log('Promise executed'))

إذا كان لدينا Callback وPromise في نفس الكتلة، سيُنفَّذ الـ Promise قبل الـ Callback.

[LOG]: "Promise executed"
[LOG]: "Callback executed"

ينطبق الأمر نفسه على دالة process.nextTick() في Node.js التي لها أسبقية على الـ Promises. تُستدعى هذه الدالة في كل tick (دورة) من حلقة الأحداث.

Promise.resolve().then(() => console.log('Promise logged'))
process.nextTick(() => {
  console.log('Next tick logged')
});

//
[LOG]: "Next tick logged"
[LOG]: "Promise logged"

هذه هي الطريقة التي يمكننا بها إخبار محرك JavaScript بمعالجة دالة بشكل غير متزامن بأسرع وقت ممكن.

لا تحجب حلقة الأحداث

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

ستحجب أي مهمة متزامنة حلقة الأحداث، لأن حلقة الأحداث لا يمكنها العمل حتى تصبح call stack فارغة. لكن هل كنت تعلم أن الـ Promises يمكنها أيضًا حجب حلقة الأحداث؟

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

setTimeout(() => { console.log('Timeout') }, 0)
Promise.resolve().then(() => { console.log('Promise 1') })
Promise.resolve().then(() => { console.log('Promise 2') })
Promise.resolve().then(() => { console.log('Promise 3') })
//
[LOG]: "Promise 1"
[LOG]: "Promise 2"
[LOG]: "Promise 3"
[LOG]: "Timeout"

هذه مشكلة، لأننا قد نريد القراءة من الملفات أو تنفيذ عمليات إدخال/إخراج أخرى تُدار عبر Callbacks، لكن نظرًا لأن Promise له أولوية أعلى، سيحجب حلقة الأحداث. لحل هذا، يمكننا تغليف كود Callback في Promise أو البحث عن بدائل API تستخدم Promises:

const fs = require('fs').promises;
fs.readFile('/file.txt')
  .then((result) => console.log(result))
  .catch((err) => console.error(err));

أو تجنّب استخدام المهام الصغرى حيث قد تُنشئ عنق زجاجة.

العمليات المتداخلة

نعرف الآن أنه إذا وضعنا Promise وCallback تحت نفس المظلة، سيكون لـ Promise أسبقية على Callback. لنؤكد ذلك تمامًا، لنرَ هذا مرة أخرى بكود متداخل.

setTimeout(() => {
  Promise.resolve().then(() => console.log('Promise 1'))
  setTimeout(() => {
    console.log('Timeout 1')
  }, 0)
  console.log('Logger 1')
}, 1000)

setTimeout(() => {
  setTimeout(() => {
    console.log('Timeout 2')
  }, 0)
  Promise.resolve().then(() => console.log('Promise 2'))
  console.log('Logger 2')
}, 1000)

Promise.resolve().then(() => console.log('Promise 0'))

في هذه الحالة، سيكون الناتج:

[LOG]: "Promise 0"
[LOG]: "Logger 1"
[LOG]: "Promise 1"
[LOG]: "Logger 2"
[LOG]: "Promise 2"
[LOG]: "Timeout 1"
[LOG]: "Timeout 2"
  • عند بدء التطبيق، لا توجد مهام حاجبة، لذا نستدعي أول Promise، لأن له أولوية على المؤقتات.
  • ثم نستدعي أول مؤقّت. الكود بداخله غير متاح فورًا بسبب التأخير (1000 ميلي ثانية)، لذا تنتقل حلقة الأحداث إلى المؤقّت التالي.
  • المؤقّت الثاني له تأخير أيضًا. ما ستفعله حلقة الأحداث هو إجراء بضع دورات (ticks) حتى تنتهي مدة أحد المؤقّتين.
  • عند الاكتمال، تنظر حلقة الأحداث داخل المؤقّت الأول وتلاحظ logger وmicrotask وmacrotask. يُنفَّذ logger أولًا، يليه Promise (microtask) ضمن نفس النطاق.
  • على نفس المستوى، خارج نطاق الغلاف الأول مباشرة، لدينا مؤقّت آخر، يحتوي أيضًا على logger وmicro وmacrotask.
  • يُطبع الـ Logger الثاني، يليه microtask ثانٍ.
  • أخيرًا، نصل إلى macrotasks لأن لها أقل أولوية. لذا نستدعي كليهما.

Queue Microtask

Queue Microtask هي واجهة برمجة متصفح جديدة تعمل بعد أن تكمل المهمة الحالية عملها وعندما لا يوجد كود آخر ينتظر التنفيذ قبل إعادة التحكم في سياق التنفيذ إلى حلقة أحداث المتصفح. أي شيء نضعه بداخلها سيُعامَل بأولوية.

queueMicrotask(() => {
  console.log('Hello World');
});

يشبه هذا ما كنا نفعله باستخدام setTimeout:

setTimeout(() => {
  console.log('Hello World');
}, 0);

الفرق الوحيد هو أن queueMicrotask ستُنفَّذ مسبقًا بسبب قائمة انتظار المهام الصغرى.

كشف غموض Async Await

أُضيفت ميزة Async Await إلى JavaScript مع ECMAScript 2017 كطريقة للتعامل بشكل أفضل مع الـ Promises. لكن هناك سوء فهم شائع حولها.

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

في C#، استدعاءات API متزامنة افتراضيًا، وهذا ليس مشكلة كبيرة لأن C# متعددة الخيوط.

public List<UserClass> GetUsers(int userId)
{
  var result = _userContext.GetUsers()
    .Where(...)
    .ToList();
  return result;
}

يمكننا تحويل هذا إلى النسخة غير المتزامنة باستخدام صنف Task.

public async Task<List<UserClass>> GetUsers(int userId)
{
  var result = await _userContext.GetUsers()
    .Where(...)
    .ToListAsync();
  return result.Value;
}

قد يظن المرء أن JavaScript تعمل بنفس الطريقة، لكننا تعلّمنا للتو أن JavaScript يمكنها تشغيل مهام غير متزامنة في Callbacks (macrotasks) كانت جزءًا من اللغة قبل Async Await بوقت طويل.

في الواقع، ستحاول JavaScript/Node.js دائمًا تنفيذ عمليات الإدخال/الإخراج بشكل غير متزامن ما لم نقل خلاف ذلك تحديدًا.

const fs = require('fs');
fs.readFile( path, options ) // Asynchronous
fs.readFileSync( path, options ) // Synchronous
إذن ما هو الغرض من Async Await في JavaScript؟

Async Await هو غلاف للـ Promises - طريقة للتعامل معها بشكل أكثر قابلية للقراءة وأسهل للصيانة.

الاستخدام الأصلي للـ Promises:

function greeting() {
  const greetingPromise = Promise.resolve('Hello World')
  greetingPromise.then(greet => console.log(greet))
}
greeting();

بديل Async Await:

async function greeting() {
  const greetingPromise = Promise.resolve('Hello World')
  const greet = await greetingPromise;
  console.log(greet);
}
greeting();

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

لنستعرض بسرعة جميع الخطوات:

  • لاستخدام await داخل دالة، يجب تصريحها بالكلمة المفتاحية async
  • يمكن وضع الكلمة المفتاحية await فقط أمام تعبير يُقيَّم إلى Promise
  • تُوقِف الكلمة المفتاحية await تنفيذ الدالة غير المتزامنة مؤقتًا حتى تُسوَّى Promise (تُحل أو تُرفَض). عندما يحدث هذا، يُقيَّم تعبير await بأكمله إلى قيمة نتيجة Promise، ثم يستأنف تنفيذ الدالة غير المتزامنة.

رغم أن Async Await تجعل كودنا يبدو متزامنًا، ضع في اعتبارك أن هذا لا يزال غلافًا لـ Promise، وهو غير متزامن. لكن، هناك فرق طفيف - التوقف المؤقت (Pause).

function greetingWithPromises() {
  console.log('Logger 1')
  const greetingPromise = Promise.resolve('Hello World')
  greetingPromise.then(greet => console.log(greet))
  console.log('Logger 2')
}
greetingWithPromises();
//
[LOG]: "Logger 1"
[LOG]: "Logger 2"
[LOG]: "Hello World"

لنقارن هذا بـ Async Await:

async function greetingAsyncAwait() {
  console.log('Logger 1')
  const greetingPromise = Promise.resolve('Hello World')
  const greet = await greetingPromise;
  console.log(greet);
  console.log('Logger 2')
}
greetingAsyncAwait();
//
[LOG]: "Logger 1"
[LOG]: "Hello World"
[LOG]: "Logger 2"

لأن تعبير await يسبب توقف تنفيذ الدالة غير المتزامنة مؤقتًا حتى تُسوَّى Promise، فإن await لن تسمح لـ loggers بالاكتمال قبل متابعة التنفيذ. إذن أساسًا، تُنفَّذ Async Await قبل Promise.then() لأنها لا تحتاج لانتظار أن تصبح call stack فارغة.

المفاجأة الأخيرة مع الدوال غير المتزامنة في JavaScript - تُعيد دائمًا Promises، بغض النظر عمّا تحتويه.

Async functions return Promise

يمكن بعدها التعامل مع المخرَج عبر await آخر أو Promise.then().

نظام الملفات

تمامًا كما في مثال قراءة البيانات من ملف هذا:

const fs = require('fs').promises;
const output = await fs.readFile( path, options )

معالِجات API (Express.js)

قبل:

app.get('/', (req, res) => { // call to database .then( ... ) })

بعد:

app.get('/', async (req, res) => { // await call to database })

الخلاصة

تُفوّض JavaScript المهام غير المتزامنة من الخيط الرئيسي إلى Web API. لهذا لا تحجب الطلبات غير المتزامنة تنفيذ البرنامج.

ينطبق الأمر نفسه على الجانب الخلفي من JS. طلبات DNS غير متزامنة. عندما يصل طلب جديد، يُفوَّض إلى Web API. استدعاءات قاعدة البيانات (عمليات الإدخال/الإخراج) غير حاجبة أيضًا، لذا الخيط الرئيسي حر لتولي طلبات أخرى بينما تعالج خيوط Libuv طلبات الإدخال/الإخراج في الخلفية.

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

Summary diagram

للمهام الصغرى أولوية، وبالتالي تُنفَّذ مسبقًا، لكنها تحجب حلقة الأحداث. Async Await غلاف لدوال Promise يتيح لنا كتابة كود غير متزامن بطريقة متزامنة.

Rain window

الإيجابيات والسلبيات

الآن لنستعرض إيجابيات وسلبيات استخدام الكود غير المتزامن.

السلبيات

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

التطوير - كتابة وتصحيح الكود غير المتزامن صعب. ربما سمعت بمصطلح يُسمى Callback-Hell. إنه سلسلة من الدوال المتداخلة بعمق تعتمد على بعضها البعض وصعبة القراءة والصيانة جدًا.

const fs = require('fs');
const data = 'Hello World';
setTimeout(() => {
  fs.readFile(fileWithData, 'utf8', (err, response) => {
    if (err) return console.log(err);
    fs.writeFile(data, response, (err) => {
      if(err) return console.log(err);
      console.log('Done!');
    });
  });
}, 0);

هناك عيوب في الواجهات البرمجية الأحدث أيضًا. بما أن الدوال المُعلَّمة بالكلمة المفتاحية async تعيد Promise، إذا كان لدينا سلسلة من استدعاءات الدوال المتداخلة، يجب تعليم كل واحدة بـ async لتتمكن من استخدام الكلمة المفتاحية await بداخلها. وإلا، نميل لاستخدام Promises.

function changeGear() {
  return Promise.resolve('Fifth Gear');
}
async function hitTheGas() {
  return await changeGear();
}
async function drive() {
  const data = await hitTheGas();
  console.log(data); // Fifth Gear
}
drive();

العمليات المتزامنة - نعرف أن العمليات المتزامنة تُنفَّذ على الخيط الرئيسي. بما أن JavaScript تستخدم خيطًا واحدًا فقط للتعامل مع هذه العمليات، سيعمل تطبيقنا سطرًا بسطر وعندما يواجه حلقة، سيتوقف مؤقتًا.

لهذا JavaScript ليست جيدة للعمليات الحاجبة طويلة الأمد، مثل معالجة الأرقام الكثيفة. بما أن كل تنفيذها الحاجب على خيط واحد، لا يمكن تفويض العمليات طويلة الأمد إلى خيوط أخرى، ما يعني أن تطبيقنا سينتظر ويحجب كل شيء آخر. هذا على الأرجح أكبر عيب في JavaScript، لكن هناك بعض الحلول القابلة للتطبيق أُضيفت مؤخرًا إلى اللغة مثل Web Workers API.

الإيجابيات

تحسينات API - تُحسَّن واجهة Async باستمرار مع إصدارات جديدة من ECMAScript. بدأت بـ Callbacks، ثم انتقلت إلى Promises، ثم Async Await. جلبت واجهة Promise مؤخرًا إضافات جديدة مثل Promise.any() وPromise.race() وPromise.all(). وهناك أيضًا Top-Level Await.

مجموعة أدوات موسّعة - تأتي JavaScript مع قائمة من الأدوات للعمل مع البيانات غير المتزامنة، أدوات أصلية مثل:

  • XMLHttpRequest
  • AJAX
  • Fetch API

بالإضافة إلى أدوات طرف ثالث:

  • Axios
  • Rx.js
  • Async.js

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

تنفيذ غير حاجب - كما أثبتنا حتى الآن، تُعالَج الكود غير المتزامن خارج الخيط الرئيسي وبالتالي لا تحجب الخيط.

Motorcycles on road

مع ذلك، نعرف الآن لماذا من المهم استخدام الأنماط غير المتزامنة في JavaScript كلما أمكن. تستخدم العديد من أطر العمل والمكتبات الشائعة على NPM الأنماط غير المتزامنة تلقائيًا، رغم أن بعض الأقدم لا تزال تعمل فقط مع Callbacks.

Workers API

قبل الختام، لنقل كلمة أو اثنتين عن واجهة JavaScript Workers الجديدة. تتيح Web Workers تشغيل عملية سكريبت في خيط خلفي منفصل عن خيط التنفيذ الرئيسي لتطبيق JavaScript.

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

تمتلك Node.js أيضًا نسختها الخاصة باستخدام وحدة worker threads المدمجة. لكن هذه ليست فعّالة للمهام غير المتزامنة. الطريقة القياسية (حلقة الأحداث) أفضل بكثير للتعامل مع المهام غير المتزامنة.

تجمّع خيوط أفضل

حتى مع كل قدرات وقت التشغيل، لا نزال مقيدين بحجم تجمّع الخيوط. تتيح لنا مكتبة Libuv معالجة عدد قليل فقط من عمليات الإدخال/الإخراج في وقت واحد، نظرًا لامتلاك Libuv ما يصل إلى 4 خيوط في تجمّع الخيوط.

يمكننا زيادة هذا العدد إلى ما يصل إلى 1024 خيطًا بضبط متغيّر البيئة التالي عند بدء تشغيل التطبيق:

UV_THREADPOOL_SIZE = x

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

يمكننا التحقق من ذلك باستيراد وحدة OS (الأصلية في Node.js)، ثم استدعاء os.cpus() (التي تعيد مصفوفة) ثم طباعة طولها:

const os = require('os')
console.log(os.cpus().length) // e.g. 16

بعدها يمكننا ضبط حجم تجمّع الخيوط، إما في ملف .env أو ديناميكيًا (في ملف JS الرئيسي لدينا):

process.env.UV_THREADPOOL_SIZE = os.cpus().length
Boat at dusk

كلمة أخيرة

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

بشكل عام، إنها ميزة مذهلة تجعل JS يتميّز عن غيرها.

في الجزء الثاني سنتعمّق أكثر في أمثلة عملية مع Fetch API وObservables ومعالجة الأخطاء والمزيد.

قراءات إضافية عرض الكل
الخطوة التالية

طبّق هذه الأفكار على منتجك

يساعد استوديو فالنس الفرق ذات المخاطر العالية على تحويل الوضوح الهندسي إلى تسليم منتج معياري.