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

كيفية تنفيذ عدة دوال عبر ميزة التساير Concurrency في لغة جو


هدى جبور

واحدة من أهم الميزات التي تدعمها لغة جو هي القدرة على إجراء أكثر من عملية في وقت واحد، وهذا ما يُسمى "بالتساير 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

في نموذج البرمجة غير المتزامن، يكون التعامل مع المهام على أنها خطوة واحدة تُشغّل مهام متعددة، ولا يؤخذ بالحسبان كيفية ترتيب هذه المهام. يمكن تشغيلها في وقت واحد أو في بعض الحالات، ستكون هناك بعض المهام التي تتُنفّذ أولًا ثم تتوقف مؤقتًا وتأتي مهام أخرى لتحل محلها بالتناوب وهكذا. يُطلق على هذا السلوك اسم متساير.

لفهم الأمر أكثر تخيل أنّه طُلب منك تناول كعكة ضخمة وغناء أغنية، وستفوز إذا كنت أسرع من يغني الأغنية وينهي الكعكة. القاعدة هي أن تغني وتأكل في نفس الوقت، ويمكنك أن تأكل كامل الكعكة ثم تغني كامل الأغنية، أو أن تأكل نصف كعكة ثم تغني نصف أغنية ثم تفعل ذلك مرةً أخرى، إلخ. ينطبق الأمر نفسه على علوم الحاسب؛ فهناك مهمتان تُنفذان بصورةٍ متسايرة، ولكن تُنفذان على وحدة معالجة مركزية أحادية النواة، لذلك ستقرر وحدة المعالجة المركزية تشغيل مهمة أولًا ثم المهمة الأخرى، أو تشغيل نصف مهمة ونصف مهمة أخرى، إلخ. يجعلنا هذا التقسيم نشعر بأن جميع المهام تُُنفّذ بنفس الوقت.

حسنًا تبقى لنا معرفة التنفيذ المتوازي. بالعودة إلى مثال الكعكة والأغنية تخيل أن يُسمح لك بالاستعانة بصديق، وبالتالي يمكن أن يغني بينما أنت تأكل. هذا يقابل أن يكون لدينا وحدة معالجة مركزية بنواتين، إذ يمكن تنفيذ المهمة -التي يمكن تقسيمها لمهمتين فرعيتين- على نواتين مختلفتين، وهذا ما يسمى بالتوازي، وهو نوع معين من التساير، إذ تُنفّذ المهام فعلًا في وقت واحد. لا يمكن تحقيق التوازي إلا في بيئات متعددة النواة.

المتطلبات

لتتابع هذه المقالة، يجب أن تستوفي الشروط التالية:

تشغيل عدة دوال في وقت واحد باستخدام خيوط معالجة جو Goroutines

تُصمّم المعالجات الحديثة أو وحدة المعالجة المركزية CPU لأجهزة الحواسيب بحيث يمكنها تنفيذ أكبر عدد من المجاري التدفقية Streams من الشيفرة (أي كتل من التعليمات البرمجية) في نفس الوقت. لتحقيق هذه الإمكانية تتضمن المعالجات نواة أو عدة أنوية (يمكنك التفكير بكل نواة على أنها معالج أصغر) كل منها قادر على تنفيذ جزء من تعليمات البرنامج في نفس الوقت. بالتالي، يمكننا أن نستنتج أنّه يمكن الحصول على أداء أسرع بازدياد عدد الأنوية التي تُشغّل البرنامج. لكن مهلًا، لا يكفي وجود عدة أنوية لتحقيق الأمر؛ إذ يجب أن يدعم البرنامج إمكانية التنفيذ على نوى متعددة وإلا لن يُنفّذ البرنامج إلا على نواة واحدة في وقت واحد ولن نستفيد من خاصية النوى المتعددة والتنفيذ المتساير.

للاستفادة من وجود النوى المتعددة، يقع على عاتق المبرمج تصميم شيفرة البرنامج، بحيث يمكن تقسيم هذه الشيفرة إلى كتل تُنفّذ باستقلالية عن بعضها. ليس تقسيم شيفرة البرنامج إلى عدة أجزاء أو كتل برمجية أمرًا سهلًا، فهو يمثّل تحدٍ للمبرمج. لحسن الحظ أن لغة جو تجعل الأمر أسهل من خلال الأداة goroutine أو "تنظيم جو".

تنظيم جو هو نوع خاص من الدوال يمكنه العمل في نفس الوقت الذي تعمل به خيوط المعالجة الأخرى. نقول عن برنامج أنّه مُصمّم ليُنفّذ على التساير عندما يتضمن كتل من التعليمات البرمجية التي يمكن تنفيذها باستقلالية في وقت واحد. بالحالة العادية عندما تُستدعى دالة ما، ينتظر البرنامج انتهاء تنفيذ كامل الدالة قبل أن يكمل تنفيذ الشيفرة. يُعرف هذا النوع من التنفيذ باسم التشغيل في "المقدمة Foreground" لأنه يمنع البرنامج من تنفيذ أي شيء آخر قبل أن ينتهي. من خلال "تنظيم جو"، ستُستدعى الدالة وتنفّذ في "الخلفية Background" بينما يُكمل البرنامج تنفيذ باقي الشيفرة. نقول عن شيفرةً -أو جزء من شيفرة- أنها تُنفّذ في الخلفية عندما لا يمنع تنفيذها باقي أجزاء الشيفرة من التنفيذ، أي عندما لا تكون باقي أجزاء الشيفرة مُضطرة لانتظارها حتى تنتهي.

تأتي قوة خيوط معالجة جو -وهي عبارة عن خيط معالجة lightweight thread يديره مشغل جو الآني Go runtime)- من فكرة أن كل تنظيم يمكنه أن يشغل نواة معالج واحدة في نفس الوقت. إذا كان لديك معالج بأربع نوى ويتضمن برنامجك 4 خيوط معالجة، فإن كل تنظيم يمكنه أن يُنفّذ على نواة في نفس الوقت. عندما تُنفّذ عدة أجزاء من الشيفرة البرمجيّة في نفس الوقت على نوى مختلفة (كما في الحالة السابقة) -نقول عن عملية التنفيذ أنها تفرعيّة (أو متوازية). يمكنك الاطلاع على مقال تنفيذ المهام بالتوازي في dot NET على أكاديمية حسوب لمزيدٍ من المعلومات حول تنفيذ المهام على التوازي.

لتوضيح الفرق بين التساير concurrency والتوازي parallelism، ألقِ نظرةً على المخطط التالي. عندما يُنفّذ المعالج دالةً ما، فهو عادةً لا يُنفّذها من البداية للنهاية دفعةً واحدة، إذ يعمل نظام التشغيل أحيانًا على التبديل بين الدوال أو خيوط المعالجة أو البرامج الأخرى التي تُنفّذ على نواة المعالج عندما تنتظر الدالة حدوث شيءٍ ما (مثلًا قد يتطلب تنفيذ الدالة استقبال مُدخلات من المستخدم أو قراءة ملف). يعرض لنا المخطط كيف يمكن للبرنامج المصمّم للعمل على التساير التنفيّذ على نواة واحدة أو عدة نوى، كما يوضح أيضًا أنه من الملائم أكثر تنفيّذ خيوط معالجة جو على التوازي من التشغيل على نواة واحدة.

diagram1(1).png

يوّضح المخطط على اليسار والمُسمّى "تساير 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.

اقرأ أيضًا


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

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

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



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

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

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

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   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.


×
×
  • أضف...