المستوى: مبتدئ — هذا المقال يفترض إنك كتبت كود من قبل بأي لغة (JavaScript أو Python أو حتى C)، ولسه ما لمستش Go فعلياً. متوسط القراءة: 8 دقائق.
لو السكربت بتاعك بياخد 50 ثانية علشان يرسل 100 إيميل واحد ورا التاني، Goroutines في Go بتنزّل الزمن لـ 0.6 ثانية بـ 6 سطور كود فعلية، بدون threads ولا queue ولا أي تعقيد إضافي. الموضوع مش "Go أسرع من Python" — الموضوع إن Go صمم concurrency جواه من اليوم الأول، وده اللي بيخلّي السطر go funcName() يقوم بشغل أيام عند مهندسين تانيين بـ ThreadPoolExecutor و asyncio.
Goroutines: المهام المتزامنة بسطر واحد فعلاً
المشكلة باختصار
أي تطبيق حقيقي بيعمل I/O كتير — بيقرا من DB، بيبعت HTTP requests، بيكتب logs، بيرسل إيميلات. لو نفّذت العمليات دي تسلسلياً، السيرفر بيفضل واقف 90% من الوقت بيستنّى الشبكة. الحلول التقليدية (threads، processes، asyncio) بتشتغل، لكن كل واحدة بثمن: threads ثقيلة على الذاكرة، processes ثقيلة على الـ CPU، asyncio محتاج تفصّل كل سطر كود ليكون async/await. Goroutines بتديك نفس الفايدة بنصف الكلفة الذهنية.
ابدأ بمثال — صرّاف البنك
تخيل بنك فيه شبّاك واحد بس. 100 عميل واقفين في طابور، كل عميل عمليته بتاخد نص ثانية. آخر عميل في الطابور هيستنى 50 ثانية. ده الكود التسلسلي.
دلوقتي افتح 100 شبّاك في نفس الفرع، كل صرّاف بيخدم عميل واحد. آخر عميل بيخلّص في نص ثانية. ده اللي Goroutines بتعمله بالظبط، لكن الشبابيك مش بشر — هي مهام صغيرة جداً (2 كيلوبايت لكل واحدة في البداية) بتشتغل كلها على نفس الـ CPU بترتيب ذكي يديره runtime مدمج في اللغة.
الفرق المهم عن threads: لو فتحت 100 thread في Java، كل واحد بياكل 1 ميجا ذاكرة كـ stack افتراضي، يعني 100 ميجا قبل ما تكتب أي logic. 100 goroutine بتاكل 200 كيلوبايت إجمالي. الفرق 500 ضعف.
التعريف العلمي بدون مجاملة
Goroutine هي "lightweight thread" يديرها Go runtime مش الـ OS مباشرة. الفرق الجوهري إن thread في Linux بياخد افتراضياً 8 ميجا من الذاكرة (مع overcommit بيتحجز فعلياً أقل، لكن الـ kernel بيحسبها)، الـ Goroutine بتبتدي بـ 2 كيلوبايت وبتنمو لو محتاجة. ده معناه إن مليون goroutine ممكن تتشغّل على سيرفر بـ 4 جيجا RAM، بينما مليون OS thread هتموّت أي ماكينة.
الـ Go runtime بيستخدم نموذج اسمه M:N scheduler. M = goroutines (يمكن مليون)، N = OS threads (افتراضياً بعدد cores الـ CPU، عادي 4–16). الـ scheduler بيوزّع الـ M على الـ N بشكل cooperative، ولما goroutine تستنّى I/O (network، disk، channel)، الـ scheduler بيدّي الـ thread لـ goroutine تانية فوراً بدل ما الـ thread يفضل واقف. ده اسمه "work stealing scheduler" واتطوّر من ورقة Dmitry Vyukov في 2012.
الكود الفعلي — قبل وبعد
هنبص على نفس المهمة بالظبط: إرسال 100 إيشعار. النسخة الأولى تسلسلية، النسخة الثانية بـ Goroutines.
// قبل: تسلسلي، 50 ثانية لـ 100 إيميل
package main
import (
"fmt"
"time"
)
func sendEmail(to string) {
time.Sleep(500 * time.Millisecond) // محاكاة API call
fmt.Println("sent:", to)
}
func main() {
start := time.Now()
for i := 0; i < 100; i++ {
sendEmail(fmt.Sprintf("user%d@example.com", i))
}
fmt.Println("took:", time.Since(start))
}
// took: 50.0s
// بعد: متزامن بـ Goroutines + WaitGroup، 0.6 ثانية
package main
import (
"fmt"
"sync"
"time"
)
func sendEmail(to string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(500 * time.Millisecond)
fmt.Println("sent:", to)
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go sendEmail(fmt.Sprintf("user%d@example.com", i), &wg)
}
wg.Wait()
fmt.Println("took:", time.Since(start))
}
// took: 0.6s
الفرق الفعلي 6 سطور: استيراد sync، تعريف wg، استدعاء wg.Add(1) داخل اللوب، إضافة defer wg.Done() داخل الدالة، استبدال sendEmail(...) بـ go sendEmail(...)، وإضافة wg.Wait() في الآخر علشان الـ main ما تخلّصش قبل الـ goroutines.
أرقام مقاسة فعلياً من شغل إنتاج
على خدمة داخلية بترسل إشعارات Push يومياً عبر Firebase Cloud Messaging لـ 4,200 جهاز:
- قبل (Python + requests متسلسل): 38 دقيقة لكل دفعة، 1.8 طلب/ثانية فقط، استهلاك ذاكرة 480 ميجا.
- بعد (Go + 200 goroutine): 14 ثانية لكل دفعة، 300 طلب/ثانية، استهلاك ذاكرة 142 ميجا.
- التحسّن: 162 ضعف في السرعة، 70% توفير في الذاكرة، نفس عدد الأسطر تقريباً.
القياس على Hetzner CCX22 (4 vCPU، 16 جيجا RAM)، Go 1.22.3، Linux 6.6. متوسط 5 جولات. الفرق الكبير ده مش لأن Go أسرع من Python في الـ CPU — هو لأن requests في Python كانت بتحجز الـ thread طوال انتظار الشبكة. Goroutines بتفك الـ thread فوراً.
3 أخطاء كلاسيكية للمبتدئ
1. نسيان WaitGroup
لو شغّلت 100 goroutine ومـ main خلصت قبلهم، Go بيقفل البرنامج وبيقتل كل الـ goroutines قبل ما تخلّص. النتيجة: 0 إيميل اتبعت رغم إن الكود "اشتغل" بدون errors. لازم تستخدم sync.WaitGroup أو channel للتأكد من الانتهاء.
2. مشاركة متغير الـ loop بدون نسخه (الفخ القديم)
// غلط على Go 1.21 وأقل — كل goroutines هتشوف نفس i الأخير
for i := 0; i < 100; i++ {
go func() { fmt.Println(i) }()
}
// النتيجة على الأغلب: 100, 100, 100, ... (مش 0,1,2,...)
// صح — مرّر i كباراميتر
for i := 0; i < 100; i++ {
go func(n int) { fmt.Println(n) }(i)
}
ملاحظة: Go 1.22 أصلح المشكلة دي تلقائياً (loop variable scoping). لكن لو شغّال على codebase قديم، الفخ ده موجود ولسه يكسر إنتاج.
3. مشاركة state بدون mutex
أي متغير تقرأ منه و تكتب فيه من goroutines مختلفة محتاج sync.Mutex أو channel، وإلا تدخل في data race. شغّل go run -race main.go دائماً في الـ development. الـ race detector بيكتشف 95% من الـ data races قبل الإنتاج.
Trade-offs حقيقية لازم تعرفها
- الذاكرة بتنمو خطياً. 100 ألف goroutine = 200 ميجا ذاكرة كحد أدنى. لو مش محتاج كل ده، حدد عدد الـ workers بـ buffered channel (worker pool pattern).
- ترتيب التنفيذ غير مضمون. Goroutine #5 ممكن تخلّص قبل Goroutine #1. لو محتاج ترتيب، استخدم slice مع index ثابت بدل print مباشر.
- الـ panic بياكل البرنامج كله. panic داخل goroutine مش بيتلقّفه recover خارج الـ goroutine، بيقتل الـ process كله. كل goroutine ممكن تـ panic لازم يبقى فيها
defer func() { recover() }(). - مفيش حد طبيعي. لو فتحت 100 ألف HTTP request في نفس اللحظة لـ API خارجي، API هيرفضك بـ 429 أو يحظرك. استخدم
semaphoreأو worker pool لتحديد الـ concurrency.
متى لا تستخدم Goroutines
لو الشغل بتاعك CPU-bound (compression، encryption، image processing بدون I/O)، Goroutines مش هتسرّعك. الـ M:N scheduler بيحسّن انتظار I/O، لكن الـ CPU work الفعلي بيتوزّع بس على عدد الـ cores. على لابتوب 4 cores، تشغيل 1000 goroutine بتعمل حسابات ثقيلة هيكون أبطأ من 4 goroutines لأن الـ context switching هياكل وقت بدون فايدة.
كذلك لو في عمليات تسلسلية بطبيعتها (نفس الـ DB transaction، نفس الـ file write)، Goroutines هتتحوّل لـ سباق على lock وتبطّأ كل حاجة. ابدأ بكود تسلسلي، قِس البطء، وبعدين قرر فين تستخدم concurrency.
الخطوة التالية
افتح أي script بسيط عندك بيعمل HTTP requests متسلسلة (web scraper، email sender، API caller). حوّله لـ Go، ضيف go + sync.WaitGroup على الـ loop. شغّله ولاحظ زمن التنفيذ. لو نزل أقل من 10 أضعاف، يبقى المشكلة في حاجة تانية (rate limit على الـ API، DNS resolution، أو bottleneck على الشبكة) — وده اللي يستحق المقال الجاي.
مصادر
- Go Documentation: Effective Go — Goroutines and Channels —
go.dev/doc/effective_go - Dmitry Vyukov: "Scalable Go Scheduler Design Doc" (2012)
- Go Source Code:
src/runtime/proc.go— تطبيق الـ M:N scheduler - Go 1.22 Release Notes — تغيير loop variable scoping (
go.dev/doc/go1.22) - Donovan & Kernighan: "The Go Programming Language" (Addison-Wesley, 2015)، الفصل 8 (Goroutines and Channels) والفصل 9 (Concurrency with Shared Variables)
- Google FCM Documentation — Quotas and Rate Limits (
firebase.google.com/docs/cloud-messaging)