Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

أساسيات البرمجة غير المتزامنة (Asynchronous Programming): Async، وAwait، وFutures، وStreams

تستغرق العديد من العمليات التي نطلب من الحاسوب القيام بها وقتاً طويلاً للانتهاء. سيكون من الجيد لو تمكنا من القيام بشيء آخر بينما ننتظر اكتمال تلك العمليات الطويلة الأمد. توفر الحواسيب الحديثة تقنيتين للعمل على أكثر من عملية في وقت واحد: التوازي (Parallelism) والتزامن (Concurrency). ومع ذلك، فإن منطق برامجنا مكتوب بطريقة خطية غالباً. نود أن نكون قادرين على تحديد العمليات التي يجب أن يؤديها البرنامج والنقاط التي يمكن للدالة (Function) عندها أن تتوقف مؤقتاً ويمكن لجزء آخر من البرنامج أن يعمل بدلاً منها، دون الحاجة إلى تحديد الترتيب والطريقة التي يجب أن يعمل بها كل جزء من الكود (Code) مسبقاً وبدقة. البرمجة غير المتزامنة (Asynchronous Programming) هي تجريد (Abstraction) يسمح لنا بالتعبير عن الكود الخاص بنا من حيث نقاط التوقف المحتملة والنتائج النهائية التي تتولى تفاصيل التنسيق نيابة عنا.

يبني هذا الفصل على استخدام الفصل 16 للخيوط (Threads) من أجل Parallelism وConcurrency من خلال تقديم نهج بديل لكتابة Code: العقود (Futures) في Rust، والمجاري (Streams)، وصيغة async وawait التي تتيح لنا التعبير عن كيفية جعل العمليات غير متزامنة، والحزم (Crates) الخارجية التي تنفذ بيئات التشغيل غير المتزامنة (Asynchronous Runtimes): Code الذي يدير وينسق تنفيذ العمليات غير المتزامنة.

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

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

تصدير الفيديو هو مثال على عملية مقيدة بـ CPU (CPU-bound) أو مقيدة بالحوسبة (Compute-bound). فهي محدودة بسرعات معالجة البيانات المحتملة للحاسوب داخل CPU أو GPU، ومقدار تلك السرعة التي يمكن تخصيصها للعملية. تنزيل الفيديو هو مثال على عملية مقيدة بالإدخال والإخراج (I/O-bound)، لأنها محدودة بسرعة الإدخال والإخراج (Input and Output) للحاسوب؛ حيث لا يمكنها السير إلا بالسرعة التي يمكن بها إرسال البيانات عبر Network.

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

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

ملاحظة: هذه هي الطريقة التي تعمل بها معظم استدعاءات Functions، إذا فكرت في الأمر. ومع ذلك، فإن مصطلح Blocking عادة ما يكون محجوزاً لاستدعاءات Functions التي تتفاعل مع الملفات، أو Network، أو الموارد الأخرى على الحاسوب، لأن هذه هي الحالات التي يستفيد فيها البرنامج الفردي من كون العملية غير حاجبة (Non-blocking).

يمكننا تجنب حجب الخيط الرئيسي (Main Thread) الخاص بنا عن طريق إنشاء Thread مخصص لتنزيل كل ملف. ومع ذلك، فإن العبء الإضافي لموارد النظام التي تستخدمها تلك Threads سيصبح مشكلة في النهاية. سيكون من الأفضل إذا لم يكن الاستدعاء Blocking في المقام الأول، وبدلاً من ذلك يمكننا تحديد عدد من المهام التي نود أن يكملها برنامجنا والسماح لـ Runtime باختيار أفضل ترتيب وطريقة لتشغيلها.

هذا هو بالضبط ما يوفره لنا تجريب Async (اختصار لـ Asynchronous) في Rust. في هذا الفصل، ستتعلم كل شيء عن Async حيث نغطي المواضيع التالية:

  • كيفية استخدام صيغة async وawait في Rust وتنفيذ الدوال غير المتزامنة (Asynchronous Functions) باستخدام Runtime.
  • كيفية استخدام نموذج Async لحل بعض التحديات نفسها التي اطلعنا عليها في الفصل 16.
  • كيف يوفر تعدد الخيوط (Multithreading) وAsync حلولاً متكاملة يمكنك دمجها في كثير من الحالات.

قبل أن نرى كيف يعمل Async في الممارسة العملية، نحتاج إلى أخذ جولة قصيرة لمناقشة الاختلافات بين Parallelism وConcurrency.

التوازي والتزامن (Parallelism and Concurrency)

لقد تعاملنا مع Parallelism وConcurrency كأنهما قابلان للتبادل غالباً حتى الآن. الآن نحتاج إلى التمييز بينهما بدقة أكبر، لأن الاختلافات ستظهر بمجرد أن نبدأ العمل.

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

