تُنفّذ شيفرة البرنامج في معظم أنظمة التشغيل الحالية ضمن عملية process ويُدير نظام التشغيل عمليات متعددة في وقت واحد، يمكنك أيضًا العثور على أجزاء مستقلة تعمل بصورةٍ متزامنة داخل البرنامج، وتسمى الميّزات التي تنفّذ هذه الأجزاء المستقلة بالخيوط threads، على سبيل المثال يمكن أن يحتوي خادم الويب web server على خيوط متعددة بحيث يمكنه أن يستجيب لأكثر من طلب واحد في نفس الوقت.
يمكن أن يؤدي تجزئة عمليات الحساب في برنامجك إلى خيوط متعددة لتنفيذ مهام متعددة في نفس الوقت إلى تحسين الأداء ولكنه يضيف تعقيدًا إضافيًا أيضًا. لا يوجد ضمان حول الترتيب الذي ستُنفَّذ فيه أجزاء من شيفرتك البرمجية في الخيوط المختلفة، وذلك بسبب إمكانية تنفيذ الخيوط بصورةٍ متزامنة، ويمكن لهذا أن يؤدي إلى مشاكل مثل:
- حالات التسابق race conditions، إذ يمكن للخيوط الوصول للبيانات أو المصادر بترتيب غير مضبوط.
- أقفال ميتة deadlocks، وهي الحالة التي ينتظر فيها الخيطان بعضهما بعضًا مما يمنع كلا الخيطين من الاستمرار.
- الأخطاء التي تحدث فقط في حالات معينة وتكون صعبة الحدوث دومًا والإصلاح بصورةٍ موثوقة.
تحاول لغة رست التخفيف من الآثار السلبية لاستخدام الخيوط ولكن لا تزال البرمجة في سياق متعدد الخيوط تتطلّب تفكيرًا حذرًا ويتطلب هيكل شيفرة برمجية مختلف عن ذلك الموجود في البرامج المنفذة في خيط مفرد.
تُنفّذ لغات البرمجة الخيوط بطرق مختلفة عن بعضها البعض، وتوفر العديد من أنظمة التشغيل واجهة برمجية يمكن للغة أن تستدعيها لإنشاء خيوط جديدة. تستخدم مكتبة رست القياسية نموذج 1:1 لتنفيذ الخيط، إذ يستخدم البرنامج خيط نظام تشغيل واحد لكل خيط لغة. هناك وحدات مصرفة تنفذ نماذج أخرى للخيوط لتقديم مقايضات مختلفة عن نموذج 1:1.
إنشاء خيط جديد باستخدام spawn
نستدعي الدالة thread::spawn
لإنشاء خيط جديد ونمرر لها مغلَّف closure يحتوي على الشيفرة التي نريد أن ننفذها في الخيط الجديد (تحدثنا عن المغلفات سابقًا في المقال المغلفات closures في لغة رست). يطبع المثال في الشيفرة 1 نصًا ما من خيط رئيسي ونص آخر من خيط جديد:
اسم الملف: src/main.rs
use std::thread; use std::time::Duration; fn main() { thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } }); for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } }
الشيفرة 1: إنشاء خيط جديد لطباعة شيء ما بينما يطبع الخيط الرئيسي شيئًا آخر
لاحظ أنه عندما يكتمل الخيط الرئيسي لبرنامج رست، تُغلَق كل الخيوط المُنشأة بغض النظر إذا أنهت التنفيذ أم لا، ويمكن لخرج هذا البرنامج أن يكون مختلفًا في كل مرة ولكنه سيكون مشابهًا لما يلي:
hi number 1 from the main thread! hi number 1 from the spawned thread! hi number 2 from the main thread! hi number 2 from the spawned thread! hi number 3 from the main thread! hi number 3 from the spawned thread! hi number 4 from the main thread! hi number 4 from the spawned thread! hi number 5 from the spawned thread!
تؤدي استدعاءات thread::sleep
إلى إجبار الخيط على إيقاف تنفيذه لمدة قصيرة مما يسمح بتشغيل خيوط مختلفة، ومن المحتمل أن تتناوب الخيوط ولكن هذا الأمر غير مضمون الحدوث، إذ يعتمد ذلك على كيفية جدولة نظام التشغيل الخاص للخيوط. يُطبع في التنفيذ السابق الخيط الرئيسي أولًا على الرغم من ظهور عبارة الطباعة من الخيط الذي أُنشئ أولًا في الشيفرة البرمجية، وعلى الرغم من أننا أخبرنا الخيط المُنشأ أن يطبع حتى تصبح i
مساوية للقيمة 9 إلا أنه وصل إلى 5 فقط قبل إغلاق الخيط الرئيسي.
إذا نفَّذت هذه الشيفرة ورأيت فقط المخرجات من الخيط الرئيسي أو لم ترَ أي تداخل، فحاول زيادة الأرقام في المجالات لإنشاء المزيد من الفرص لنظام التشغيل للتبديل بين الخيوط.
انتظار انتهاء كل الخيوط بضم المقابض Handles عن طريق join
لا توقف الشيفرة البرمجية الموجودة في الشيفرة 1 الخيط المُنتَج قبل الأوان في معظم الأوقات بسبب انتهاء الخيط الرئيسي، وإنما بسبب عدم وجود ضمان على الترتيب الذي تُنفَّذ به الخيوط، إذ لا يمكننا أيضًا ضمان أن الخيط المُنتَج سيُنفذ إطلاقًا.
يمكننا إصلاح مشكلة عدم تشغيل الخيط الناتج spawned أو انتهائه قبل الأوان عن طريق حفظ قيمة إرجاع thread::spawn
في متغير، إذ يكون النوع المُعاد من thread::spawn
هو JoinHandle
؛ الذي يمثّل قيمةً مملوكةً، بحيث عندما نستدعي التابع join
عليها ستنتظر حتى ينتهي الخيط الخاص بها. تُظهر الشيفرة 2 كيفية استعمال JoinHandle
الخاص بالخيط التي أنشأناه في الشيفرة 1 واستدعاء join
للتأكد من انتهاء الخيط المنتج قبل الخروج من الدالة main
:
اسم الملف: src/main.rs
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } }); for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } handle.join().unwrap(); }
الشيفرة 2: حفظ JoinHandle
من thread::spawn
لضمان تنفيذ الخيط لحين الاكتمال
يؤدي استدعاء join
على المقبض handle إلى إيقاف تنفيذ الخيط الجاري حتى ينتهي الخيط الذي يمثله المقبض، ويعني إيقاف تنفيذ blocking الخيط أن الخيط ممنوع من أداء العمل أو الخروج منه. يجب أن ينتج تنفيذ الشيفرة 2 خرجًا مشابهًا لما يلي لأننا وضعنا استدعاء join
بعد حلقة الخيط الرئيسي for
:
hi number 1 from the main thread! hi number 2 from the main thread! hi number 1 from the spawned thread! hi number 3 from the main thread! hi number 2 from the spawned thread! hi number 4 from the main thread! hi number 3 from the spawned thread! hi number 4 from the spawned thread! hi number 5 from the spawned thread! hi number 6 from the spawned thread! hi number 7 from the spawned thread! hi number 8 from the spawned thread! hi number 9 from the spawned thread!
يستمرّ الخيطان بالتناوب، وينتظر الخيط الرئيسي بسبب استدعاء ()handle.join
، ولا ينتهي حتى ينتهي الخيط الناتج.
دعنا نرى ما سيحدث عندما ننقل ()handle.join
إلى ما قبل حلقة for
في main
على النحو التالي:
اسم الملف: src/main.rs
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } }); handle.join().unwrap(); for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } }
سينتظر الخيط الرئيسي انتهاء الخيط الناتج، ثم سينفّذ الحلقة for
الخاصة به لذلك لن تتداخل المخرجات بعد الآن كما هو موضح هنا:
hi number 1 from the spawned thread! hi number 2 from the spawned thread! hi number 3 from the spawned thread! hi number 4 from the spawned thread! hi number 5 from the spawned thread! hi number 6 from the spawned thread! hi number 7 from the spawned thread! hi number 8 from the spawned thread! hi number 9 from the spawned thread! hi number 1 from the main thread! hi number 2 from the main thread! hi number 3 from the main thread! hi number 4 from the main thread!
يمكن أن تؤثر التفاصيل الصغيرة على ما إذا كانت الخيوط الخاصة بك تُنفَّذ في نفس الوقت أم لا مثل استدعاء join
.
استعمال مغلفات move مع الخيوط
نستخدم غالبًا الكلمة المفتاحية move
مع المغلفات التي تُمرَّر إلى thread::spawn
وذلك لأن المغلف سيأخذ بعد ذلك ملكية القيم التي يستخدمها من البيئة وبالتالي تُنقل ملكية هذه القيم من خيط إلى آخر، ناقشنا سابقًا في فقرة "الحصول على المعلومات من البيئة باستخدام المغلفات" من مقال المغلفات closures في لغة رست الكلمة المفتاحية move
في سياق المغلفات، إلا أننا سنركز الآن أكثر على التفاعل بين move
و thread::spawn
.
لاحظ في الشيفرة 1 أن المغلف الذي نمرّره إلى thread::spawn
لا يأخذ أي وسطاء arguments، إذ أننا لا نستخدم أي بيانات من الخيط الرئيسي في شيفرة الخيط المنتج، ولاستخدام البيانات من الخيط الرئيسي في الخيط المُنتج يجب أن يحصل مغلّف الخيط الناتج على القيم التي يحتاجها. تُظهر الشيفرة 3 محاولة إنشاء شعاع vector في الخيط الرئيسي واستعماله في الخيط الناتج، ومع ذلك لن ينجح هذا الأمر كما سترى بعد لحظة.
اسم الملف: src/main.rs
use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(|| { println!("Here's a vector: {:?}", v); }); handle.join().unwrap(); }
الشيفرة 3: محاولة استعمال شعاع منشأ بواسطة الخيط الرئيسي ضمن خيط آخر
يستخدم المغلّف الشعاع v
لذلك سوف يحصل على القيمة v
ويجعلها جزءًا من بيئة المغلف، ونظرًا لأن thread::spawn
ينفّذ المغلف في خيط جديد فيجب أن نكون قادرين على الوصول إلى v
داخل هذا الخيط الجديد، ولكن عندما نصرِّف الشيفرة السابقة نحصل على الخطأ التالي:
$ cargo run Compiling threads v0.1.0 (file:///projects/threads) error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function --> src/main.rs:6:32 | 6 | let handle = thread::spawn(|| { | ^^ may outlive borrowed value `v` 7 | println!("Here's a vector: {:?}", v); | - `v` is borrowed here | note: function requires argument type to outlive `'static` --> src/main.rs:6:18 | 6 | let handle = thread::spawn(|| { | __________________^ 7 | | println!("Here's a vector: {:?}", v); 8 | | }); | |______^ help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword | 6 | let handle = thread::spawn(move || { | ++++ For more information about this error, try `rustc --explain E0373`. error: could not compile `threads` due to previous error
تستنتج رست كيفية الحصول على القيمة v
ولأن !println
تحتاج فقط إلى مرجع إلى v
، يحاول المغلّف استعارة v
، ومع ذلك هناك مشكلة، إذ لا يمكن لرست معرفة المدة التي سيُنفَّذ فيها الخيط الناتج لذلك لا تعرف ما إذا كان المرجع إلى v
صالحًا دائمًا.
تقدّم الشيفرة 4 سيناريو من المرجح به أن يحتوي على مرجع غير صالح إلى v
:
اسم الملف: src/main.rs
use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(|| { println!("Here's a vector: {:?}", v); }); drop(v); // oh no! handle.join().unwrap(); }
الشيفرة 4: خيط مع مغلف يحاول أن يحصل على مرجع يشير للشعاع v
من خيط رئيسي يحرّر v
إذا سمحت لنا رست بتنفيذ الشيفرة البرمجية السابقة فهناك احتمال أن يوضَع الخيط الناتج في الخلفية فورًا دون تنفيذ إطلاقًا. يحتوي الخيط الناتج على مرجع يشير إلى v
من الداخل إلا أن الخيط الرئيسي يحرّر v
فورًا باستخدام دالة drop
التي ناقشناها سابقًا في مقال تنفيذ شيفرة برمجية عند تحرير الذاكرة cleanup باستخدام السمة Drop في لغة رست، وعندما يبدأ تنفيذ الخيط المنتج، لن تصبح v
صالحة، لذلك فإن الإشارة إليها تكون أيضا غير صالحة، ولا نريد حدوث ذلك.
لتصحيح الخطأ التصريفي في الشيفرة 3 نتّبع النصيحة المزودة لنا في رسالة الخطأ:
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword | 6 | let handle = thread::spawn(move || { | ++++
يمكننا من خلال من خلال إضافة الكلمة المفتاحية move
قبل المغلف أن نفرض عليه الحصول على ملكية القيم التي يستعملها بدلًا من السماح لرست بالتدخل والاستنتاج أن عليها استعارة القيم. التعديل على الشيفرة 3 موضّح في الشيفرة 5 وسيُصرَّف البرنامج ويُنفَّذ وفق المطلوب:
اسم الملف: src/main.rs
use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(move || { println!("Here's a vector: {:?}", v); }); handle.join().unwrap(); }
الشيفرة 5: استعمال الكلمة المفتاحية move
لنفرض على المغلف أن يأخذ ملكية القيم التي يستعملها
قد نرغب في تجربة الأمر ذاته لتصحيح الشيفرة البرمجية في الشيفرة 4، إذ استدعى الخيط الرئيسي drop
باستعمال مغلف move
، ومع ذلك فإن هذا لن يعمل لأن ما تحاول الشيفرة 4 فعله غير مسموح به لسبب مختلف؛ وإذا أضفنا move
إلى المغلف فسننقل v
إلى بيئة المغلف ولن يعد بإمكاننا بعد ذلك استدعاء drop
عليه في الخيط الرئيسي، وسنحصل على الخطأ التصريفي التالي بدلًا من ذلك:
$ cargo run Compiling threads v0.1.0 (file:///projects/threads) error[E0382]: use of moved value: `v` --> src/main.rs:10:10 | 4 | let v = vec![1, 2, 3]; | - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait 5 | 6 | let handle = thread::spawn(move || { | ------- value moved into closure here 7 | println!("Here's a vector: {:?}", v); | - variable moved due to use in closure ... 10 | drop(v); // oh no! | ^ value used here after move For more information about this error, try `rustc --explain E0382`. error: could not compile `threads` due to previous error
أنقذتنا قواعد الملكية في رست مرةً أخرى، إذ حصلنا على خطأ في الشيفرة 3 لأن رست كانت صارمة باستعارة v
للخيط ذاته فقط مما يعني أن الخيط الرئيسي يمكنه نظريًا إبطال مرجع الخيط الناتج، ويمكننا بإخبار رست أن تنقل ملكية v
إلى الخيط الناتج أن نضمن لرست أن الخيط الرئيسي لن يستخدم v
بعد الآن. إذا عدّلنا الشيفرة 4 بالطريقة ذاتها فإننا بذلك ننتهك قواعد الملكية عندما نحاول استعمال v
في الخيط الرئيسي. تتجاوز الكلمة المفتاحية move
الوضع الافتراضي الصارم للاستعارة في رست، فلا يُسمح لنا بانتهاك قواعد الملكية.
بعد فهمنا أساسيات الخيوط وواجهتها البرمجية، لننظر عما يمكننا فعله باستخدام الخيوط.
ترجمة -وبتصرف- لقسم من الفصل Fearless Concurrency من كتاب The Rust Programming Language.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.