واحدة من أهم الميزات التي تدعمها لغة جو هي القدرة على إجراء أكثر من عملية في وقت واحد، وهذا ما يُسمى "بالتساير Concurrency"، وقد أصبحت فكرة تشغيل التعليمات البرمجية بطريقة متسايرة جزءًا مهمًا في تطبيقات الحاسب نظرًا لما تتيحه من استثمار أكبر للموارد الحاسوبية المتاحة والسرعة في إنهاء تنفيذ البرامج، فبدلًا من تنفيذ كتلة واحدة من التعليمات البرمجية في وقت واحد، يمكن تنفيذ عدة كتل من التعليمات البرمجية.
مفهوم التساير هو فكرة يمكن دعم البرامج بها من خلال تصميم البرنامج بطريقة تسمح بتنفيذ أجزاء منه باستقلالية عن الأجزاء الأخرى.
تمتلك لغة جو ميزتين، هما: خيوط معالجة جو Goroutines والقنوات Channels تُسهّلان إجراء عمليات التساير؛ إذ تُسهّل الأولى عملية إعداد الشيفرة المتسايرة للبرنامج، وتجعل الثانية عملية التواصل بين أجزاء البرنامج المتساير آمنة.
سنتعرّف في هذا المقال على كل من خيوط معالجة جو والقنوات، إذ سننشئ برنامجًا يستخدم خيوط معالجة جو لتشغيل عدة دوال في وقت واحد، ثم سنضيف قنوات لتحقيق اتصال آمن بين أجزاء البرنامج المتساير. أخيرًا، سنضيف عدة خيوط معالجة (كل منها يُسمّى عامل Worker، إشارةً إلى أدائه مهمة ما)، لمحاكاة حالات أكثر تعقيدًا.
الفرق بين التزامن Synchronous وعدم التزامن Asynchronous والتساير Concurrency والتوازي Parallelism
التزامن وعدم التزامن هما نموذجان مختلفان للبرمجة، يشيران إلى أنماط البرمجة. تُكتب التعليمات البرمجية في النموذج الأول مثل خطوات، إذ تُنفّذ التعليمات البرمجية من الأعلى إلى الأسفل، خطوةً بخطوة، وتنتقل إلى الخطوة الثانية فقط عندما تنتهي من الخطوة الأولى، ويمكن هنا التنبؤ بالخرج:
func step1() { print("1") } func step2() { print("2") } func main() { step1() step2() } // result -> 12
تُكتب التعليمات البرمجية في النموذج الثاني مثل مهمات، ويجري تنفيذها بعد ذلك على التساير. التنفيذ المتساير يعني أنه من المحتمل أن تُنفّذ جميع المهام في نفس الوقت، وهنا الخرج لايمكن التنبؤ به:
func task1() { print("1") } func task2() { print("2") } func main() { task1() task2() } // result -> 12 or 21
في نموذج البرمجة غير المتزامن، يكون التعامل مع المهام على أنها خطوة واحدة تُشغّل مهام متعددة، ولا يؤخذ بالحسبان كيفية ترتيب هذه المهام. يمكن تشغيلها في وقت واحد أو في بعض الحالات، ستكون هناك بعض المهام التي تتُنفّذ أولًا ثم تتوقف مؤقتًا وتأتي مهام أخرى لتحل محلها بالتناوب وهكذا. يُطلق على هذا السلوك اسم متساير.
لفهم الأمر أكثر تخيل أنّه طُلب منك تناول كعكة ضخمة وغناء أغنية، وستفوز إذا كنت أسرع من يغني الأغنية وينهي الكعكة. القاعدة هي أن تغني وتأكل في نفس الوقت، ويمكنك أن تأكل كامل الكعكة ثم تغني كامل الأغنية، أو أن تأكل نصف كعكة ثم تغني نصف أغنية ثم تفعل ذلك مرةً أخرى، إلخ. ينطبق الأمر نفسه على علوم الحاسب؛ فهناك مهمتان تُنفذان بصورةٍ متسايرة، ولكن تُنفذان على وحدة معالجة مركزية أحادية النواة، لذلك ستقرر وحدة المعالجة المركزية تشغيل مهمة أولًا ثم المهمة الأخرى، أو تشغيل نصف مهمة ونصف مهمة أخرى، إلخ. يجعلنا هذا التقسيم نشعر بأن جميع المهام تُُنفّذ بنفس الوقت.
حسنًا تبقى لنا معرفة التنفيذ المتوازي. بالعودة إلى مثال الكعكة والأغنية تخيل أن يُسمح لك بالاستعانة بصديق، وبالتالي يمكن أن يغني بينما أنت تأكل. هذا يقابل أن يكون لدينا وحدة معالجة مركزية بنواتين، إذ يمكن تنفيذ المهمة -التي يمكن تقسيمها لمهمتين فرعيتين- على نواتين مختلفتين، وهذا ما يسمى بالتوازي، وهو نوع معين من التساير، إذ تُنفّذ المهام فعلًا في وقت واحد. لا يمكن تحقيق التوازي إلا في بيئات متعددة النواة.
المتطلبات
لتتابع هذه المقالة، يجب أن تستوفي الشروط التالية:
- إصدار مُثبّت من جو 1.13 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده.
- على درايةٍ بكيفية عمل واستخدام الدوال في لغة جو. يمكنك الاطلاع على مقالة كيفية تعريف واستدعاء الدوال في لغة جو Go.
تشغيل عدة دوال في وقت واحد باستخدام خيوط معالجة جو Goroutines
تُصمّم المعالجات الحديثة أو وحدة المعالجة المركزية CPU لأجهزة الحواسيب بحيث يمكنها تنفيذ أكبر عدد من المجاري التدفقية Streams من الشيفرة (أي كتل من التعليمات البرمجية) في نفس الوقت. لتحقيق هذه الإمكانية تتضمن المعالجات نواة أو عدة أنوية (يمكنك التفكير بكل نواة على أنها معالج أصغر) كل منها قادر على تنفيذ جزء من تعليمات البرنامج في نفس الوقت. بالتالي، يمكننا أن نستنتج أنّه يمكن الحصول على أداء أسرع بازدياد عدد الأنوية التي تُشغّل البرنامج. لكن مهلًا، لا يكفي وجود عدة أنوية لتحقيق الأمر؛ إذ يجب أن يدعم البرنامج إمكانية التنفيذ على نوى متعددة وإلا لن يُنفّذ البرنامج إلا على نواة واحدة في وقت واحد ولن نستفيد من خاصية النوى المتعددة والتنفيذ المتساير.
للاستفادة من وجود النوى المتعددة، يقع على عاتق المبرمج تصميم شيفرة البرنامج، بحيث يمكن تقسيم هذه الشيفرة إلى كتل تُنفّذ باستقلالية عن بعضها. ليس تقسيم شيفرة البرنامج إلى عدة أجزاء أو كتل برمجية أمرًا سهلًا، فهو يمثّل تحدٍ للمبرمج. لحسن الحظ أن لغة جو تجعل الأمر أسهل من خلال الأداة goroutine أو "تنظيم جو".
تنظيم جو هو نوع خاص من الدوال يمكنه العمل في نفس الوقت الذي تعمل به خيوط المعالجة الأخرى. نقول عن برنامج أنّه مُصمّم ليُنفّذ على التساير عندما يتضمن كتل من التعليمات البرمجية التي يمكن تنفيذها باستقلالية في وقت واحد. بالحالة العادية عندما تُستدعى دالة ما، ينتظر البرنامج انتهاء تنفيذ كامل الدالة قبل أن يكمل تنفيذ الشيفرة. يُعرف هذا النوع من التنفيذ باسم التشغيل في "المقدمة Foreground" لأنه يمنع البرنامج من تنفيذ أي شيء آخر قبل أن ينتهي. من خلال "تنظيم جو"، ستُستدعى الدالة وتنفّذ في "الخلفية Background" بينما يُكمل البرنامج تنفيذ باقي الشيفرة. نقول عن شيفرةً -أو جزء من شيفرة- أنها تُنفّذ في الخلفية عندما لا يمنع تنفيذها باقي أجزاء الشيفرة من التنفيذ، أي عندما لا تكون باقي أجزاء الشيفرة مُضطرة لانتظارها حتى تنتهي.
تأتي قوة خيوط معالجة جو -وهي عبارة عن خيط معالجة lightweight thread يديره مشغل جو الآني Go runtime)- من فكرة أن كل تنظيم يمكنه أن يشغل نواة معالج واحدة في نفس الوقت. إذا كان لديك معالج بأربع نوى ويتضمن برنامجك 4 خيوط معالجة، فإن كل تنظيم يمكنه أن يُنفّذ على نواة في نفس الوقت. عندما تُنفّذ عدة أجزاء من الشيفرة البرمجيّة في نفس الوقت على نوى مختلفة (كما في الحالة السابقة) -نقول عن عملية التنفيذ أنها تفرعيّة (أو متوازية). يمكنك الاطلاع على مقال تنفيذ المهام بالتوازي في dot NET على أكاديمية حسوب لمزيدٍ من المعلومات حول تنفيذ المهام على التوازي.
لتوضيح الفرق بين التساير concurrency والتوازي parallelism، ألقِ نظرةً على المخطط التالي. عندما يُنفّذ المعالج دالةً ما، فهو عادةً لا يُنفّذها من البداية للنهاية دفعةً واحدة، إذ يعمل نظام التشغيل أحيانًا على التبديل بين الدوال أو خيوط المعالجة أو البرامج الأخرى التي تُنفّذ على نواة المعالج عندما تنتظر الدالة حدوث شيءٍ ما (مثلًا قد يتطلب تنفيذ الدالة استقبال مُدخلات من المستخدم أو قراءة ملف). يعرض لنا المخطط كيف يمكن للبرنامج المصمّم للعمل على التساير التنفيّذ على نواة واحدة أو عدة نوى، كما يوضح أيضًا أنه من الملائم أكثر تنفيّذ خيوط معالجة جو على التوازي من التشغيل على نواة واحدة.
يوّضح المخطط على اليسار والمُسمّى "تساير Concurrency" كيف يمكن تنفيذ برنامج متساير التصميم على نواة معالج وحيدة من خلال تشغيل أجزاء من goroutine1
ثم دالة أو تنظيم أو برنامج ثم goroutine2
ثم goroutine1
مرةً أخرى وهكذا. هنا يشعر المستخدم بأن البرنامج ينفِّذ كل الدوال أو خيوط المعالجة في نفس الوقت، على الرغم من أنها تُنفّذ على التسلسل.
يوضّح العمود الأيمن من المخطط والمسمّى "توازي Parallelism" كيف أن نفس البرنامج يمكن أن يُنفّذ على التوازي على معالج يملك نواتين، إذ يُظهر المخطط أن النواة الأولى تنفِّذ goroutine1
وهناك دوال أو خيوط معالجة أو برامج أخرى تُنفّذ معها أيضًا، نفس الأمر بالنسبة للنواة الثانية التي تنفِّذ goroutine1
. نلاحظ أحيانًا أن goroutine1
و goroutine2
تُنفّذان في نفس الوقت لكن على نوى مختلفة.
يظهر هذا المخطط أيضًا سمات أخرى من سمات جو القوية، وهي قابلية التوسع Scalability، إذ يكون البرنامج قابلًا للتوسع عندما يمكن تشغيله على أي شيء بدءًا من جهاز حاسوب صغير به عدد قليل من الأنوية وحتى خادم كبير به عشرات الأنوية والاستفادة من هذه الموارد الإضافية، أي كلما أعطيته موارد أكبر، يمكنه الاستفادة منها. يوضّح المخطط أنه من خلال استخدام خيوط معالجة جو، يمكن لبرنامج متساير أن يُنفّذ على نواة وحيدة، لكن مع زيادة عدد الأنوية سيكون البرنامج قابلًا للتنفيذ المتوازي، وبالتالي يمكن تنفيّذ أكثر من تنظيم في نفس الوقت وتسريع الأداء.
للبدء بإنشاء برنامج متساير، علينا أولًا إنشاء مجلد "multifunc" في المكان الذي تختاره. قد يكون لديك مجلد مشاريع خاص بك، لكن هنا سنعتمد مجلدًا اسمه "projects". يمكنك إنشاء المجلد إما من خلال بيئة تطوير متكاملة IDE أو سطر الأوامر. إذا كنت تستخدم سطر الأوامر، فابدأ بإنشاء مجلد "projects" وانتقل إليه:
$ mkdir projects $ cd projects
من المجلد "projects" استخدم الأمر mkdir
لإنشاء مجلد المشروع "multifunc" وانتقل إليه:
$ mkdir multifunc $ cd multifunc
افتح الآن ملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده:
$ nano main.go
ضع بداخله الشيفرة التالية:
package main import ( "fmt" ) func generateNumbers(total int) { for idx := 1; idx <= total; idx++ { fmt.Printf("Generating number %d\n", idx) } } func printNumbers() { for idx := 1; idx <= 3; idx++ { fmt.Printf("Printing number %d\n", idx) } } func main() { printNumbers() generateNumbers(3) }
يعرّف هذا البرنامج الأولي دالتين generateNumbers
و printNumbers
، إضافةً إلى الدالة الرئيسية main
التي تُستدعى هذه الدوال ضمنها. تأخذ الدالة الأولى معامًلا يُدعى total
يُمثّل عدد الأعداد المطلوب توليدها، وفي حالتنا مررنا القيمة 3 لهذه الدالة وبالتالي سنرى الأعداد من 1 إلى 3 على شاشة الخرج. لا تأخذ الدالة الثانية أيّة معاملات، فهي تطبع دومًا الأرقام من 0 إلى 3.
بعد حفظ ملف main.go، يمكننا تشغيله باستخدام الأمر التالي:
$ go run main.go
سيكون الخرج كما يلي:
Printing number 1 Printing number 2 Printing number 3 Generating number 1 Generating number 2 Generating number 3
نلاحظ أن الدالة printNumbers
نُفّذت أولًا، ثم تلتها الدالة generateNumbers
، وهذا منطقي لأنها استُدعيت أولًا. تخيل الآن أن كل من هاتين الدالتين تحتاج 3 ثوان حتى تُنفّذ. بالتالي عند تنفيذ البرنامج السابق بطريقة متزامنة synchronously (خطوةً خطوة)، سيتطلب الأمر 6 ثوانٍ للتنفيذ (3 ثوان لكل دالة). الآن، لو تأملنا قليلًا سنجد أن هاتين الدالتين مستقلتان عن بعضهما بعضًا، أي لا تعتمد أحدهما على نتيجة تنفيذ الأخرى، وبالتالي يمكننا الاستفادة من هذا الأمر والحصول على أداء أسرع للبرنامج من خلال تنفيذ الدوال بطريقة متسايرة باستخدام خيوط معالجة جو. نظريًّا: سنتمكن من تنفيذ البرنامج في نصف المدة (3 ثوان)، وذلك لأن كل من الدالتين تحتاج 3 ثوان لتُنفّذ، وبما أنهما سيُنفذان في نفس الوقت، بالتالي يُفترض أن ينتهي تنفيذ البرنامج خلال 3 ثوان.
يختلف حقيقةً الجانب النظري هنا عن التجربة، فليس ضروريًا أن يكتمل التنفيذ خلال 3 ثوان، لأن هناك العديد من العوامل الأخرى الخارجية، مثل البرامج الأخرى التي تؤثر على زمن التنفيذ.
إن تنفيذ دالة على التساير من خلال تنظيم جو مُشابه لتنفيّذ دالة بطريقة متزامنة. لتنفيذ دالة من خلال تنظيم جو (أي بطريقة متسايرة) يجب وضع الكلمة المفتاحية go
قبل استدعاء الدالة.
لجعل البرنامج يُنفّذ جميع خيوط المعالجة على التساير، يتعين علينا إضافة تعديل بسيط للبرنامج، بحيث نجعله ينتظر انتهاء تنفيذ كل خيوط معالجة جو. هذا ضروري لأنه إذا انتهى تنفيذ دالة main
ولم تنتظر انتهاء خيوط معالجة جو، فقد لا يكتمل تنفيذ خيوط معالجة جو (تحدث مقاطعة لتنفيذها إن لم تكن قد انتهت).
لتحقيق عملية الانتظار هذه نستخدم WaitGroup
من حزمة sync
التابعة لجو، والتي تتضمّن الأدوات الأولية للتزامن synchronization primitives، مثل WaitGroup
المُصممة لتحقيق التزامن بين عدة أجزاء من البرنامج. ستكون مهمة التزامن في مثالنا هي تعقب اكتمال تنفيذ الدوال السابقة وانتظارها حتى تنتهي لكي يُسمح بإنهاء البرنامج.
تعمل الأداة الأولية WaitGroup
عن طريق حساب عدد الأشياء التي تحتاج إلى انتظارها باستخدام دوال Add
و Done
و Wait
. تزيد الدالة Add
العداد من خلال الرقم المُمرر إلى الدالة، بينما تنقص الدالة Done
العداد بمقدار واحد. تنتظر الدالة Wait
حتى تصبح قيمة العداد 0، وهذا يعني أن الدالة Done
استُدعيت بما يكفي من المرات لتعويض استدعاءات Add
. بمجرد وصول العداد إلى الصفر، ستعود دالة الانتظار وسيستمر البرنامج في العمل.
لنعدّل ملف "main.go" لتنفيذ الدوال من خلال خيوط معالجة جو باستخدام الكلمة المفتاحية go
، ولنضِف sync.WaitGroup
إلى البرنامج:
package main import ( "fmt" "sync" ) func generateNumbers(total int, wg *sync.WaitGroup) { defer wg.Done() for idx := 1; idx <= total; idx++ { fmt.Printf("Generating number %d\n", idx) } } func printNumbers(wg *sync.WaitGroup) { defer wg.Done() for idx := 1; idx <= 3; idx++ { fmt.Printf("Printing number %d\n", idx) } } func main() { var wg sync.WaitGroup wg.Add(2) go printNumbers(&wg) go generateNumbers(3, &wg) fmt.Println("Waiting for goroutines to finish...") wg.Wait() fmt.Println("Done!") }
سنحتاج -بعد التصريح عن WaitGroup
- إلى معرفة عدد الأشياء التي يجب انتظارها. سيدرّك wg
عند وضع التعليمة (wg.Add(2
داخل الدالة main
قبل بدء تنفيذ خيوط معالجة جو، أن عليه انتظار استدعائين Done
حتى يُنهي عملية الانتظار. إذا لم نفعل ذلك قبل بدء تنفيذ خيوط معالجة جو، فمن المحتمل أن تحدث حالات تعطّل في البرنامج أو قد تحدث حالة هلع Panic في الشيفرة، لأن wg
لا يعرف أنه يجب أن ينتظر أية استدعاءات Done
.
ستستخدم كل دالة defer
بعد ذلك، لاستدعاء Done
بهدف تخفيض العداد بواحد بعد انتهاء تنفيذ الدالة. تُحدَّث الدالة main
أيضًا لتضمين استدعاء Wait
من WaitGroup
، لذا ستنتظر الدالة main
حتى تُستدعى الدالة Done
مرتين قبل إنهاء البرنامج.
بعد حفظ ملف main.go، يمكننا تشغيله باستخدام الأمر التالي:
$ go run main.go
ويكون الخرج على النحو التالي:
Printing number 1 Waiting for goroutines to finish... Generating number 1 Generating number 2 Generating number 3 Printing number 2 Printing number 3 Done!
قد يختلف الخرج عما تراه أعلاه (تذكر أن التنفيذ المتساير لا يمكن التنبؤ بسلوكه)، وقد يختلف في كل مرة تُنفّذ فيها الشيفرة. سيعتمد الخرج الناتج عن تنفيذ الدالتين السابقتين على مقدار الوقت الذي يمنحه نظام التشغيل ومُصرّف لغة جو لكل دالة وهذا أمر يصعب معرفته؛ فمثلًا قد تُمنح كل دالة وقتًا كافيًا لتُنفّذ كل تعليماتها دفعةً واحدة قبل أن يقاطع نظام التشغيل تنفيذها، وبالتالي سيكون الخرج كما لو أن التنفيذ كان تسلسيًا، وفي بعض الأحيان لن تحصل الدالة على ما يكفي من الوقت دفعةً واحدة، أي تطبع سطر ثم يتوقف تنفيذها ثم تطبع الدالة الثانية سطر ثم يتوقف تنفيذها ثم نعود للدالة الأولى فتطبع باقي الأسطر وهكذا، وسنرى عندها خرجًا مشابهًا للخرج أعلاه.
على سبيل التجربة، يمكننا حذف تعليمة استدعاء ()wg.Wait
في الدالة الرئيسية main
ثم تنفيذ الشيفرة عدة مرات باستخدام go run
. اعتمادًا على جهاز الحاسب المُستخدم، قد ترى بعض النتائج من دالتي generateNumbers
و printNumbers
، ولكن من المحتمل أيضًا ألا ترى أي خرج منهما إطلاقًا، وذلك لأنه عند حذفنا لاستدعاء الدالة Wait
، لن ينتظر البرنامج اكتمال تنفيذ الدالتين حالما ينتهي من تنفيذ باقي تعليماته؛ أي بدلًا من استخدام مبدأ "ضعهم في الخلفية وأكمل عملك وانتظرهم"، سيعتمد مبدأ "ضعهم في الخلفية وأكمل عملك وانساهم"، وبما أن الدالة الرئيسية تنتهي بعد وقت قصير من دالة Wait
، فهناك فرصة جيدة لأن يصل برنامجك إلى نهاية الدالة main
ويخرج قبل انتهاء تنفيذ خيوط معالجة جو. عند حصول ذلك قد ترى بعضًا من الأرقام تُطبع من خلال الدالتين، لكن غالبًا لن ترى الخرج المتوقع كاملًا.
أنشأنا في هذا القسم برنامجًا يستخدم الكلمة المفتاحية go
لتنفيذ دالتين على التساير وفقًا لمبدأ خيوط معالجة جو وطباعة سلسلة من الأرقام. استخدمنا أيضًا sync.WaitGroup
لجعل البرنامج ينتظر هذه خيوط المعالجة حتى تنتهي قبل الخروج من البرنامج.
ربما نلاحظ أن الدالتين generateNumbers
و printNumbers
لا تعيدان أية قيم، إذ يتعذر على خيوط معالجة جو إعادة قيم مثل الدوال العادية، على الرغم من أنه بمقدورنا استخدام الكلمة المفتاحية go
مع الدوال التي تُعيد قيم، ولكن سيتجاهل المُصرّف هذه القيم ولن تتمكن من الوصول إليها. هذا يطرح تساؤلًا؛ ماذا نفعل إذا كنا بحاجة إلى تلك القيم المُعادة (مثلًا نريد نقل بيانات من تنظيم إلى تنظيم آخر)؟ يكمن الحل باستخدام قنوات جو التي أشرنا لها في بداية المقال، والتي تسمح لخيوط معالجة جو بالتواصل بطريقة آمنة.
التواصل بين خيوط معالجة جو بأمان من خلال القنوات
أحد أصعب أجزاء البرمجة المتسايرة هو الاتصال بأمان بين أجزاء البرنامج المختلفة التي تعمل في وقت واحد، فإذا لم تكن حذرًا قد تتعرض لمشاكل من نوع خاص أثناء التنفيذ. على سبيل المثال، يمكن أن يحدث ما يسمى سباق البيانات Data race عندما يجري تنفيذ جزأين من البرنامج على التساير، ويحاول أحدهما تحديث متغير بينما يحاول الجزء الآخر قراءته في نفس الوقت. عندما يحدث هذا، يمكن أن تحدث القراءة أو الكتابة بترتيب غير صحيح، مما يؤدي إلى استخدام أحد أجزاء البرنامج أو كليهما لقيمة خاطئة. مثلًا، كان من المفترض الكتابة ثم القراءة، لكن حدث العكس، وبالتالي نكون قد قرأنا قيمة خاطئة. يأتي اسم "سباق البيانات" من فكرة أن عاملين Worker يتسابقان للوصول إلى نفس المتغير أو المورد. على الرغم من أنه لا يزال من الممكن مواجهة مشكلات التساير مثل سباق البيانات في لغة جو، إلا أن تصميم اللغة يُسهّل تجنبها. إضافةً إلى خيوط معالجة جو، تُعد قنوات جو ميزةً أخرى تهدف إلى جعل التساير سهلًا وأكثر أمنًا للاستخدام. يمكن التفكير بالقناة على أنها أنبوب pipe بين تنظيمين أو أكثر، ويمكن للبيانات العبور خلالها. يضع أحد خيوط معالجة البيانات في أحد طرفي الأنبوب ليتلقاه تنظيم آخر في الطرف الآخر، وتجري معالجة الجزء الصعب المتمثل في التأكد من انتقال البيانات من واحد إلى آخر بأمان نيابةً عنّا. إنشاء قناة في جو مُشابه لإنشاء شريحة slice، باستخدام الدالة المُضمّنة ()make
. يكون التصريح من خلال استخدام الكلمة المفتاحية chan
متبوعةً بنوع البيانات التي تريد إرسالها عبر القناة؛ فمثلًا لإنشاء قناة تُرسل قيمًا من الأعداد الصحيحة، يجب أن تستخدم النوع chan int
؛ وإذا أردت قناة لإرسال قيم بايت byte[]
، سنكتب chan []byte
:
bytesChan := make(chan []byte)
يمكنك -بمجرد إنشاء القناة- إرسال أو استقبال البيانات عبر القناة باستخدام العامل ->
، إذ يحدد موضع العامل ->
بالنسبة إلى متغير القناة ما إذا كنت تقرأ من القناة أو تكتب إليها؛ فللكتابة إلى قناة نبدأ بمتغير القناة متبوعًا بالعامل ->
، ثم القيمة التي نريد كتابتها إلى القناة:
intChan := make(chan int) intChan <- 10
لقراءة قيمة من قناة: نبدأ بالمتغير الذي نريد وضع القيمة فيه، ثم نضع إما =
أو =:
لإسناد قيمة إلى المتغير متبوعًا بالعامل ->
، ثم القناة التي تريد القراءة منها:
intChan := make(chan int) intVar := <- intChan
للحفاظ على هاتين العمليتين في وضع سليم، تذكر أن السهم ->
يشير دائمًا إلى اليسار (عكس <-
)، ويشير السهم إلى حيث تتجه القيمة. في حالة الكتابة إلى قناة، يكون السهم منطلقًا من القيمة إلى القناة. أما عند القراءة من قناة، فيكون السهم من القناة إلى المتغير. على غرار الشريحة: يمكن قراءة القناة باستخدام الكلمة المفتاحية range
في حلقة for
. عند قراءة قناة باستخدام range
، ستُقرأ القيمة التالية من القناة في كل تكرار للحلقة وتُوضع في متغير الحلقة، وستستمر القراءة من القناة حتى إغلاق القناة، أو الخروج من حلقة for
بطريقة ما، مثل استخدام تعليمة break
:
intChan := make(chan int) for num := range intChan { // Use the value of num received from the channel if num < 1 { break } }
قد نرغب أحيانًا في السماح لدالة فقط بالقراءة أو الكتابة من القناة وليس كلاهما. لأجل ذلك يمكننا أن نضيف العامل ->
إلى تصريح القناة chan
. يمكننا استخدام المعامل ->
بطريقة مشابهة لعملية القراءة والكتابة من قناة للسماح فقط بالقراءة أو الكتابة أو القراءة والكتابة معًا. على سبيل المثال، لتعريف قناة للقراءة فقط لقيم int
، سيكون التصريح chan int->
:
func readChannel(ch <-chan int) { // ch is read-only }
لجعل القناة للكتابة فقط، سيكون التصريح chan<- int
:
func writeChannel(ch chan<- int) { // ch is write-only }
لاحظ أن السهم يُشير إلى خارج القناة للقراءة ويشير إلى القناة من أجل الكتابة. إذا لم يكن للتصريح سهم، كما في حالة chan int
، فهذا يعني أنه يمكن استخدام القناة للقراءة والكتابة.
أخيرًا، عند انتهاء الحاجة من استخدام القناة، يمكن إغلاقها باستخدام الدالة ()close
، وتًعد هذه الخطوة ضرورية، فقد يؤدي إنشاء القنوات وتركها دون استخدام عدة مرات في أحد البرامجما يُعرف باسم تسرب الذاكرة Memory leak، إذ يحدث تسرب للذاكرة عندما يُنشئ برنامج ما شيئًا ما يستخدم ذاكرة الحاسب، لكنه لا يحرّر تلك الذاكرة بعد الانتهاء من استخدامها، وهذا من شأنه أن يُبطئ تنفيذ البرنامج والحاسب عمومًا؛ فعند إنشاء قناة باستخدام ()make
، يخصص نظام التشغيل جزءًا من ذاكرة الحاسب للقناة، وتحرّر هذه الذاكرة عند استدعاء الدالة ()close
ليُتاح استخدامها من قبل شيء آخر.
لنحدّث الآن ملف "main.go" لاستخدام قناة chan int
للتواصل بين خيوط معالجة جو. ستنشئ الدالة generateNumbers
أرقامًا وتكتبها على القناة بينما ستقرأ الدالة printNumbers
هذه الأرقام من القناة وتطبعها على الشاشة. سننشئ في الدالة main
قناةً جديدة لتمريرها مثل معامل لكل دالة من الدوال الأخرى، ثم تستخدم ()close
على القناة لإغلاقها لعدم الحاجة لاستخدامها بعد ذلك. يجب ألا تكون دالة generateNumbers
تنظيم جو بعد ذلك، لأنه بمجرد الانتهاء من تنفيذ هذه الدالة، سينتهي البرنامج من إنشاء جميع الأرقام التي يحتاجها. تُستدعى دالة ()close
بهذه الطريقة على القناة فقط قبل انتهاء تشغيل كلتا الدالتين.
package main import ( "fmt" "sync" ) func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) { defer wg.Done() for idx := 1; idx <= total; idx++ { fmt.Printf("sending %d to channel\n", idx) ch <- idx } } func printNumbers(ch <-chan int, wg *sync.WaitGroup) { defer wg.Done() for num := range ch { fmt.Printf("read %d from channel\n", num) } } func main() { var wg sync.WaitGroup numberChan := make(chan int) wg.Add(2) go printNumbers(numberChan, &wg) generateNumbers(3, numberChan, &wg) close(numberChan) fmt.Println("Waiting for goroutines to finish...") wg.Wait() fmt.Println("Done!") }
تستخدم أنواع chan
في معاملات الدالتين generateNumbers
و printNumbers
أنواع القراءة فقط والكتابة فقط، إذ تحتاج الدالة generateNumbers
إلى القدرة على الكتابة في القناة وبالتالي نجعل chan
للكتابة فقط من خلال جعل السهم ->
يشير إلى القناة، بينما printNumbers
تحتاج للقراءة فقط من خلال جعل السهم ->
يشير إلى خارج القناة.
على الرغم من أنه كان بإمكاننا جعل الدوال تستطيع القراءة والكتابة وليس فقط واحدًا منهما من خلال استخدام chan int
، إلا أنه من الأفضل تقييدهم وفقًا لما تحتاجه كل دالة، وذلك لتجنب التسبب بطريق الخطأ في توقف برنامجك عن العمل والوقوع في حالة الجمود أو التوقف التام deadlock. تحدث حالة الجمود عندما ينتظر ينتظر جزء A من البرنامج جزءًا آخر B لفعل شيء ما، لكن الجزء B هذا ينتظر أيضًأ الجزء A لفعل شيء ما. هنا أصبح كل منهما ينتظر الآخر وبالتالي فإن كلاهما سيبقى منتظرًا ولن ينتهي من التنفيذ، ولن يكتمل تنفيذ البرنامج أيضًا.
قد يحدث الجمود بسبب الطريقة التي تعمل بها اتصالات القنوات في لغة جو، فعندما يكتب جزء من البرنامج إلى قناة، فإنه سينتظر حتى يقرأ جزءًا آخر من البرنامج من تلك القناة قبل المتابعة، وبالمثل، إذا كان البرنامج يقرأ من قناة، فإنه سينتظر حتى يكتب جزءًا آخر من البرنامج على تلك القناة قبل أن يستمر. عندما يكتب جزء A على القناة، سينتظر الجزء B أن يقرأ ما كتبه على القناة قبل أن يستمر في عمله. بطريقة مشابهة إذا كان الجزء A يقرأ من قناة، سينتظر حتى يكتب الجزء B قبل أن يستمر في عمله. يُقال أن جزءًا من البرنامج "محظور Blocking" عندما ينتظر حدوث شيء آخر، وذلك لأنه ممنوع من المتابعة حتى يحدث ذلك الشيء. تُحظر القنوات عند الكتابة إليها أو القراءة منها، لذلك إذا كانت لدينا دالة نتوقع منها أن تكتب إلى القناة ولكنها عن طريق الخطأ تقرأ من القناة، فقد يدخل البرنامج في حالة الجمود لأن القناة لن يُكتب فيها إطلاقًا. لضمان عدم حدوث ذلك نستخدم أسلوب القراءة فقط chan int->
أو الكتابة فقط chan<- int
بدلًًا من القراءة والكتابة معًا chan int
.
أحد الجوانب المهمة الأخرى للشيفرة المحدّثة هو استخدام ()close
لإغلاق القناة بمجرد الانتهاء من الكتابة عليها عن طريق generateNumbers
، إذ يؤدي استدعاء الدالة ()close
في البرنامج السابق إلى إنهاء حلقة for ... range
في دالة printNumbers
. نظرًا لأن استخدام range
للقراءة من القناة يستمر حتى تُغلق القناة التي يُقرأ منها، بالتالي إذا لم تُستدعى close
على numberChan
فلن تنتهي printNumbers
أبدًا، وفي هذه الحالة لن يُستدعى التابع Done
الخاص بـ WaitGroup
إطلاقًا من خلال defer
عند الخروج من printNumbers
، وإذا لم يُستدعى، لن ينتهي تنفيذ البرنامج، لأن التابع Wait
الخاص بـ WaitGroup
في الدالة main
لن يستمر. هذا مثال آخر على حالة الجمود، لأن الدالة main
تنتظر شيئًا لن يحدث أبدًا.
نفّذ الآن ملف "main.go" باستخدام الأمر go run
:
$ go run main.go
قد يختلف الخرج قليلًا عما هو معروض أدناه، ولكن يجب أن يكون متشابهًا:
sending 1 to channel sending 2 to channel read 1 from channel read 2 from channel sending 3 to channel Waiting for functions to finish... read 3 from channel Done!
يُظهر خرج البرنامج السابق أن الدالة generateNumbers
تولّد الأرقام من واحد إلى ثلاثة أثناء كتابتها على القناة المشتركة مع printNumbers
. حالما تستقبل printNumbers
الرقم تطبعه على شاشة الخرج، وبعد أن تولّد generateNumbers
الأرقام الثلاثة كلها، سيكون قد انتهى تنفيذها وستخرج، سامحةً بذلك للدالة main
بإغلاق القناة والانتظار ريثما تنتهيprintNumbers
. بمجرد أن تنتهي printNumbers
من طباعة الأرقام، تستدعي Done
الخاصة بـ WaitGroup
وينتهي تنفيذ البرنامج.
على غرار نتائج الخرج التي رأيناها سابقًا، سيعتمد الخرج الذي تراه على عوامل خارجية مختلفة، مثل عندما يختار نظام التشغيل أو مُصرّف لغة جو تشغيل تنظيم أو عامل معين قبل الآخر أو يبدّل بينهما، ولكن يجب أن يكون الخرج متشابهًا عمومًا.
تتمثل فائدة تصميم البرامج باستخدام خيوط معالجة جو والقنوات في أنه بمجرد تصميم البرامج بطريقة تقبل التقسيم، يمكنك توسيع نطاقه ليشمل المزيد من خيوط معالجة جو. بما أن generateNumbers
يكتب فقط على القناة، فلا يهم عدد الأشياء الأخرى التي تقرأ من تلك القناة، إذ سيرسل فقط أرقامًا إلى أي شيء يقرأ القناة. يمكنك الاستفادة من ذلك عن طريق تشغيل أكثر من دالة printNumbers
مثل تنظيم جو، بحيث يقرأ كل منها من نفس القناة ويتعامل مع البيانات في نفس الوقت.
الآن، بعد أن استخدم البرنامج قنوات للتواصل، نفتح ملف "main.go" مرةً أخرى لنُحدّث البرنامج بطريقة تمكننا من استخدام عدة دوال printNumbers
على أنها خيوط معالجة جو. سنحتاج إلى تعديل استدعاء wg.Add
، بحيث نضيف واحدًا لكل تنظيم نبدأه، ولا داعٍ لإضافة واحد إلىWaitGroup
من أجل استدعاء generateNumbers
بعد الآن، لأن البرنامج لن يستمر دون إنهاء تنفيذ كامل الدالة، على عكس ما كان يحدث عندما كنا ننفذه مثل تنظيم.
للتأكد من أن هذه الطريقة لا تقلل من عدد WaitGroup
عند انتهائها، يجب علينا إزالة سطر ()defer wg.Done
من الدالة. تسهّل إضافة رقم التنظيم إلى printNumbers
رؤية كيفية قراءة القناة بواسطة كل منهم. تُعد زيادة كمية الأرقام التي تُنشأ فكرةً جيدة أيضًا بحيث يسهل تتبعها:
func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) { for idx := 1; idx <= total; idx++ { fmt.Printf("sending %d to channel\n", idx) ch <- idx } } func printNumbers(idx int, ch <-chan int, wg *sync.WaitGroup) { defer wg.Done() for num := range ch { fmt.Printf("%d: read %d from channel\n", idx, num) } } func main() { var wg sync.WaitGroup numberChan := make(chan int) for idx := 1; idx <= 3; idx++ { wg.Add(1) go printNumbers(idx, numberChan, &wg) } generateNumbers(5, numberChan, &wg) close(numberChan) fmt.Println("Waiting for goroutines to finish...") wg.Wait() fmt.Println("Done!") }
بعد تحديث ملف "main.go"، يمكنك تشغيل البرنامج مرةً أخرى باستخدام go run
. ينبغي أن يبدأ البرنامج بإنشاء ثلاثة خيوط معالجة للدالة printNumbers
قبل المتابعة وتوليد الأرقام، كما ينبغي أن يُنشئ البرنامج أيضًا خمسة أرقام بدلًا من ثلاثة لتسهيل رؤية الأرقام موزعة بين كل من خيوط المعالجة الثلاثة للدالة printNumbers
:
$ go run main.go
قد يبدو الخرج مشابهًا لهذا (قد يختلف الخرج قليلًا):
sending 1 to channel sending 2 to channel sending 3 to channel 3: read 2 from channel 1: read 1 from channel sending 4 to channel sending 5 to channel 3: read 4 from channel 1: read 5 from channel Waiting for goroutines to finish... 2: read 3 from channel Done!
بالنظر إلى الخرج في هذه المرة، هناك احتمال كبير ليختلف الخرج عن الناتج الذي تراه أعلاه، لأنه هناك 3 خيوط معالجة من الدالة printNumbers
تُنفّذ، وأيٌّ منها قد يقرأ أحد الأرقام المولّدة، وبالتالي هناك احتمالات خرج عديدة. عندما يتلقى أحد خيوط معالجة الدالة printNumbers
رقمًا، فإنه يقضي وقتًا قصيرًا في طباعة هذا الرقم على الشاشة، وفي نفس الوقت يكون هناك تنظيم آخر يقرأ الرقم التالي من القناة ويفعل الشيء نفسه. عندما ينتهي تنظيم من قراءة الرقم الذي استقبله وطباعته على الشاشة، سيذهب للقناة مرةً أخرى ويحاول قراءة رقم آخر وطباعته، وإذا لم يجد رقمًا جديدًا، فسيبدأ الحظر حتى يمكن قراءة الرقم التالي. بمجرد أن تنتهي الدالة generateNumbers
من التنفيذ وتستدعى الدالة ()close
على القناة، ستغلِق كل خيوط معالجة الدالة printNumbers
حلقاتها وتخرج، وعندما تخرج جميع خيوط المعالجة الثلاثة وتستدعي Done
من WaitGroup
، يصل عدّاد WaitGroup
إلى الصفر وينتهي البرنامج. يمكنك أيضًا تجربة زيادة أو إنقاص عدد خيوط المعالجة أو الأرقام التي تُنشأ لمعرفة كيف يؤثر ذلك على الخرج.
عند استخدام خيوط معالجة جو، تجنب أن تُكثر منها؛ فمن الناحية النظرية يمكن أن يحتوي البرنامج على مئات أو حتى الآلاف من خيوط المعالجة، وهذا ما قد يكون له تأثير عكسي على الأداء، فقد يُبطئ برنامجك والحاسب والوقوع في حالة مجاعة الموارد Resource Starvation. في كل مرة تُنفّذ فيها لغة جو تنظيمًا، يتطلب ذلك وقتًا إضافيًا لبدء التنفيذ من جديد، إضافةً إلى الوقت اللازم لتشغيل الشيفرة في الدالة التالية، وبالتالي من الممكن أن يستغرق الحاسب وقتًا أطول في عملية التبديل بين خيوط المعالجة مقارنةً تشغيل التنظيم نفسه، وهذا ما نسميه مجاعة الموارد، لأن البرنامج وخيوط المعالجة لا تأخذ الموارد الكافية للتنفيذ أو ربما لا تحصل على أية موارد، وفي هذه الحالة يكون من الأفضل تخفيض عدد خيوط المعالجة (أجزاء الشيفرة التي تعمل بالتساير) التي ينفذها البرنامج، لتخفيض عبء الوقت الإضافي المُستغرق في التبديل بينها، ومنح المزيد من الوقت لتشغيل البرنامج نفسه. يُفضّل غالبًا أن يكون عدد خيوط المعالجة مساوٍ لعدد النوى الموجودة في المعالج أو ضعفها.
يتيح استخدام مزيج من خيوط معالجة جو والقنوات إمكانية إنشاء برامج قوية جدًا وقابلة للتوسع من أجل أجهزة حواسيب أكبر وأكبر. رأينا في هذا القسم أنه يمكن استخدام القنوات للتواصل بين عدد قليل من خيوط معالجة جو أو حتى آلاف دون الحاجة للكثير من التغييرات. إذا أخذنا هذا في الحسبان عند كتابة البرامج، سنتمكن من الاستفادة من التساير المتاح في لغة جو لتزويد المستخدمين بتجربة شاملة أفضل.
الخاتمة
أنشأنا في هذا المقال برنامجًا يطبع أرقامًا على الشاشة باستخدام الكلمة المفتاحية go
وخيوط معالجة جو التي تتيح لنا التنفيذ المتساير. بمجرد تشغيل البرنامج أنشأنا قنواتًا جديدةً تمرِّر قيمًا صحيحة int
عبرها باستخدام (make(chan int
، ثم استخدمنا القناة من خلال إرسال أرقام من تنظيم جو إلى تنظيم جو آخر عبرها، ليطبعها الأخير على الشاشة بدوره. أخيرًا وسّعنا البرنامج من خلال إنشاء عدة خيوط معالجة تؤدي نفس المهمة (تستقبل أرقام من القناة وتطبعها)، وكان مثالًا على كيفية استخدام القنوات وخيوط المعالجة لتسريع البرامج على أجهزة الحواسيب متعددة النوى.
ترجمة -وبتصرف- للمقال How To Run Multiple Functions Concurrently in Go لصاحبه Kristin Davidson.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.