Goroutine

الدرس 13: Goroutine

تخيل مطعمًا: إذا كان هناك نادل واحد فقط (خيط واحد)، فيجب عليه إنهاء تلقي الطلبات وتقديم الطعام وفوترة كل طاولة قبل الانتقال إلى التالية. توظيف العديد من الندلات (تعدد الخيوط) سيكون مكلفًا. حل Go هو توظيف مجموعة من الندلات الخفيفة بدوام جزئي (goroutines) الذين يتشاركون نفس نظام المطعم (المجدول) — أيّ منهم متاح يقدم الخدمة التالي، مما يتعامل مع عدد كبير من العملاء بأقل الموارد.


المفاهيم الأساسية


البنية الأساسية والاستخدام

إطلاق Goroutine

أضف كلمة المفتاح go قبل استدعاء الدالة لإطلاق goroutine جديد:

GO
go functionName(args)
// أو
go func() {
    // جسم الدالة المجهولة
}()

انتظار Goroutines باستخدام sync.WaitGroup

بما أن الـ goroutine الرئيسي لا ينتظر تلقائيًا الـ goroutines الفرعية، استخدم sync.WaitGroup للمزامنة:

GO
var wg sync.WaitGroup

wg.Add(1)        // زيادة العداد بـ 1
go func() {
    defer wg.Done() // تقليل العداد عند الانتهاء
    // تنفيذ المهمة
}()
wg.Wait()        // الحظر حتى يصل العداد إلى الصفر
💡 نصيحة: يجب استدعاء wg.Add() قبل جملة go، وإلا قد يحدث تنافس — قد يعود الـ goroutine الرئيسي من Wait قبل استدعاء Add.

💡 نصيحة: لا تضع wg.Add() داخل الـ goroutine، وإلا قد يتم تنفيذ wg.Wait() قبل Add.

💡 نصيحة: استخدم دائمًا defer wg.Done() لضمان تقليل العداد بشكل صحيح حتى لو حدث panic.


الأمثلة

مثال: إطلاق عدة Goroutines (الصعوبة ⭐)

GO
package main

import (
	"fmt"
	"sync"
)

func sayHello(id int, wg *sync.WaitGroup) {
	defer wg.Done() // إخطار WaitGroup عند الانتهاء
	fmt.Printf("مرحبًا، أنا goroutine #%d\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go sayHello(i, &wg)
	}

	wg.Wait() // انتظار جميع goroutines للانتهاء
	fmt.Println("جميع goroutines اكتملت")
}
▶ جرّب الكود

ترتيب الإخراج غير محدد (تنفيذ متزامن):

TEXT
مرحبًا، أنا goroutine #3
مرحبًا، أنا goroutine #1
مرحبًا، أنا goroutine #5
مرحبًا، أنا goroutine #2
مرحبًا، أنا goroutine #4
جميع goroutines اكتملت

مثال: الحساب المتزامن وتجميع النتائج (الصعوبة ⭐⭐)

GO
package main

import (
	"fmt"
	"sync"
)

// حساب المربعات بشكل متزامن وتخزين النتائج في slice مشترك
func main() {
	numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	results := make([]int, len(numbers))

	var wg sync.WaitGroup

	for i, num := range numbers {
		wg.Add(1)
		go func(index, val int) {
			defer wg.Done()
			results[index] = val * val // كل goroutine يكتب في فهرس مختلف، لا حاجة لقفل
		}(i, num) // تمرير متغيرات الحلقة كوسيطات لتجنب مشكلة الالتقاط بالإغلاق
	}

	wg.Wait()

	fmt.Println("البيانات الأصلية:", numbers)
	fmt.Println("نتائج المربعات:", results)
}
▶ جرّب الكود

الإخراج:

TEXT
البيانات الأصلية: [1 2 3 4 5 6 7 8 9 10]
نتائج المربعات: [1 4 9 16 25 36 49 64 81 100]
💡 نصيحة: بدءًا من Go 1.22، يُنشئ متغير حلقة for نسخة جديدة في كل تكرار، لذلك لم يعد التمرير الصريح للوسيطات مطلوبًا. ومع ذلك، للتوافق وسهولة القراءة، يُوصى بالتمرير الصريح.


مثال: تجمع المهام ومنع تسرب Goroutine (الصعوبة ⭐⭐⭐)

GO
package main

