تجميع كل شيء معاً: العقود الآجلة والمهام والخيوط (Futures, Tasks, and Threads)
كما رأينا في الفصل 16، توفر الخيوط (Threads) نهجاً واحداً للتزامن (Concurrency). لقد رأينا نهجاً آخر في هذا الفصل: استخدام البرمجة غير المتزامنة (Async) مع العقود الآجلة (Futures) والتدفقات (Streams). إذا كنت تتساءل متى تختار طريقة على الأخرى، فالإجابة هي: يعتمد ذلك على الحالة! وفي كثير من الحالات، لا يكون الاختيار بين Threads أو Async، بل بالأحرى Threads و Async معاً.
وفرت العديد من أنظمة التشغيل نماذج Concurrency تعتمد على الخيوط لعقود من الزمن، ونتيجة لذلك تدعمها العديد من لغات البرمجة. ومع ذلك، فإن هذه النماذج لا تخلو من المقايضات. ففي العديد من أنظمة التشغيل، تستهلك Threads قدراً لا بأس به من الذاكرة لكل Thread. كما أن Threads لا تكون خياراً متاحاً إلا عندما يدعمها نظام التشغيل والأجهزة الخاصة بك. وعلى عكس أجهزة الكمبيوتر المكتبية والمحمولة السائدة، فإن بعض الأنظمة المضمنة (Embedded Systems) لا تحتوي على نظام تشغيل على الإطلاق، وبالتالي لا تحتوي أيضاً على Threads.
يوفر نموذج Async مجموعة مختلفة—ومكملة في النهاية—من المقايضات. في نموذج Async، لا تتطلب العمليات المتزامنة Threads خاصة بها. بدلاً من ذلك، يمكن تشغيلها على مهام (Tasks)، كما هو الحال عندما استخدمنا trpl::spawn_task لبدء العمل من دالة متزامنة (Synchronous Function) في قسم Streams. تشبه Task الخيط (Thread)، ولكن بدلاً من إدارتها بواسطة نظام التشغيل، يتم إدارتها بواسطة شفرة برمجية (Code) على مستوى المكتبة: وقت التشغيل (Runtime).
هناك سبب يجعل واجهات برمجة التطبيقات (APIs) لإنشاء (Spawning) الخيوط وإنشاء المهام متشابهة جداً. تعمل Threads كحدود لمجموعات من العمليات المتزامنة؛ حيث يكون Concurrency ممكناً بين Threads. وتعمل Tasks كحدود لمجموعات من العمليات غير المتزامنة؛ حيث يكون Concurrency ممكناً بين و داخل Tasks، لأن Task يمكنها التبديل بين Futures في جسمها (Body). أخيراً، تعتبر Futures هي الوحدة الأكثر دقة لـ Concurrency في Rust، وقد يمثل كل Future شجرة من Futures الأخرى. يدير Runtime—وتحديداً المنفذ (Executor) الخاص به—المهام (Tasks)، وتدير Tasks العقود الآجلة (Futures). وفي هذا الصدد، تشبه Tasks خيوطاً خفيفة الوزن يديرها Runtime مع قدرات إضافية تأتي من كونها تدار بواسطة Runtime بدلاً من نظام التشغيل.
هذا لا يعني أن Async Tasks دائماً أفضل من Threads (أو العكس). يعتبر Concurrency باستخدام Threads نموذجاً برمجياً أبسط في بعض النواحي من Concurrency باستخدام async. يمكن أن يكون ذلك نقطة قوة أو نقطة ضعف. تعتبر Threads نوعاً ما “أطلق وانسَ” (Fire and forget)؛ فليس لها مكافئ أصلي لـ Future، لذا فهي تعمل ببساطة حتى الاكتمال دون مقاطعة إلا من قبل نظام التشغيل نفسه.
واتضح أن Threads و Tasks غالباً ما يعملان معاً بشكل جيد جداً، لأن Tasks يمكن (على الأقل في بعض Runtimes) نقلها بين Threads. في الواقع، تحت الغطاء، فإن Runtime الذي كنا نستخدمه—بما في ذلك دالتي spawn_blocking و spawn_task—هو متعدد الخيوط (Multithreaded) بشكل افتراضي! تستخدم العديد من Runtimes نهجاً يسمى سرقة العمل (Work stealing) لنقل Tasks بشكل شفاف بين Threads، بناءً على كيفية استخدام Threads حالياً، لتحسين الأداء العام للنظام. يتطلب هذا النهج فعلياً وجود Threads و Tasks، وبالتالي Futures.
عند التفكير في الطريقة التي يجب استخدامها ومتى، ضع في اعتبارك هذه القواعد العامة:
- إذا كان العمل قابلاً للتوازي بشكل كبير (أي مرتبط بالمعالج CPU-bound)، مثل معالجة مجموعة من البيانات حيث يمكن معالجة كل جزء بشكل منفصل، فإن Threads هي الخيار الأفضل.
- إذا كان العمل متزامناً بشكل كبير (أي مرتبط بالإدخال والإخراج I/O-bound)، مثل التعامل مع الرسائل من مجموعة من المصادر المختلفة التي قد تأتي على فترات مختلفة أو بمعدلات مختلفة، فإن Async هو الخيار الأفضل.
وإذا كنت بحاجة إلى كل من التوازي (Parallelism) والتزامن (Concurrency)، فلا يتعين عليك الاختيار بين Threads و Async. يمكنك استخدامهما معاً بحرية، مما يسمح لكل منهما بلعب الدور الذي يبرع فيه. على سبيل المثال، تعرض القائمة 17-25 مثالاً شائعاً جداً لهذا النوع من المزيج في Code لغة Rust الواقعي.
extern crate trpl; // for mdbook test
use std::{thread, time::Duration};
fn main() {
let (tx, mut rx) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
tx.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::block_on(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
نبدأ بإنشاء قناة غير متزامنة (Async Channel)، ثم إنشاء Thread يأخذ ملكية جانب المرسل من القناة باستخدام الكلمة المفتاحية (Keyword) move. داخل Thread، نرسل الأرقام من 1 إلى 10، مع النوم لمدة ثانية بين كل رقم. أخيراً، نقوم بتشغيل Future تم إنشاؤه باستخدام كتلة Async ممررة إلى trpl::block_on تماماً كما فعلنا طوال الفصل. في ذلك Future، ننتظر (Await) تلك الرسائل، تماماً كما في أمثلة تمرير الرسائل (Message-passing) الأخرى التي رأيناها.
للعودة إلى السيناريو الذي افتتحنا به الفصل، تخيل تشغيل مجموعة من مهام ترميز الفيديو (Video encoding tasks) باستخدام Thread مخصص (لأن ترميز الفيديو مرتبط بالحوسبة Compute-bound) ولكن مع إخطار واجهة المستخدم (UI) بأن تلك العمليات قد اكتملت باستخدام Async Channel. هناك أمثلة لا حصر لها لهذه الأنواع من التوليفات في حالات الاستخدام الواقعية.
ملخص
هذه ليست المرة الأخيرة التي سترى فيها Concurrency في هذا الكتاب. سيطبق المشروع في الفصل 21 هذه المفاهيم في موقف أكثر واقعية من الأمثلة البسيطة التي تمت مناقشتها هنا، وسيقارن بين حل المشكلات باستخدام Threads مقابل Tasks و Futures بشكل مباشر أكثر.
بغض النظر عن أي من هذه الأساليب تختار، تمنحك Rust الأدوات التي تحتاجها لكتابة Code آمن وسريع ومتزامن—سواء كان ذلك لخادم ويب عالي الإنتاجية أو لنظام تشغيل مضمن.
بعد ذلك، سنتحدث عن الطرق الاصطلاحية (Idiomatic ways) لنمذجة المشكلات وهيكلة الحلول مع زيادة حجم برامج Rust الخاصة بك. بالإضافة إلى ذلك، سنناقش كيف ترتبط اصطلاحات Rust بتلك التي قد تكون مألوفاً بها من البرمجة كائنية التوجه (Object-oriented programming).