اذهب إلى المحتوى

استخدام الخيوط Threads لتنفيذ شيفرات رست بصورة متزامنة آنيًا


Naser Dakhel

تُنفّذ شيفرة البرنامج في معظم أنظمة التشغيل الحالية ضمن عملية 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.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...