import (
	"context"
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// task تمثل مهمة تحتاج إلى معالجة
func task(ctx context.Context, id int) (string, error) {
	// محاكاة عملية تستغرق وقتًا
	duration := time.Duration(rand.Intn(500)) * time.Millisecond

	select {
	case <-time.After(duration):
		return fmt.Sprintf("المهمة #%d اكتملت (استغرقت %v)", id, duration), nil
	case <-ctx.Done():
		return "", ctx.Err() // الإرجاع فورًا عند إلغاء السياق، لمنع تسرب goroutine
	}
}

func main() {
	rand.Seed(time.Now().UnixNano())

	// تعيين مهلة إجمالية لمنع تسرب goroutines
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel() // ضمان تحرير الموارد

	taskCount := 20
	results := make(chan string, taskCount) // قناة مخزنة لجمع النتائج
	var wg sync.WaitGroup

	// بدء 5 goroutines عاملة كتجمع مهام
	workerCount := 5
	for w := 1; w <= workerCount; w++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			for i := workerID; i <= taskCount; i += workerCount {
				res, err := task(ctx, i)
				if err != nil {
					fmt.Printf("العامل %d: المهمة #%d ألغيت\n", workerID, i)
					return
				}
				results <- res
			}
		}(w)
	}

	// إغلاق القناة بعد انتهاء جميع العمال
	go func() {
		wg.Wait()
		close(results)
	}()

	// جمع النتائج
	for res := range results {
		fmt.Println(res)
	}

	fmt.Println("تمت معالجة جميع المهام")
}
▶ جرّب الكود

النقاط الأساسية:


حالات الاستخدام العملية

الحالة 1: طلبات HTTP متزامنة

GO
package main

import (
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

func fetchURL(url string, wg *sync.WaitGroup) {
	defer wg.Done()

	start := time.Now()
	resp, err := http.Get(url)
	if err != nil {
		fmt.Printf("[خطأ] %s: %v\n", url, err)
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	elapsed := time.Since(start)
	fmt.Printf("[تم] %s — الحالة: %d، الحجم: %d بايت، الوقت: %v\n",
		url, resp.StatusCode, len(body), elapsed)
}

func main() {
	urls := []string{
		"https://httpbin.org/get",
		"https://httpbin.org/delay/1",
		"https://httpbin.org/delay/2",
		"https://httpbin.org/status/200",
	}

	var wg sync.WaitGroup
	start := time.Now()

	for _, url := range urls {
		wg.Add(1)
		go fetchURL(url, &wg)
	}

	wg.Wait()
	fmt.Printf("\nانتهى الكل، الوقت الإجمالي: %v\n", time.Since(start))
	// الوقت الإجمالي يساوي تقريبًا أبطأ طلب، وليس مجموع جميع الطلبات
}

الحالة 2: كتابة السجلات بشكل غير متزامن

GO
package main

import (
	"fmt"
	"time"
)

type LogEntry struct {
	Level   string
	Message string
}

// logWriter تستهلك السجلات بشكل غير متزامن في goroutine خلفية
func logWriter(entries <-chan LogEntry, done chan<- struct{}) {
	for entry := range entries {
		// محاكاة الكتابة إلى ملف أو خدمة عن بُعد
		time.Sleep(50 * time.Millisecond)
		fmt.Printf("[%s] %s\n", entry.Level, entry.Message)
	}
	done <- struct{}{} // إخطار الـ goroutine الرئيسي بأن الكتابة اكتملت
}

func main() {
	logCh := make(chan LogEntry, 100) // قناة مخزنة كطابور سجلات
	done := make(chan struct{})

	// بدء goroutine كتابة السجلات في الخلفية
	go logWriter(logCh, done)

	// البرنامج الرئيسي يعمل بشكل طبيعي، مع تسجيل غير متزامن
	for i := 1; i <= 5; i++ {
		logCh <- LogEntry{
			Level:   "INFO",
			Message: fmt.Sprintf("معالجة الطلب #%d", i),
		}
		fmt.Printf("تم إرسال السجل #%d\n", i)
	}

	close(logCh) // إغلاق القناة للإشارة للكاتب بالخروج
	<-done       // انتظار الكاتب للانتهاء
	fmt.Println("انتهى البرنامج")
}

❓ أسئلة شائعة

1. لماذا لا يتم تنفيذ goroutine الخاص بي؟

عندما يخرج الـ goroutine الرئيسي، يُنهي جميع الـ goroutines الفرعية قسرًا. السبب الشائع:

GO
// ❌ خاطئ: الـ goroutine الرئيسي يخرج فورًا، الـ goroutine الفرعي لا يحصل على فرصة للتشغيل
func main() {
	go fmt.Println("مرحبًا")
}

// ✅ صحيح: استخدام WaitGroup للانتظار
func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("مرحبًا")
	}()
	wg.Wait()
}

2. متى يحدث تسرب goroutines؟

تسرب goroutine يعني أن الـ goroutine لا يمكنه الخروج أبدًا، ويحتل الذاكرة وموارد المجدول باستمرار. السبب الشائع:

