1 مقدمة حول Goroutines

1.1 المفاهيم الأساسية للتوازي والتوازي

التوازي والتوازي هما مفاهيم شائعة في برمجة الخيوط المتعددة. يتم استخدامهما لوصف الأحداث أو تنفيذ البرنامج التي قد تحدث في وقت واحد.

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

صمم لغة Go بالتوازي في الاعتبار كواحد من أهدافها الأساسية. تحقق نماذج البرمجة المتزامنة الفعالة من خلال Goroutines وChannels. يدير واجهة برمجة التطبيقات (runtime) في Go Goroutines، ويمكنها جدولة هذه الGoroutines على عدة خيوط نظام لتحقيق المعالجة المتوازية.

1.2 Goroutines في لغة Go

Goroutines هي المفهوم الأساسي لتحقيق البرمجة المتزامنة في لغة Go. إنها خيوط خفيفة يتم إدارتها بواسطة واجهة برمجة التطبيقات (runtime) في Go. من وجهة نظر المستخدم، فهي مشابهة للخيوط، ولكنها تستهلك موارد أقل وتبدأ بسرعة أكبر.

تشمل سمات Goroutines:

  • خفيفة الوزن: تحتل Goroutines أقل ذاكرة تراكمية مقارنة بالخيوط التقليدية، ويمكن أن يتوسع حجم الكرنك (الستاب) الخاص بها أو ينقص حسب الحاجة.
  • إشغال منخفض: إن التكاليف العامة لإنشاء وتدمير Goroutines أقل بكثير من تلك المتعلقة بالخيوط التقليدية.
  • آلية اتصال بسيطة: توفر القنوات آلية اتصال بسيطة وفعالة بين Goroutines.
  • تصميم غير مانع: Goroutines لا تحجب Goroutines أخرى عن التشغيل في بعض العمليات. على سبيل المثال، أثناء انتظار Goroutines واحدة عمليات الإدخال/الإخراج، يمكن للGoroutines الأخرى الاستمرار في التنفيذ.

2 إنشاء وإدارة Goroutines

2.1 كيفية إنشاء Goroutine

في لغة Go، يمكنك بسهولة إنشاء Goroutine باستخدام الكلمة الأساسية go. عندما تضيف بادئة الدالة بكلمة go، ستُنفَذ الدالة بشكل غير متزامن في Goroutine جديد.

دعونا نلقي نظرة على مثال بسيط:

package main

import (
	"fmt"
	"time"
)

// قم بتعريف دالة لطباعة "مرحبًا"
func sayHello() {
	fmt.Println("مرحبًا")
}

func main() {
	// بدء Goroutine جديدة باستخدام كلمة go
	go sayHello()

	// تنتظر الGoroutine الرئيسية لمدة معينة للسماح لـsayHello بالتنفيذ
	time.Sleep(1 * time.Second)
	fmt.Println("دالة رئيسية")
}

في الكود أعلاه، ستُنفَذ الدالة sayHello() بشكل غير متزامن في Goroutine جديدة. وهذا يعني أن الدالة main() لن تنتظر انتهاء sayHello() قبل المتابعة. لذلك، نستخدم time.Sleep لكي نتوقف الGoroutine الرئيسية، الأمر الذي سيسمح بتنفيذ بيان الطباعة في sayHello. هذا مجرد لأغراض العرض. في التطوير الفعلي، نستخدم قنوات أو طرق تزامن أخرى لتنسيق تنفيذ Goroutines مختلفة.

ملاحظة: في التطبيقات العملية، يجب تجنب استخدام time.Sleep() لانتظار انتهاء Goroutine، حيث إنه ليس آلية تزامن موثوقة.

2.2 آلية جدولة Goroutine

في Go، تتم إدارة جدولة الGoroutines من قبل جدولة w واجهة برمجة التطبيقات (runtime) في Go، والتي تتولى تخصيص الوقت التنفيذي على المعالجات المنطقية المتاحة. يستخدم جدولة Go تقنية جدولة M:N (عدد من Goroutines طبق على عدد من خيوط نظام التشغيل) لتحقيق أداء أفضل على المعالجات متعددة النوى.

GOMAXPROCS والمعالجات المنطقية

GOMAXPROCS هو متغير بيئي يحدد العدد الأقصى لوحدات المعالجة المركزية (CPUs) المتاحة لجدولة واجهة برمجة التطبيقات (runtime)، حيث يكون القيمة الافتراضية هي عدد نوى الجهاز. يخصص واجهة برمجة التطبيقات (runtime) في Go خيوط نظام واحدة لكل وحدة معالجة منطقية. من خلال ضبط GOMAXPROCS، يمكننا تقييد عدد النوى المستخدمة من قبل واجهة برمجة التطبيقات.

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

يضبط الكود أعلاه حدًا أقصى لما بينه من نواتين لجدولة Goroutines، حتى عند تشغيل البرنامج على جهاز يحتوي على المزيد من النوى.