عندما يعمل فرد على عدة مهام مختلفة قبل اكتمال أي منها، فإن هذا يسمى تزامناً (Concurrency). إحدى طرق تنفيذ Concurrency تشبه وجود مشروعين مختلفين مفتوحين على حاسوبك، وعندما تشعر بالملل أو تتعثر في مشروع واحد، تنتقل إلى الآخر. أنت شخص واحد فقط، لذا لا يمكنك إحراز تقدم في كلتا المهمتين في نفس الوقت تماماً، ولكن يمكنك القيام بمهام متعددة (Multitask)، وإحراز تقدم في مهمة واحدة تلو الأخرى عن طريق التبديل بينهما (انظر الشكل 17-1).

مخطط يحتوي على صناديق مكدسة تحمل علامة المهمة أ والمهمة ب، مع وجود معينات فيها تمثل المهام الفرعية. تشير الأسهم من أ1 إلى ب1، ومن ب1 إلى أ2، ومن أ2 إلى ب2، ومن ب2 إلى أ3، ومن أ3 إلى أ4، ومن أ4 إلى ب3. الأسهم بين المهام الفرعية تعبر الصناديق بين المهمة أ والمهمة ب.
الشكل 17-1: سير عمل متزامن (Concurrent Workflow)، يتم فيه التبديل بين المهمة أ والمهمة ب

عندما يقوم الفريق بتقسيم مجموعة من المهام من خلال قيام كل عضو بأخذ مهمة واحدة والعمل عليها بمفرده، فإن هذا يسمى توازياً (Parallelism). يمكن لكل شخص في الفريق إحراز تقدم في نفس الوقت تماماً (انظر الشكل 17-2).

مخطط يحتوي على صناديق مكدسة تحمل علامة المهمة أ والمهمة ب، مع وجود معينات فيها تمثل المهام الفرعية. تشير الأسهم من أ1 إلى أ2، ومن أ2 إلى أ3، ومن أ3 إلى أ4، ومن ب1 إلى ب2، ومن ب2 إلى ب3. لا توجد أسهم تعبر بين صناديق المهمة أ والمهمة ب.
الشكل 17-2: سير عمل متوازٍ (Parallel Workflow)، حيث يتم العمل على المهمة أ والمهمة ب بشكل مستقل

في كلا سيري العمل هذين، قد تضطر إلى التنسيق بين المهام المختلفة. ربما اعتقدت أن المهمة المسندة لشخص واحد كانت مستقلة تماماً عن عمل أي شخص آخر، ولكنها تتطلب في الواقع أن ينهي شخص آخر في الفريق مهمته أولاً. يمكن القيام ببعض العمل بشكل متوازٍ، ولكن بعضه كان في الواقع تسلسلياً (Serial): لا يمكن أن يحدث إلا في سلسلة، مهمة تلو الأخرى، كما في الشكل 17-3.

مخطط يحتوي على صناديق مكدسة تحمل علامة المهمة أ والمهمة ب، مع وجود معينات فيها تمثل المهام الفرعية. في المهمة أ، تشير الأسهم من أ1 إلى أ2، ومن أ2 إلى زوج من الخطوط الرأسية السميكة مثل رمز 'الإيقاف المؤقت'، ومن ذلك الرمز إلى أ3. في المهمة ب، تشير الأسهم من ب1 إلى ب2، ومن ب2 إلى ب3، ومن ب3 إلى أ3، ومن ب3 إلى ب4.
الشكل 17-3: سير عمل متوازٍ جزئياً، حيث يتم العمل على المهمة أ والمهمة ب بشكل مستقل حتى يتم حجب المهمة أ3 بانتظار نتائج المهمة ب3.

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

يمكن أن يتقاطع Parallelism وConcurrency مع بعضهما البعض أيضاً. إذا علمت أن زميلاً لك متعثر حتى تنهي إحدى مهامك، فمن المحتمل أن تركز كل جهودك على تلك المهمة لـ “فك حجب” (Unblock) زميلك. لم تعد أنت وزميلك قادرين على العمل بشكل متوازٍ، ولم تعد قادراً أيضاً على العمل بشكل متزامن على مهامك الخاصة.

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

تشغيل Code غير متزامن في Rust يحدث عادةً بشكل متزامن. اعتماداً على الأجهزة، ونظام التشغيل، وAsynchronous Runtime الذي نستخدمه (المزيد عن Asynchronous Runtimes قريباً)، قد يستخدم هذا Concurrency أيضاً Parallelism في الخفاء.

الآن، دعونا نتعمق في كيفية عمل البرمجة غير المتزامنة في Rust فعلياً.