GO
// ❌ تسرب: القناة لا يوجد مستقبِل
func leaky() <-chan int {
	ch := make(chan int)
	go func() {
		ch <- 42 // محظور بشكل دائم، goroutine لا يستطيع الخروج
	}()
	return ch
}

// ✅ آمن: استخدام قناة مخزنة أو إلغاء السياق
func safe() <-chan int {
	ch := make(chan int, 1) // تخزين بحجم 1، الإرسال لن يحظر
	go func() {
		ch <- 42
	}()
	return ch
}

3. كيف أتجنب فخ الالتقاط بالإغلاق الكلاسيكي مع متغيرات الحلقة؟

GO
// ❌ Go 1.21 وما قبلها: جميع goroutines قد تطبع نفس القيمة
for i := 0; i < 5; i++ {
	go func() {
		fmt.Println(i) // قد تطبع جميعًا 5
	}()
}

// ✅ موصى به: تمرير وسيطات صريح، متوافق مع جميع إصدارات Go
for i := 0; i < 5; i++ {
	go func(n int) {
		fmt.Println(n) // تطبع بشكل صحيح 0, 1, 2, 3, 4
	}(i)
}

4. هل يمكنني الحصول على عدد goroutines الجارية حاليًا؟

GO
import "runtime"

fmt.Println("عدد goroutines الحالي:", runtime.NumGoroutine())

هذا مفيد جدًا لتصحيح أخطاء تسرب goroutines. البرنامج الخامل الطبيعي عادةً يحتوي على 1-2 goroutines فقط؛ إذا استمر العدد في التزايد، فقد يكون هناك تسرب.


📖 ملخص

المفهوم الوصف
كلمة المفتاح go تطلق goroutine، البنية go func()
نموذج الجدولة نموذج G-M-P، وقت تشغيل Go يدير الجدولة، شفاف للمستخدم
خفيف الوزن المكدس الأولي حوالي 2KB، يمكن إنشاء مئات الآلاف
WaitGroup تُستخدم للانتظار حتى تكتمل مجموعة من goroutines
السياق (Context) يُستخدم لنشر إشارات الإلغاء والتحكم في المهلة
منع التسرب تأكد أن كل goroutine لديه مسار خروج

المبادئ الأساسية:

  1. تأكد دائمًا أن goroutines لديها شروط خروج واضحة.
  2. استخدم sync.WaitGroup أو القنوات لمزامنة الـ goroutine الرئيسي مع الـ goroutines الفرعية.
  3. استخدم context للتحكم في دورة حياة goroutine ومنع التسرب.
  4. انتبه للتفاعل بين الإغلاقات ومتغيرات الحلقة (يُوصى بالتمرير الصريح للوسيطات).

📝 تمارين

التمرين 1: فاحص الأعداد الأولية المتزامن

اكتب برنامجًا يستخدم goroutines للتحقق بشكل متزامن مما إذا كانت مجموعة من الأعداد أولية، ثم تجميع النتائج وطبعها.

GO
// تلميحات:
// - حدد دالة isPrime(n int) bool
// - أطلق goroutine لكل عدد
// - استخدم WaitGroup للانتظار حتى يكتمل الكل
// - استخدم قفلًا أو قناة لجمع النتائج

التمرين 2: محاكي التحميل المتزامن

محاكاة تحميل ملفات متزامن: ابدأ 3 goroutines عاملة تختار المهام من طابور المهام، كل تحميل يستغرق 100-1000ms عشوائيًا، وأبلغ عن الوقت الإجمالي وعدد المهام التي أكملها كل عامل.

GO
// تلميحات:
// - استخدم قناة كطابور مهام
// - كل عامل يقرأ المهام من القناة
// - استخدم context.WithTimeout للتحكم في المهلة الإجمالية
// - استخدم sync.Mutex أو قناة لعدّاد كل عامل

التمرين 3: كاشف تسرب Goroutine

اكتب دالة أداة تقبل دالة مهمة كوسيط، وتنفذها، وتراقب عدد goroutines. إذا كان عدد goroutines بعد اكتمال المهمة أعلى مما كان قبل التنفيذ، فهذا يشير إلى تسرب محتمل — اطبع تحذيرًا.

GO
// تلميحات:
// - استخدم runtime.NumGoroutine() للحصول على عدد goroutines
// - سجّل العدد قبل وبعد تنفيذ المهمة
// - انتظر قليلًا (مثل 100ms) بعد التنفيذ قبل الفحص
// - اطبع نتيجة الكشف

الدرس التالي: Channel →

Web-Tutorial.com

فريق Web-Tutorial التقني

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

100%