سنغطي في هذا المقال من سلسلة تعلم البرمجة ما يلي:
- آلية ووقت استخدام التزامن concurrency والخيوط threads.
- مثال على تقسيم الحمل بين العمليات.
- الوصول إلى البيانات المشتركة باستخدام الخيوط.
- بعض الاحتمالات والمحاذير الأخرى.
رأينا إضافة هذا المقال إلى شرحنا بعد تفكير طويل نظرًا لأهمية موضوعه، وسبب ترددنا في ذلك أن التزامن عمومًا -والخيوط على وجه الخصوص- موضوع يصعب فهمه، رغم مسارعة الكثيرين إليه ظنًا منهم أنه يحل مشاكلهم، لكنهم يزيدونها سوءًا لضعف فهمهم لمفاهيم التزامن والخيوط، واللتان هما أداتان قويتان عند استعمالهما في المواضع المناسبة عن فهم وإدراك، لكن نرى أن يكونا الملاذ الأخير، نلجأ إليهما بعد فشل جميع الحلول الأخرى، وقد عملنا -يقصد المؤلف نفسه- على عدة مشاريع تستخدم التزامن بصورة أو بأخرى في الأعوام الأربعين الماضية، لكننا لم نستخدمها في مشاريع شخصية من قبل، وربما يعطي هذا تصورًا عن توقيت استخدام هذه التقنية، ونوعية المشاريع التي تُستخدم فيها.
مفهوم البرمجة التزامنية وتوقيت استخدامها
تشير البرمجة التزامنية Concurrent programming ببساطة إلى برنامج يحوي عناصر تعمل على التوازي في نفس الوقت، وهو ما نطبقه على حواسيبنا كل يوم، لكننا نترك أمر إدارة تلك البرامج لنظام التشغيل، ليجدولها لتعمل بالترتيب، ويوزع حمل التشغيل على المعالجات والنوى kernels الموجودة فيها، فمفهوم تقسيم المهمة إلى عدة أجزاء والعمل على كل جزء على حدة ليس جديدًا، وهو يشبه إنشاء فرق مختلفة للتعامل مع مشكلة كبيرة تُقسم إلى عدة أجزاء، ليعمل كل فريق على جزء منها.
وقد اتبعنا تلك الأساليب في علم الحواسيب على مدار العقود الماضية، خاصةً في الخوادم، فمثلًا يستقبل خادم الويب طلبات ويعالجها، لكن إذا كان الموقع كبيرًا وطلباته كثيرة ومتلاحقة، وكان الخادم يُعالج كل طلب قبل بدء الطلب الذي يليه؛ فستطفح قائمة انتظار المدخلات للخادم، لذلك يقرأ الخادم الطلب من قائمة الانتظار، فإذا كان الطلب ملف html بسيطًا فيعيده، أما إن كان أعقد من هذا فيشتق الخادم عمليةً فرعيةً للتعامل معه، ويعود هو لقراءة الطلب التالي.
وقد رأينا من قبل كيفية إنشاء عمليات من شيفرة بايثون الخاصة بنا في مقال التواصل مع نظام التشغيل باستخدام وحدة subprocess
، ورأينا منظورًا مختلفًا في مقال التواصل بين العمليات، حيث استخدمنا fork
لإنشاء عملية فرعية تكون نسخةً من العملية الأصلية، وتعمل كلا التقنيتين بسلاسة إذا كنا نرغب في عمليات قليلة منمقالة تمامًا عن بعضها، لكنهما لا تناسبان الحالة التي نحتاج فيها إلى عمليات كثيرة، كما في حالة معالجة خادم الويب لآلاف الطلبات كل دقيقة، وإحدى المشاكل التي قد تحدث عند استخدامهما لحالة الطلبات الكثيرة هو ما ينتج عن بطء إنشاء العملية الجديدة -بمقياس سرعة الحواسيب-، كما تكلف كل عملية الكثير من الأحمال الزائدة من استهلاك الذاكرة وغيرها، وهنا يبرز دور الخيوط therads، وهي تشبه العمليات الدقيقة التي تعمل داخل العملية الأصل، فتتشارك جميعها نفس مساحة الذاكرة ونفس الشيفرة، وتأتي تسمية الخيوط من فكرة أن الشيفرة الخاصة بنا تعمل من الأعلى إلى الأسفل -مع قفزات وحلقات تكرارية في طريقها-، فهي مثل بكرة من القطن أمسكنا طرف خيطها وتركناها تسقط، فكل خيط جديد يشبَّه بكرة قطن جديدة نسقطها بالتوازي مع الخيط الأول، وكل خيط له مسار تنفيذ خاص به في نفس الشيفرة.
لقد أشرنا أن التزامنية تتعلق بشكل ما بالأداء، خاصةً في حالة الأحمال الكبيرة من البيانات، والحق أن هذا هو السبب الوحيد لاستخدامها، فإذا واجهنا مشكلة عند زيادة البيانات ونكون قد بذلنا كل ما نستطيع من تعديلات على الأداء الأساسي -لعملية تبادل أو وحدة بيانات واحدة-، فحينئذ قد نحتاج إلى إعادة التفكير في شأن تقسيم الأحمال.
اختيار أسلوب التزامن
بعد أن أثبتنا فائدة التزامن في تحسين الأداء في حالة الأحمال الكبير، كيف نقرر التقنية التي سنستخدمها لمعالجة مهمة ما؟ أيهما أفضل لمهمتنا العمليات المتعددة أم الخيوط المتعددة؟ والجواب أننا ننظر في عدة عوامل، حيث:
- نستخدم الخيوط إذا كان في المهمة انتظار لنشاط الإدخال والإخراج، مثل رسائل الشبكة أو نتائج استعلامات قواعد البيانات.
- نستخدم الخيوط إذا تطلبت المهمة مشاركة البيانات بين عدة "خيوط"، لكن يجب تذكر قفل lock البيانات المشارَكة أثناء التحديثات.
-
نستخدم العمليات الفرعية إذا كان في المهمة معالجة خالصة للبيانات data crunching، وقد صممت الوحدة
multiprocessing
في بايثون خصيصًا لهذه الحالة. - إذا كانت المهمة الفرعية تعمل لوقت طويل، أو لا تُستدعى إلا لوقت قصير، فننشئ عملية خادم طويلة التشغيل long running server process، كما فعلنا في مقال التواصل بين العمليات، لنرسل البيانات إليها عند الحاجة.
اقتباستواجه بايثون مشكلةً كبيرةً في منظورها للخيوط تنطوي على آليةً داخليةً معقدةً، تسمى قفل المفسر العام Global Interpreter Lock أو GIL اختصارًا، يتمثل تأثير هذا القفل في منع خيوط العمليات الحسابية من العمل بالكفاءة التي نريدها، إلى الحد الذي دفع البعض إلى القول بعدم استخدام الخيوط في بايثون بالكلية، غير أن في مبالغةً كبيرةً، إذ إن الخيوط طريقة ممتازة للتعامل مع كل ما يجب قفله لعمليات الإدخال والإخراج، وهذا يعني "كل شيء" تقريبًا، والمهام الحسابية الخالصة فقط هي التي تواجه مشاكل، وليست المشاكل فيها بذلك السوء كما سنرى في المثال أدناه، وسنستخدم وحدة
multiprocessing
عندما نواجه تلك المشاكل، وهي مماثلة للخيوط في استخدامها، وستنفذ نفس المهمة لكن مع استخدام العمليات بدلًا من الخيوط، وهو الخيار الذي يستهلك الموارد كما ذكرنا في بداية المقال.
البدء بالبرمجة المتزامنة
بما أننا قررنا استخدام التزامن فلننظر في الشيفرة المطلوبة لها، لدينا وحدتان في المكتبة القياسية تتعلقان بالخيوط، هما Thread
وthreading
، والوحدة الأخيرة هي وحدة عالية المستوى بُنيت فوق وحدة Thread
، ولا نحتاج إلا إلى النظر فيها، أما في حالة العمليات المتعددة فنستخدم وحدة multiprocessing
التي تعمل تقريبًا بنفس طريقة threading
.
تحديد المشكلة
لننظر في مشروع بسيط نريد فيه حساب مضروب أول N عدد من مجموعة ما من الأعداد، فنكتب دالةً لحساب المضروب ثم نضعها في حلقة تكرارية ونخزن النتائج، وستبدو كما يلي:
import time, sys factorials = [] def fact(n): if n < 2: return 1 result = 1.0 for x in range(2,n+1): result *= x return result def do_it(f,lo,hi): global factorials for n in range(lo,hi): factorials.append(f(n)) if __name__ == "__main__": hi = int(sys.argv[1]) + 1 start = time.time() do_it(fact, 1, hi) print('Time for %s: %s' % (hi, time.time() - start))
نلاحظ أننا أنشأنا دالة do_it
التي تغلف الحلقة التكرارية الخارجية واستدعاءات المضروب، وهي ليست ضروريةً لكننا سنرى نفعها لاحقًا عندما ننظر في التزامن، وقد جعلنا عدد مرات التكرار وسيطًا في سطر الأوامر، وأضفنا مؤقتًا لإظهار الزمن الذي تستغرقه.
احفظ ذلك في ملف باسم no_threads.py وشغله لأول 100 عدد كما يلي:
$ python3 no_threads.py 100
إذا شغلنا هذا الأمر فسنجد أن الوقت المستغرق أقل من ثانية، نظرًا لسرعة الحواسيب هذه الأيام، لكن جرب ذلك مع أول ألف عدد مثلًا وانظر الوقت المستغرق، وقد جربناها لكل من أول 100 عدد، وأول 1000، وأول 10000، وخرجنا بالنتائج التالية:
$ python3 no_threads.py 100 Time for 100: 0.0006973743438720703 $ python3 no_threads.py 1000 Time for 1000: 0.06539225578308105 $ python3 no_threads.py 10000 Time for 10000: 6.800917863845825
نلاحظ أن السرعة جيدة إلى أن نصل إلى الألف عدد، لكنها تسوء فجأةً مع زيادة العدد عن ذلك، ونريد تحسين هذا الأداء لتلك الأعداد الكبيرة، وهنا يأتي دور التزامن.
إضافة التزامن إلى العملية
باتباع الإرشادات التي شرحناها أعلاه، سنختار multiprocessing
بدلًا من threading
لهذه المهمة، ونريد أن نعرف مقدار العدد المُدخَل أولًا، فإذا كان أكثر من 1000، فسنقسم العملية إلى عدة فروع، وسيكون ذلك أبطأ في الأعداد الكبيرة، لذلك بدلًا من تقسيم البيانات بالتساوي بين العمليات سنقسمها عند 75% -عشوائيًا-.
ستكون الشيفرة كما يلي:
import time, sys import multiprocessing factorials = [] def fact(n): if n < 2: return 1 result = 1.0 for x in range(2,n+1): result *= x return result def do_it(f,lo,hi): global factorials for n in range(lo,hi): factorials.append(f(n)) if __name__ == "__main__": hi = int(sys.argv[1]) + 1 start = time.time() if hi > 1000: hi_1 = int(hi * 0.75) p1 = multiprocessing.Process(target=do_it, args=(fact,1,hi_1)) p2 = multiprocessing.Process(target=do_it, args=(fact,hi_1,hi+1)) p1.start() p2.start() p1.join() p2.join() else: do_it(fact, 1, hi) print('Time for %s: %s' % (hi, time.time() - start))
نلاحظ أننا أنشأنا كائنيْ Process
، ومررنا الدالة التي نريد تشغيلها -وهي do_it
- إضافةً إلى الوسائط اللازمة في صف tuple، ثم استدعينا start
لتشغيل العمليات، أخيرًا استخدمنا التابع join
لجعل البرنامج الرئيسي ينتظر انتهاء العمليات.
$ python3 processes.py 100 Time for 100: 0.0006771087646484375 $ python3 processes.py 1000 Time for 1000: 0.06577491760253906 $ python3 processes.py 10000 Time for 10000: 3.690302610397339
نلاحظ أن الوقت المستغرق للعملية الكبيرة قد انخفض إلى النصف تقريبًا، لكن ما نفذناه لم يكن بسيطًا أو مباشرًا، فلم نقسم البيانات بالتساوي، فإذا جربت تقسيمها عند 50% فستجد أن الوقت المستغرق زاد مرةً أخرى.
يبدو أن التزامن يعطينا تحسنًا كبيرًا في الأداء، لكن في الواقع لدينا خلل كبير في برنامجنا، فإذا طبعنا factorials
-وهي قائمة النتائج- فسنجدها فارغةً!
والسبب في هذا أن كل عملية فرعية هي نسخة من العملية الأصل، وبالتالي لها نسختها الخاصة من قائمة factorials
، والتي نفقد بياناتها عند انتهاء العملية، لذلك نحتاج إلى طريقة لتمرير البيانات إلى العملية الأصل، مما يعني الكتابة إلى ذاكرة مشتركة -انظر وحدة mmap
- أو إلى قاعدة بيانات أو إلى ملف.
نستخدم في تلك الحالات كلها عمليات الإدخال والإخراج، لذا فلم لا نستخدم الخيوط بما أنها تتشارك الذاكرة! فنكون قد أنشأنا نسختنا الخاصة من معضلة Catch-22)!
لكن لحسن الحظ نادرًا ما يكون لدينا عمليات نحتاج إلى تقسيمها بالتوازي دون أن يكون فيها عمليات إدخال وإخراج، لذا فإن الخيوط هنا حل يمكن تنفيذه، وسننظر في مثال "مفتعل" إلى حد ما لمجرد موازنة هيكل الشيفرة مع مثال تعدد العمليات السابق.
استخدام الوحدة threading
سننشئ في هذا المثال خيطًا مساعِدًا لتحديث المتغير theTime
في كل ثانية، وسيتكرر البرنامج الرئيسي بلا نهاية ليطبع المتغير في كل مرة يتغير فيها، ولضمان تشغيل الخيوط بسلاسة سنضيف تأخيرًا زمنيًا باستخدام time.sleep
، التي يعاملها المفسر على أنها عملية إدخال/إخراج.
import time, threading theTime = 0 def getTime(): global theTime while True: theTime = time.time() time.sleep(1) def main(): global theTime current = theTime thrd = threading.Thread(target=getTime) thrd.start() while True: if current != theTime: current = theTime print(time.asctime(time.localtime(current))) time.sleep(0.01) if __name == "__main__": main()
لقد أنشأنا في المثال أعلاه دالةً تغلف مهمة الخيوط، لضمان وجود عملية time.sleep
فيها -أو أي دالة I/O أخرى-، ثم أنشأنا خيطًا نسند فيه دالتنا على أنها الهدف، كما فعلنا في العمليات أعلاه، ونبدأ تشغيل الخيط في الخلفية ثم ننتقل إلى الحلقة الأساسية التي تتحقق من المتغير في كل جزء من مئة من الثانية.
عند الحاجة إلى إنهاء البرنامج نكتب Ctrl+C
أو نستخدم مدير المهام في نظام التشغيل لإنهاء هذه العملية.
إذا شغلنا الشيفرة السابقة فستكون النتيجة شبيهةً بما يلي:
$ python3 clock.py Sat Dec 30 11:30:33 2017 Sat Dec 30 11:30:34 2017 Sat Dec 30 11:30:35 2017 Sat Dec 30 11:30:36 2017 ...
كان من الممكن تحقيق ذلك بسهولة دون استخدام الخيوط، لكنها تعطينا فكرةً عما تبدو عليه أبسط شيفرة تحوي خيوطًا، فإذا وازنّاها مع شيفرة العمليات المتعددة فسنجد أنهما متطابقتان تقريبًا في بنيتيهما، والفرق بينهما أن شيفرة الخيوط تستطيع تحديث متغير getTime
العام الذي تستطيع الدالة الرئيسية الموجودة في العملية الأصل أن تراه، نستطيع الآن أن نعود ونستبدل العمليات بالخيوط في المثال السابق ونوازن بين النتائج، وسنجد أن نسخة الخيوط لم تقدم مزيةً زمنيةً على نسخة العمليات.
سنكتفي بهذا القدر من شرح التزامن لأنه موضوع معقد فيه الكثير من الفخاخ الدقيقة، وننصح بعدم استخدامه إلا عند الضرورة، وحتى في تلك الضرورة فالشرح الموجود هنا يمثل نقطة بداية فقط، يمكن الانتقال بعدها إلى شروح أكثر تفصيلًا وعمقًا.
للمزيد من المعلومات، ننصحك بالإطلاع على دورة تطوير التطبيقات باستخدام لغة Python التي تشرح الكثير من المفاهيم الأساسية لبناء التطبيقات في بايثون.
خاتمة
نأمل في نهاية هذا المقال أن تكون تعلمت ما يلي:
- يُستخدم التزامن لتحسين الأداء.
- يُنفَّذ التزامن بتشغيل عمليات أو خيوط إضافية.
- تعمل العمليات بشكل مستقل عن بعضها، وتتواصل مع العملية الأصل باستخدام الأنابيب pipes.
- تعمل الخيوط افتراضيًا داخل العملية الأصل، وتتشارك الموارد -مثل الذاكرة وغيرها- فيما بينها.
- يجب إقفال الموارد المشتركة قبل تغيير الحالة لتجنب حدوث تعارضات.
-
توجد طرق أخرى للتزامن، كما في وحدة
asyncio
في بايثون التي تستخدم منظورًا مختلفًا.
ترجمة -بتصرف- للمقال الثاني والثلاثين: Concurrent Programming من كتاب Learn To Program لصاحبه Alan Gauld.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.