عملية جدولة

يعمل جدول الجدولة باستخدام ثلاث كيانات هامة: (M) الآلة، (P) المعالج، و(G) الجوروتين. تمثل (M) الآلة أو الخيط، وتعتبر تجريدًا لخيوط نواة نظام التشغيل. تمثل (P) الموارد المطلوبة لتنفيذ جوروتين. لكل (P) طابور جوروتين محلي. تمثل (G) جوروتين معين، بما في ذلك مكدس التنفيذ الخاص به ومجموعة التعليمات والمعلومات الأخرى.

مبادئ عمل جدولة Go هي:

  • يجب أن يكون لدى (M) (P) لتنفيذ (G). إذا لم يكن هناك (P)، سيتم إعادة (M) إلى ذاكرة الخيوط.
  • عندما يكون (G) غير محظور بواسطة (G) أخرى (على سبيل المثال، في استدعاءات النظام)، يعمل على نفس (M) قدر الإمكان، مما يساعد في الحفاظ على بيانات (G) المحلية "ساخنة" لاستخدام الذاكرة المؤقتة لوحدة المعالجة المركزية بكفاءة أكبر.
  • عندما يتم حظر (G)، سيفصل (M) و (P)، وسيبحث (P) عن (M) جديد أو يوقظ (M) جديد لخدمة (G) الأخرى.
go func() {
    fmt.Println("مرحبًا من جوروتين")
}()

يوضح الكود أعلاه بدء جوروتين جديد، الأمر الذي سيحث جدول الجدولة على إضافة (G) الجديد هذا إلى الطابور للتنفيذ.

جدولة مسبقة لجوروتينات

في المراحل الأولية، كانت لغة Go تستخدم الجدولة التعاونية، مما يعني أن جوروتينات يمكن أن تؤدي إلى جوع جوروتينات أخرى إذا استمرت في التنفيذ لفترة طويلة دون التنازل عن السيطرة بشكل طوعي. الآن، تنفذ جدولة Go الجدولة الاستباقية، مما يسمح بإيقاف تنفيذ (G) طويلة الأمد لإعطاء فرصة لتنفيذ (G) أخرى.

إدارة دورة حياة الجوروتينات

لضمان قوة وأداء تطبيق Go الخاص بك، من الضروري فهم وإدارة دورة حياة الجوروتينات بشكل سليم. بدء جوروتينات بسيط، ولكن بدون إدارة صحيحة، قد تؤدي إلى مشاكل مثل تسريب الذاكرة وظروف السباق.

بدء جوروتينات بأمان

قبل بدء جوروتين، تأكد من فهم عبء عملها وخصائص الوقت التشغيلي. يجب أن تكون لدى جوروتين بداية واضحة ونهاية لتجنب خلق "جوروتينات يتيامى" بدون شروط إنهاء.

func worker(done chan bool) {
    fmt.Println("يعمل...")
    time.Sleep(time.Second) // محاكاة مهمة مكلفة
    fmt.Println("انتهى العمل.")
    done <- true
}

func main() {
    // هنا، يتم استخدام آلية القناة في Go. يمكنك ببساطة التفكير في القناة كطابور رسائل أساسي، واستخدام العامل "<-" لقراءة وكتابة بيانات الطابور.
    done := make(chan bool, 1)
    go worker(done)
    
    // انتظر حتى تنتهي جوروتين
    <-done
}

الكود أعلاه يُظهر طريقة للانتظار حتى تنتهي جوروتين باستخدام قناة "done".

ملاحظة: هذا المثال يستخدم آلية القناة في Go، والتي سيتم التفصيل في فصول لاحقة.

إيقاف جوروتينات

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

  1. استخدام القنوات لإرسال إشارات الإيقاف: يمكن لجوروتينات استطلاع القنوات للتحقق من إيقاف الإشارات.
stop := make(chan struct{})

go func() {
    for {
        select {
        case <-stop:
            fmt.Println("حصلنا على إشارة الإيقاف. يتم إيقاف التشغيل...")
            return
        default:
            // تنفيذ العملية العادية
        }
    }
}()

// إرسال إشارة الإيقاف
stop <- struct{}{}
  1. استخدام حزمة context لإدارة دورة الحياة:
ctx, cancel := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("حصلنا على إشارة الإيقاف. يتم إيقاف التشغيل...")
            return
        default:
            // تنفيذ العملية العادية
        }
    }
}(ctx)

// عندما ترغب في إيقاف جوروتين
cancel()

استخدام حزمة context يتيح التحكم المرن في دورة حياة الجوروتينات، ويوفر إمكانيات الإيقاف وانتهاء الوقت. في التطبيقات الكبيرة أو الخدمات المصغرة، يُفضل استخدام context للتحكم في دورة حياة الجوروتينات.