C++تعدد المهام
- مفهوم تعدد المهام
- مفهوم الثريد الرئيسي
- مبادئ إنشاء ثريد و تشغيله
- أمثلة شاملة حول التعامل مع الثريد
- المزامنة
مفهوم تعدد المهام
عندما تستخدم هاتفك أو حاسوبك ترى أنه يمكنك تشغيل عدة برامج مع بعض في وقت واحد, كل برنامج شغال في الذاكرة يعتبر Process فمثلاً إذا قمت بتشغيل خمسة برامج مع بعض فهذا يعني أن نظام التشغيل ينظم عمل خمسة Processes مع بعض. آلية تشغيل عدة برامج مع بعض تسمى Multiprocessing.
من جهة اخرى, في البرنامج الواحد يمكنك تنفيذ عدة أوامر مع بعض و جعل المستخدم يشعر كأنها تتنفذ في وقت واحد, فمثلاً في حال كنت تلعب لعبة مثل لعبة كرة القدم, تجد أنه هناك عدة أشياء تحدث في وقت واحد, فمثلاُ عند تشغيل اللعبة تسمع عدة أصوات ( مثل أغنية حماسية, صوت المعلق, صوت المشجعين, صوت صفارة الحكم في حال وقع خطأ إلخ.. ), بالإضافة إلى أنه يمكنك تحريك اللاعب و مشاهدة توقيت المبارة و الكثير من التفاصيل الأخرى التي تحدث كلها في نفس الوقت لتصنع لك لعبة رائعة. هذه الآلية تسمى تعدد المهام ( Multithreading ) لأن كل جزء شغال في البرنامج يكون عبارة عن مجموعة أوامر موضوعة بداخل ثريد ( Thread ) خاص.
إذاً نستخدم آلية تعدد المهام لجعل البرنامج قادر على تنفيذ عدة أوامر مع بعض و كأنها تتنفذ في وقت واحد, و هذا ما سنتعلمه في هذا الدرس.
أهمية تعدد المهام
- جعل المستخدم قادر على تنفيذ عدة عمليات مع بعض في نفس الوقت.
- جعل تصميم التطبيقات أجمل و إضافة مؤثرات فيه.
- كل ثريد تقوم بتشغيله, يعمل بشكل منعزل عن باقي الأوامر الموجودة في البرنامج, و بالتالي فإنه في حال وقوع أي خطأ في الثريد فإنه لن يؤثر على باقي الأوامر الموجود في البرنامج, كما أنه لا يؤثر على أي ثريد آخر شغال في البرنامج.
معلومة تقنية
في السابق كان لا بد لك من الإعتماد على مكتبة موجودة في لغة C إسمها POSI
حتى تتمكن من إنشاء ثريد و التعامل معه لأن لغة C++ وقتها لم تكن توفر لك ذلك.
إبتداءاً من الإصدار C++11
تم توفير كل ما يلزم لإنشاء ثريد و التحكم به و بالتالي ستتعلم في هذا الدرس كيف تستخدم الكلاسات و الدوال الموجودة في هذا الإصدار و التي تعمل على الإصدارات الجديدة أيضاً.
مفهوم الثريد الرئيسي
في C++ كل كود يتنفذ في البرنامج, فإنه حتماً يتنفذ بداخل ثريد واحد على الأقل.
أي حتى لو لم تقم بوضع الكود بداخل ثريد فإنه سيتم وضعه في واحد.
لتوضيح هذه الفكرة أكثر, عندما يتنفذ الكود الموضوع بداخل الدالة main()
يقوم المترجم بوضعه في ثريد خاص يقال له Main Thread.
إذا عندما يبدأ المترجم بتنفيذ الكود التالي فإنه سيضعه بداخل ثريد خاص.
المثال الأول
using namespace std; // سيقوم المترجم بوضعه بداخل ثريد خاص عندما يبدأ بتنفيذه main() كل الكود الموضوع بداخل الدالة int main() { for(int i=1; i<=5; i++) { cout << i << endl; } return 0; }
سنحصل على نتيجة تشبه النتيجة التالية عند التشغيل.
1 2 3 4 5
الآن حتى تتأكد أن الكود الموضوع في الدالة main()
يقوم المترجم بوضعه بداخل ثريد سنقوم في المثال التالي بالتعامل معه كثريد.
للدقة أكثر سنقوم بالتحكم بوقت تنفيذ الكود باستخدام الكلاس this_thread
المخصص للتعامل مع الثريد الذي يعمل في الوقت الحالي في البرنامج.
معلومة تقنية
الكلاس this_thread
يحتوي على دالة ثابتة إسمها sleep_for()
يمكن استخدامها لجعل الثريد الحالي ينتظر لمدة محددة قبل أن يتابع عمله, و لتحديد هذه المدة نستخدم إحدى الدوال الثابتة الموجودة في الكلاس chrono
المخصصة لذلك مثال الدالة minutes()
إذا أردنا تحديد المدة بالدقائق, أو الدالة seconds()
إذا أردنا تحديد المدة بالثواني, أو الدالة milliseconds()
إذا أردنا تحديد المدة بأجزاء من الثانية.
لاستخدام الكلاس this_thread
و الكلاس chrono
يجب تضمين الملفين <thread>
و <chrono>
.
في المثال التالي قمنا بإنشاء حلقة, في كل دورة تقوم بطباعة قيمة العداد i
و من ثم تتوقف مدة ثانية واحدة قبل أن تنتقل للدورة التالية.
ملاحظة: لجعل المترجم يتوقف استخدمنا الدالة sleep_for()
و لتحديد مدة التوقف استخدمنا الدالة seconds()
.
المثال الثاني
using namespace std; int main() { // ثم تتوقف ثانية واحدة قبل الإنتقال للدورة التالية i هنا قمنا بإنشاء حلقة تقوم في كل دورة بطباعة قيمة العداد for(int i=1; i<=5; i++) { cout << i << endl; this_thread::sleep_for(chrono::seconds(1)); } return 0; }
سنحصل على النتيجة التالية عند التشغيل مع الإشارة إلى أنه سيتم طباعة عدد واحد في كل ثانية.
1 2 3 4 5
كيف يستطيع المترجم التمييز بين ثريد و آخر؟
يقوم المترجم بإعطاء رقم تعرفة ( ID ) لكل ثريد يتم إنشاؤه حتى يستطيع التمييز فيما بينهم.
كما أنك تستطيع التعامل مع كل ثريد على حدا من خلال رقم التعرفة الخاص به.
مبادئ إنشاء ثريد و تشغيله
بشكل عام, لإنشاء ثريد و تشغيله يجب اتباع الخطوات التالية.
// thread أولاً يجب تضمين الكلاس import<thread> // callable بعدها يجب إنشاء كائن منه و تمرير الدالة التي نريده أن ينفذها مكان الباراميتر std::thread thread_object(callable); // في الأخير أو قبل نهاية البرنامج يجب إيقاف الكائن عن التنفيذ حتى لا يؤدي ذلك لحدوث مشكلة thread_object.join();
- يجب تضمين الملف
<thread>
. - يجب إنشاء كائن من الكلاس
thread
و تمرير دالة له مكان الباراميترcallable
تحتوي على الأوامر التي نريد تنفيذها بواسطة هذا الكائن. - مكان الباراميتر
callable
يمكنك تمرير إسم الدالة التي تريده أنه ينفذها, أو تعريف الدالة التي سينفذها بشكل مباشر بأسلوب Lambda أو تمرير إسم دالة معرّفة بأسلوب Function Object حتى ينفذها.
لا تقلق ستتعلم من الأمثلة كيف تنشئ ثريد بتفصيل ممل و لكن تذكر دائماً أن هذه الخطوات يجب تطبيقها عند التعامل مع ثريد.
معلومة تقنية
عند تشغيل أكثر من ثريد في وقت واحد, لا يمكنك ضمان أو تحديد أي ثريد سيتنفذ أو ينتهي قبل الآخر.
السبب في ذلك أن معالج الحاسوب ( CPU ) سيقوم بإرسال كل ثريد قمت بتشغيله إلى نواة ( Core ) حتى ينفذهم لك في وقت واحد.
و منطقياً, النواة التي عليها ضغط أقل ستنتهي من تنفيذ أوامر الثريد بشكل أسرع, و للعلم فإن النواة الواحدة قادرة على تنفيذ آلاف الأوامر في أقل من ثانية واحدة.
سنقوم باستخدام الدالة sleep_for()
في أغلب الأمثلة التي تمر معك في هذا الدرس لجعلك تلاحظ كيف أن المترجم سيقوم بتشغيل الثريدات ( Threads ) بشكل عشوائي بهدوء لأننا لو لم نفعل ذلك سيكون من الصعب عليك ملاحظة الفرق.
للإيضاح أكثر, بسبب سرعة الحاسوب الهائلة في تنفيذ الأوامر كنا سنضطر لجعل الحلقة تكرر تنفيذ أمر الطباعة الموضوع في كل ثريد آلاف المرات حتى تلاحظ كيف أن التنفيذ يحدث بشكل عشوائي لأنك سترى المترجم مرة مثلاً يطبع الكلمة "Thread-1"
700 مرة و مرة يطبع الكلمة "Thread-2"
300 مرة ثم يرجع و يطبع الكلمة "Thread-1"
200 مرة إلخ.. لذا كنت ستجد صعوبة كبيرة في ملاحظة ذلك.
نود الإشارة إلى أن الأرقام التي ذكرناها هي مجرد فرضيات و ليست حقيقية لأنه لا يمكن ضمان الترتيب الذي سيتم فيه تنفيذ أوامر الطباعة حيث أنها تتنفذ على حسب قدرة النواة التي يعمل عليها الثريد.
أمثلة شاملة حول التعامل مع الثريد
المثال الأول
في المثال التالي ستتعلم طريقة إنشاء ثريد و تمرير دالة له.
المثال الثاني
في المثال التالي ستتعلم طريقة تشغيل أكثر من ثريد في وقت واحد.
المثال الثالث
في المثال التالي ستتعلم طريقة تمرير قيم للدالة التي سينفذها الثريد.
المزامنة
في حال كنت تريد تشغيل أكثر من ثريد في نفس الوقت, يجب أن تنتبه جيداً إلى العمليات التي سيجريها كل ثريد تنوي تشغيله لأن هذا الأمر قد يسبب لك مشاكل منطقية أو يعطيك نتائج خاطئة كما سنوضح لك في السيناريوهات التالية.
السيناريو الأول
في حال قمت ببناء ثريد مهمته جلب علامات الطالب المخزنة في قاعدة بيانات و من ثم حساب معدله العام, و بعدما تم حساب المعدل و عرض المعدل للطالب, قام ثريد آخر بتعديل بعض العلامات في قاعدة البيانات لأنه وجد أن الطالب عنده غياب كثير.
إذاً, النتيجة التي أعطانا إياها الثريد الأول في هذه الحالة ليست صحيحة, حيث أنه كان يفترض حساب المعدل بعد أن تم إدخال أيام الغياب ضمن المعادلة التي تحسب له معدله النهائي و تعرض له إن كان ناجحاً بناءاً على معدله النهائي و عدد الأيام التي حضر فيها إلى الجامعة.
فعلى سبيل المثال, قد يكون من شروط الجامعة أنه في حال تغيّب الطالب مدة 30 يوم خلال الفصل الواحد, يعتبر راسباً في كل المواد.
إذاً, لحل المشكلة السابقة, كان يجب مزامنة عمل الثريد الأول و الثريد الثاني.
أي كان يجب تشغيل الثريد الذي يجلب أيام الغياب أولاً.
ثم بعد تخزين أيام الغياب و توقف الثريد عن العمل, يجب تشغيل الثريد الذي يجلب علامات الطالب و يعطيه النتيجة النهائية.
السيناريو الثاني
في حال كان يوجد ثريد يريد تعديل محتوى ملف, و كان يوجد ثريد آخر يقوم بقراءة محتوى نفس الملف.
في هذه الحالة, سيحدث أيضاً خطأ و هو أن الثريد الذي يقوم بالقراءة, سيقرأ محتوى الملف القديم, بدون معرفة أنه قد تم تحديث محتوى هذا الملف في الوقت الذي كان يقرأ منه و يجري عمليات ما بناءاً على المحتوى الذي قرأه وقتها.
لحل هذه المشكلة, كان يجب مزامنة عمل الثريد الذي يقرأ من الملف و الثريد الذي يعدل في الملف لضمان أن لا يتعاملا معه في وقت واحد.
السيناريو الثالث
في حال قمت بتشغيل إثنين ثريد, و في مرحلة ما أصبح الإثنين عالقين بسبب أن الثريد الأول بحاجة للوصول إلى شيء يستخدمه الثريد الثاني. و بنفس الوقت الثريد الثاني بحاجة للوصول إلى شيء يستخدمه الثريد الأول. هذه المعضلة تسمى Deadlock, و يمكنك تخليها كما في الصورة التالية.
لحل هذه المشكلة يمكنك تشغيل كل ثريد على حدا أو وضع قفل يقضي بجعل الثريد الذي يدخل أولاً يتم تنفيذه بالكامل و بعد أن ينتهي يبدأ الثريد الآخر بالتنفيذ.
لوضع قفل على الأوامر التي لا نريد لأكثر من ثريد أن ينفذوها في وقت واحد نستخدم دوال كلاس جاهز إسمه mutex
.
ملاحظة: لاستخدام الكلاس mutex
يجب تضمين الملف <mutex>
.
في المثال التالي قمنا بإنشاء إثنين ثريد يعملات بطريقة متزامنة, أي الواحد تلو الآخر و ليس مع بعض.
ما فعلناه ببساطة هو إنشاء كائن من الكلاس mutex
, و من ثم استدعاء الدالة lock()
منه قبل الكود الذي سينفذه الثريد و استدعاء الدالة unlock()
منه بعد الكود الذي سينفذه الثريد.
مثال
using namespace std; // لأننا سنستخدمه لجعل الثريدات التي ننشئها تعمل بشكل متزامن mutex هنا قمنا بإنشاء كائن من الكلاس mutex mtx; // txt هنا قمنا بتعريف الدالة التي سنمررها للثريد مع الإشارة إلى أنه يجب تمرير نص لها مكان الباراميتر void func(string txt) { // حتى يجعل أي ثريد آخر يريد تنفيذ الكود ينتظر mtx من الكائن lock() هنا قمنا باستدعاء الدالة mtx.lock(); cout << "Starting " << txt << "\n"; // هنا قمنا بإنشاء حلقة تفوم بطباعة إسم الثريد الذي يتم تنفيذه حالياً 3 مرات مع التوقف لمدة ثانية في كل مرة for(int i=0; i<5; i++) { cout << txt << "\n"; this_thread::sleep_for(chrono::seconds(1)); } cout << "Ending " << txt << "\n"; // حتى يجعل أي ثريد آخر موضوع في الإنتظار قادر على البدء بتنفيذ أوامر الدالة mtx من الكائن unlock() هنا قمنا باستدعاء الدالة mtx.unlock(); } int main() { // txt و كل واحد منهما يمرر لها نص مختلف مكان الباراميتر func() ينفذان الدالة thread هنا قمنا بإنشاء كائنين من الكلاس thread t1(func, "Thread-1"); thread t2(func, "Thread-2"); // المترجم ينتظر t2 و t1 من الكائنين join() هنا وضعنا قمنا باستدعاء الدالة // قبل أن يتابع تنفيذ باقي الأوامر الموجودة في الملف t1 و t1 أن يتوقف الكائنين t1.join(); t2.join(); cout << "All threads are end!"; return 0; }
سنحصل على النتيجة التالية عند التشغيل.
Thread-1
Thread-1
Thread-1
Ending Thread-1 <-- هنا إنتهى تنفيذ أوامر الثريد الأول
Starting Thread-2 <-- هنا تم البدء بتنفيذ الثريد الثاني
Thread-2
Thread-2
Thread-2
Ending Thread-2 <-- هنا إنتهى تنفيذ أوامر الثريد الثاني
Both threads are end!