يُعدّ تمرير الرسائل طريقةً جيدةً للتعامل مع التزامن ولكنها ليست الطريقة الوحيدة، إذ أن هناك طريقةٌ أخرى لوصول خيوط threads متعددة إلى ذات بيانات المُشاركة. ضع بالحسبان هذا الجزء من الشعار من توثيق لغة جو Go "لا تتواصل بمشاركة الذاكرة".
كيف سيبدو التواصل من خلال مشاركة الذاكرة؟ وبالإضافة إلى ذلك، لماذا يحذر المدافعون عن تمرير الرسائل message-passing من استخدام مشاركة الذاكرة memory sharing؟
تشبه القنوات channels في أي لغة برمجة -بطريقة ما- الملكية الفردية لأنه بمجرد نقلك لقيمة ما إلى قناة يجب ألا تستخدم هذه القيمة بعدها. يشبه تزامن الذاكرة المشتركة الملكية المتعددة multiple ownership، إذ يمكن للخيوط المتعددة أن تصل إلى موقع الذاكرة ذاته في الوقت نفسه. كما رأينا سابقًا في مقال المؤشرات الذكية Smart Pointers في رست، فقد جعلت المؤشرات الذكية smart pointers الملكية المتعددة ممكنة، ويمكن للملكية المتعددة أن تعقد الأمر لأن الملّاك المختلفين بحاجة إلى إدارة. يساعد نظام رست وقواعد الملكية الخاصة به كثيرًا في جعل عملية الإدارة صحيحة. لنلقي على سبيل المثال نظرةً على كائنات المزامنة mutexes التي تعدّ واحدةً من أكثر بدائل التزامن شيوعًا للذاكرة المشتركة.
استعمال كائنات المزامنة للسماح بالوصول للبيانات عن طريق خيط واحد بالوقت ذاته
كائن المزامنة Mutex هو اختصارٌ للاستبعاد المتبادل mutual exclusion، أي يسمح كائن المزامنة لخيط واحد فقط أن يصل إلى بعض البيانات في أي وقت، وللوصول إلى البيانات في كائن المزامنة يجب أن يشير الخيط أولًا إلى أنه يريد الوصول عن طريق طلب الحصول على قفل كائن المزامنة mutex's lock؛ والقفل هو هيكل بيانات data structure يعد جزءًا من كائن المزامنة الذي يتتبع من لديه حاليًا وصولٌ حصري إلى البيانات، وبالتالي يُوصَف كائن المزامنة بأنه يحمي البيانات التي يحملها عبر نظام القفل.
لكائنات المزامنة سمعة بأنها صعبة الاستعمال لأنك يجب أن تتذكر قاعدتين:
- يجب أن تحاول الحصول على القفل قبل استعمال البيانات.
- يجب أن تلغي قفل البيانات عندما تنتهي من البيانات التي يحميها كائن المزامنة حتى يتسنّى للخيوط الأخرى الحصول على القفل.
لنأخذ تشبيهًا حقيقيًا لكائن المزامنة: تخيل حلقة نقاش في مؤتمر بميكروفون واحد فقط، إذ يجب على أحد أعضاء اللجنة أن يسأل أو يشير إلى أنه يريد استخدام الميكروفون عند رغبته بالتحدث وعندما يحصل على الميكروفون يمكنه التحدث بقدر ما يريد من الوقت ثم يسلّم الميكروفون إلى عضو اللجنة التالي الذي يطلب التحدث؛ وإذا نسي أحد أعضاء اللجنة تسليم الميكروفون عند الانتهاء منه فلن يتمكن أي شخص آخر من التحدث؛ إذا حدث خطأ في إدارة الميكروفون المشترك فلن تعمل حلقة النقاش على النحو المطلوب.
قد تكون إدارة كائنات المزامنة صعبة جدًا وهذا هو سبب تفضيل الكثير من الناس للقنوات، ومع ذلك لا يمكنك الحصول على قفل وفتحه بصورةٍ خاطئة في رست بفضل نظامها وقواعد ملكيتها الخاصة.
واجهة <Mutex<T البرمجية
لنبدأ باستعمال كائن مزامنة بسياق خيط وحيد single-threaded مثالًا عن كيفية استعمال كائن مزامنة كما هو موضح في الشيفرة 12:
اسم الملف: src/main.rs
use std::sync::Mutex; fn main() { let m = Mutex::new(5); { let mut num = m.lock().unwrap(); *num = 6; } println!("m = {:?}", m); }
الشيفرة 12: تجربة واجهة <Mutex<T
البرمجية بسياق خيط وحيد لبساطته
كما هو الحال مع العديد من الأنواع نُنشئ <Mutex<T
باستعمال الدالة المرتبطة new
، ونستخدم التابع lock
للوصول إلى البيانات داخل كائن المزامنة وذلك للحصول على القفل. سيحظر هذا الاستدعاء الخيط الحالي ولن تتمكن من فعل أي عمل حتى يحين دور الحصول على القفل.
يفشل استدعاء lock
إذا هلع خيط آخر يحمل القفل، وفي هذه الحالة لن يتمكن أي شخص إطلاقًا من الحصول على القفل لذلك اخترنا unwrap
بحيث يهلع الخيط هذا إذا حصلت هذه الحالة.
يمكننا أن نعالج القيمة التي حصلنا عليها التي تحمل الاسم num
بعد حصولنا على القفل وستكون في هذه الحالة بمثابة مرجع متغير mutable يُشير إلى البيانات الموجودة بالداخل. يضمن نظام النوع type system حصولنا على قفل قبل استخدام القيمة في m
، ونوع m
هو <Mutex<i32
وليس i32
لذلك يجب علينا استدعاء lock
حتى نتمكن من استخدام القيمة i32
، ودعنا لا ننسى أن نظام النوع لن يسمح لنا بالوصول إلى قيمة i32
الداخلية بخلاف ذلك.
كما تعتقد فإن <Mutex<T
هو مؤشر ذكي، وبدقة أكبر، يُعيد استدعاء lock
مؤشرًا ذكيًا يدعى MutexGuard
مُغلَّفًا في LockResult
الذي تعاملنا معه في استدعاء unwrap
، يُطبّق المؤشر الذكي MutexGuard
السمة Deref
ليشير إلى بياناتنا الداخلية، كما يحتوي المؤشر الذكي أيضًا على تطبيق للسمة Drop
يُحرّر القفل تلقائيًا عندما يخرج MutexGuard
عن النطاق وهو الأمر الذي يحدث بنهاية النطاق الداخلي. نتيجة لذلك لا نخاطر بنسيان تحرير القفل وحجب استخدام كائن المزامنة بواسطة الخيوط الأخرى لأن تحرير القفل يحدث تلقائيًا.
يمكننا طباعة قيمة كائن المزامنة بعد تحرير القفل وسنرى أننا تمكنا من تغيير القيمة ذات النوعi32
الداخلية إلى 6.
مشاركة <Mutex<T بين خيوط متعددة
دعنا نحاول الآن مشاركة قيمة بين خيوط متعددة باستخدام <Mutex<T
، سنمرّ على عشرة خيوط ونجعل كل خيط منها يزيد قيمة العداد بمقدار 1 بحيث ينتقل العداد من القيمة 0 إلى 10. يحتوي المثال التالي في الشيفرة 13 على خطأ تصريفي compiler error، وسنستفيد من هذا الخطأ حتى نتعلم المزيد حول استعمال <Mutex<T
وكيف سيساعدنا رست في استعماله بصورةٍ صحيحة.
اسم الملف: src/main.rs
use std::sync::Mutex; use std::thread; fn main() { let counter = Mutex::new(0); let mut handles = vec![]; for _ in 0..10 { let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Result: {}", *counter.lock().unwrap()); }
الشيفرة 13: عشرة خيوط يزيد كل واحد منها عداد محميّ باستخدام <Mutex <T
ننشئ متغيرًا ندعوه counter
ليحمل قيمة من النوع i32
داخل <Mutex<T
كما فعلنا في الشيفرة 12، بعد ذلك نُنشئ عشرة خيوط عبر المرور iterate على مجال من الأرقام، ونستخدم لتحقيق ذلك thread::spawn
ونمنح كل خيط المغلّف ذاته الذي ينقل العداد باتجاه الخيط ويحصل على قفل على <Mutex<T
عن طريق استدعاء التابع lock
ومن ثم يضيف القيمة 1 إلى القيمة الموجودة في كائن المزامنة. عندما ينتهي الخيط من تنفيذ مغلّفه يخرج num
عن النطاق ويحرر القفل بحيث يستطيع خيطٌ آخر الحصول عليه.
نجمع كل مقابض الانضمام join handles في الخيط الرئيسي، ونستدعي بعد ذلك -كما فعلنا في الشيفرة 2 سابقًا- join
على كل مقبض للتأكد من انتهاء جميع الخيوط، وعند هذه النقطة يحصل الخيط الرئيسي على القفل ويطبع نتيجة هذا البرنامج.
لمّحنا إلى أن هذا المثال لن يُصرَّف، لنتعرف على السبب الآن:
$ cargo run Compiling shared-state v0.1.0 (file:///projects/shared-state) error[E0382]: use of moved value: `counter` --> src/main.rs:9:36 | 5 | let counter = Mutex::new(0); | ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait ... 9 | let handle = thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loop 10 | let mut num = counter.lock().unwrap(); | ------- use occurs due to use in closure For more information about this error, try `rustc --explain E0382`. error: could not compile `shared-state` due to previous error
تشير رسالة الخطأ إلى أن قيمة counter
نُقِلت في التكرار السابق للحلقة، وتخبرنا رست أنه لا يمكننا نقل ملكية قفل counter
إلى خيوط متعددة. لنصحّح الخطأ التصريفي بطريقة الملكية المتعددة التي ناقشناها سابقًا.
ملكية متعددة مع خيوط متعددة
تكلمنا في مقال سابق عن المالكين المتعددين لقيمة باستخدام المؤشر الذكي <Rc<T
<Mutex<T
داخل <Rc<T
ضمن الشيفرة 14 ونستنسخ <Rc<T
قبل نقل الملكية إلى الخيط.
اسم الملف: src/main.rs
use std::rc::Rc; use std::sync::Mutex; use std::thread; fn main() { let counter = Rc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Rc::clone(&counter); let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Result: {}", *counter.lock().unwrap()); }
الشيفرة 14: محاولة استعمال <Rc<T
للسماح لخيوط متعددة بامتلاك <Mutex<T
نصرّف الشيفرة البرمجية مرةً أخرى، ونحصل هذه المرة على أخطاء مختلفة، ويعلّمنا المصرّف الكثير من الأشياء.
$ cargo run Compiling shared-state v0.1.0 (file:///projects/shared-state) error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely --> src/main.rs:11:36 | 11 | let handle = thread::spawn(move || { | ------------- ^------ | | | | ______________________|_____________within this `[closure@src/main.rs:11:36: 11:43]` | | | | | required by a bound introduced by this call 12 | | let mut num = counter.lock().unwrap(); 13 | | 14 | | *num += 1; 15 | | }); | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely | = help: within `[closure@src/main.rs:11:36: 11:43]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>` note: required because it's used within this closure --> src/main.rs:11:36 | 11 | let handle = thread::spawn(move || { | ^^^^^^^ note: required by a bound in `spawn` For more information about this error, try `rustc --explain E0277`. error: could not compile `shared-state` due to previous error
رسالة الخطأ هذه شديدة التعقيد، إليك الجزء المهم الذي يجب أن تركز عليه:
`Rc<Mutex<i32>>` cannot be sent between threads safely
يخبرنا المصرّف أيضًا عن السبب:
the trait `Send` is not implemented for `Rc<Mutex<i32>>`
سنتحدث عن Send
في القسم التالي، إذ أنها أحد السمات التي تضمن أن الأنواع التي نستعملها مع الخيوط مخصصة للاستخدام في الحالات المتزامنة.
لسوء الحظ فإن <Rc<T
ليس آمنًا للمشاركة عبر الخيوط، فعندما يُدير <Rc<T
عدد المراجع فإنه يضيف عدد كل استدعاء إلى clone
ويطرح من العدد عندما تُحرَّر كل نسخة clone، إلا أنه لا يستعمل أي أنواع تزامن أولية للتأكد من أن التغييرات التي حدثت على العدد لا يمكن مقاطعتها بواسطة خيط آخر. قد يؤدي هذا إلى عمليات عدّ خاطئة -أخطاء خفية يمكن أن تؤدي بدورها إلى تسريب الذاكرة memory leak أو تحرير قيمة ما قبل أن ننتهي منها- وما نحتاجه هنا هو نوع مثل <Rc<T
تمامًا ولكنه نوع يُجري تغييرات على عدد المراجع بطريقة آمنة للخيوط.
عد المراجع الذري باستخدام <Arc<T
لحسن الحظ، يعد <Arc<T
نوعًا مثل <Rc<T
وهو آمن للاستخدام في الحالات المتزامنة، إذ يرمز الحرف a إلى ذرّي atomic مما يعني أنه يُعد نوع عدّ مرجع ذري atomically reference counted type. تعدّ الأنواع الذرية Atomics نوعًا إضافيًا من أنواع التزامن الأولية concurrency primitive التي لن نغطيها بالتفصيل هنا. راجع توثيق المكتبة القياسية للوحدة std::sync::atomic
للمزيد من التفاصيل، من الكافي الآن معرفة أن الأنواع الذرية تعمل مثل الأنواع الأولية ولكن من الآمن مشاركتها عبر الخيوط.
قد تتساءل عن عدم كون جميع الأنواع الأولية ذرية ولماذا أنواع المكتبات القياسية غير مُطبّقة لاستخدام <Arc<T
افتراضيًا، والسبب هنا هو أن سلامة الخيوط تأتي مع ضريبة أداء تريد دفعها فقط عندما تحتاج إليها حقًا. إذا كنت تجري عمليات على القيم ضمن خيط واحد فيمكن لشيفرتك البرمجية أن تُنفَّذ بصورةٍ أسرع إذا لم تكن بحاجة لفرض الضمانات التي تقدمها الأنواع الذرية.
بالعودة إلى مثالنا: للنوعين <Arc<T
و <Rc<T
الواجهة البرمجية API ذاتها لذلك نصحّح برنامجنا عن طريق تغيير سطر use
واستدعاء new
وكذلك استدعاء clone
. نستطيع أخيرًا تصريف الشيفرة البرمجية في الشيفرة 15 وتنفيذها.
اسم الملف: src/main.rs
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Result: {}", *counter.lock().unwrap()); }
الشيفرة 15: استخدام <Arc<T
لتغليف <Mutex<T
بحيث نستطيع مشاركة الملكية عبر خيوط متعددة
يكون خرج الشيفرة البرمجية السابقة على النحو التالي:
Result: 10
وأخيرًا نجحنا، فقد عدَدنا من 0 إلى 10، وعلى الرغم من أن هذا قد لا يبدو رائعًا جدًا، إلا أنه علّمنا الكثير عن <Mutex<T
وأمان الخيط. يمكنك أيضًا استخدام هيكل هذا البرنامج لإجراء عمليات أكثر تعقيدًا من مجرد زيادة العداد، ويمكننا باستخدام هذه الإستراتيجية تقسيم عملية حسابية إلى أجزاء مستقلة وتقسيم تلك الأجزاء عبر خيوط ثم استخدام <Mutex<T
لجعل كل خيط يحدّث النتيجة النهائية بجزئه.
لاحظ أنه إذا كنت تنفّذ عمليات عددية بسيطة فهناك أنواع أبسط من أنواع <Mutex<T
التي توفرها وحدة المكتبة القياسية std::sync::atomic
، إذ توفر هذه الأنواع وصولًا ذريًا آمنًا ومتزامنًا للأنواع الأولية، وقد اخترنا استخدام <Mutex<T
مع نوع أولي لهذا المثال حتى نتمكن من التركيز على كيفية عمل <Mutex<T
.
التشابه بين <RefCell<T>/Rc<T
و <Mutex<T>/Arc<T
ربما لاحظت أن counter
ثابت immutable ولكن يمكننا الحصول على مرجع متغيّر للقيمة الموجودة داخله، هذا يعني أن <Mutex<T
يوفر قابلية تغيير داخلية كما تفعله عائلة Cell
. نستخدم <Mutex<T
بنفس الطريقة التي استخدمنا بها <RefCell<T
سابقًا في مقال المؤشر الذكي Refcell<Rc<T
وذلك لتغيير المحتويات داخل Arc<T>
.
هناك شيء آخر يجب ملاحظته ألا وهو أن رست لا يمكنها حمايتك من جميع أنواع الأخطاء المنطقية عند استخدام <Mutex<T
. تذكر سابقًا في مقال المؤشر Rc<Rc<T
يأتي مع خطر إنشاء دورات مرجعية reference cycle، إذ تشير قيمتان <Rc<T
إلى بعضهما بعضًا مما يتسبب في حدوث تسرب في الذاكرة. وبالمثل يأتي تطبيق <Mutex<T
مع خطر خلق حالات مستعصية deadlocks، إذ تحدث هذه الحالات عندما تحتاج عملية ما إلى الحصول على مرجعين وحصل كل منهما على أحد الأقفال مما يتسبب في انتظارهما لبعضهما بعضًا إلى الأبد.
إذا أثارت الحالات المستعصية اهتمامك وأردت رؤيتها عمليًا فحاول إنشاء برنامج رست به حالة مستعصية، ثم ابحث عن استراتيجيات التخفيف من الحالات المستعصية بالنسبة لكائنات المزامنة في أي لغة ونفذها في رست. يوفر توثيق واجهة المكتبة القياسية البرمجية لـ<Mutex<T
و MutexGuard
معلومات مفيدة.
سنكمل هذا المقال بالحديث عن السمتين Send
و Sync
وكيف يمكننا استخدامها مع الأنواع المخصصة.
التزامن الموسع مع السمة Sync والسمة Send
من المثير للاهتمام أن لغة رست تحتوي على عدد قليل جدًا من ميزات التزامن، إذ تعد كل ميزة تزامن تحدثنا عنها حتى الآن جزءًا من المكتبة القياسية standard library وليست اللغة. لا تقتصر خياراتك للتعامل مع التزامن على اللغة أو المكتبة القياسية فيمكنك كتابة ميزات التزامن الخاصة بك أو استخدام تلك المكتوبة من قبل الآخرين.
ومع ذلك، ضُمِّنَ مفهومان للتزامن في اللغة: سمتَا std::marker
وهما Sync
و Send
.
السماح بنقل الملكية بين الخيوط عن طريق Send
تشير الكلمة المفتاحية للسمة Send
إلى أنه يمكن نقل ملكية القيم من النوع الذي ينفّذ Send
بين الخيوط، ويطبّق كل نوع في رست تقريبًا السمة Send
، ولكن هناك بعض الاستثناءات بما في ذلك <Rc<T
إذ لا يمكن تطبيق السمة Send
عليه لأنه إذا استنسخت قيمة <Rc<T
وحاولت نقل ملكية الاستنساخ إلى خيط آخر فقد يحدّث كلا الخيطين عدد المراجع reference count في الوقت ذاته. لهذا السبب تُنفَّذ <Rc<T
للاستخدام في المواقف أحادية الخيط حيث لا تريد دفع غرامة الأداء الآمن.
لذلك يؤكد نظام نوع رست وحدود السمات trait bounds أنه لا يمكنك أبدًا إرسال قيمة <Rc<T
بطريق الخطأ عبر الخيوط بصورةٍ غير آمنة. عندما حاولنا فعل ذلك في الشيفرة 14 سابقًا حصلنا على الخطأ:
the trait Send is not implemented for Rc<Mutex<i32>>
وعندما استبدلنا النوع بالنوع <Arc<T
الذي يطبّق السمة Send
صُرِّفَت الشيفرة البرمجية بنجاح.
أي نوع مكون بالكامل من أنواع Send
يُميَّز تلقائيًا على أنه Send
أيضًا، وتطبّق جميع الأنواع الأولية primitive types تقريبًا السمة Send
بغض النظر عن المؤشرات الأولية primitive pointers التي سنناقشها لاحقًا في.
السماح بالوصول لخيوط متعددة باستخدام السمة Sync
تشير الكلمة المفتاحية للسمة Sync
إلى أنه من الآمن للنوع المطبّق للسمة Sync
أن يُشار إليه من خيوط متعددة، بمعنى آخر أي نوع T
يطبّق السمة Sync
إذا كان T&
(مرجع غير قابل للتغيير إلى T
) يطبّق Send
أيضًا، مما يعني أنه يمكن إرسال المرجع بأمان إلى خيط آخر، وبصورةٍ مشابهة للسمة Send
فإن الأنواع الأولية تطبّق السمة Sync
والأنواع المكونة كاملًا من أنواع تطبّق السمة Sync
هي أيضًا أنواع تطبّق Sync
.
لا يطبّق المؤشر الذكي <Rc<T
أيضًا السمة Sync
للأسباب ذاتها التي تجعل منه غير قابل لتطبيق السمة Send
، كما أن النمط <RefCell<T
(الذي تحدثنا عنه سابقًا وعائلة الأنواع المرتبطة بالنوع <Cell<T
لا تطبّق السمة Sync
. يعدّ تنفيذ فحص الاستعارة الذي تفعله <RefCell<T
في وقت التنفيذ غير آمن للخيط. يطبّق المؤشر الذكي <Mutex<T
السمة Sync
ويمكن استخدامه لمشاركة الوصول مع خيوط متعددة كما رأيت سابقًا في قسم "مشاركة <Mutex<T
بين خيوط متعددة".
تطبيق السمتين Send و Sync يدويا غير آمن
بما أن الأنواع المكونة من الأنواع التي تطبّق السمتين Send
وSync
هي أنواع تطبّق السمتين Send
و Sync
تلقائيًا، فلا يتوجب علينا تطبيق هاتين السمتين يدويًا؛ وبصفتهما سمتان علّامة marker traits، فهما سمتان لا تحتويان أيّ توابع تطبّقها، وهما مفيدتان فقط لتعزيز الثوابت المرتبطة بالمزامنة.
يشمل تنفيذ هاتان السمتان يدويًا تنفيذ شيفرة رست غير آمنة، وسنتحدث عن استعمال شيفرة رست غير آمنة لاحقًا، ومن الكافي الآن معرفتك أن بناء أنواع متزامنة جديدة غير مؤلفة من أجزاء Send
و Sync
يتطلّب تفكيرًا حذرًا للمحافظة على ضمانات الأمان في لغة رست. يحتوي الكتاب “The Rustonomicon” معلومات أكثر عن هذه الضمانات وكيف يمكن التمسك بها.
ترجمة -وبتصرف- لقسم من الفصل Fearless Concurrency من كتاب The Rust Programming Language.
اقرأ أيضًا:
- المقال التالي: البرمجة كائنية التوجه OOP في لغة رست Rust
- المقال السابق: استخدام ميزة تمرير الرسائل Message Passing لنقل البيانات بين الخيوط Threads في لغة رست
- الملكية Ownership في لغة رست
- أنواع البيانات Data Types في لغة رست Rust
- الأخطاء والتعامل معها في لغة رست Rust
- استخدام الهياكل structs لتنظيم البيانات في لغة رست Rust
- استخدام الخيوط Threads لتنفيذ شيفرات رست بصورة متزامنة آنيًا
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.