لو عندك 50 goroutine بتكتب على نفس الـ map، الـ sync.Mutex هيشتغل لكن هيخلّي الكود هش. Channels في Go بتحل المشكلة بمنطق مختلف: بدل ما تقفل البيانات، تنقل ملكيتها. الفرق ده بيمنع category كاملة من الـ bugs، بس له ثمن واضح في الأداء هنقيسه بالظبط.
Go Channels: الأصل في التواصل بين Goroutines
المشكلة باختصار
Goroutine في Go رخيصة جدًا — ممكن تشغّل 100 ألف منها في دقيقة. لكن لحظة ما اتنين منهم يتلامسوا على نفس المتغيّر، عندك race condition. الحل التقليدي في C++/Java هو الـ mutex، والحل الأصلي في Go هو الـ channel. شعار فريق Go نفسه: "Don't communicate by sharing memory; share memory by communicating".
Unbuffered vs Buffered: الفرق الجوهري في سطرين
في Go نوعين من الـ channels، والفرق بينهم مش في الأداء بس — الفرق في متى بيحصل الـ synchronization.
- Unbuffered (
make(chan int)): المُرسِل بيتعلّق لحد ما المُستقبِل يستلم. ده بيضمن "handshake" — المُرسِل متأكد إن الرسالة وصلت. - Buffered (
make(chan int, 16)): المُرسِل بيكتب ويمشي لحد ما الـ buffer يمتلى. بيفكّ الترابط الزمني بين المُرسِل والمُستقبِل.
القاعدة اللي بستخدمها: unbuffered للـ coordination، buffered للـ throughput. لو محتاج تتأكد إن goroutine خلّصت شغلها قبل ما تكمل، unbuffered. لو عندك producer أسرع من consumer وعايز تمتص الفروقات، buffered بحجم = عدد الـ cores × 2 تقريبًا.
مثال تنفيذي: Worker Pool بـ 25 سطر
السيناريو: عندك 1000 URL محتاج تعمل لها HTTP request، متقدرش تفتح 1000 request مع بعض لأن الـ target بيعمل rate-limit عند 50 request/ثانية. Worker pool بـ 10 workers يحل المشكلة.
package main
import (
"fmt"
"net/http"
"sync"
)
func worker(id int, jobs <-chan string, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for url := range jobs {
resp, err := http.Get(url)
if err != nil {
results <- 0
continue
}
results <- resp.StatusCode
resp.Body.Close()
}
}
func main() {
urls := []string{"https://example.com", "https://go.dev" /* ...998 more */}
jobs := make(chan string, len(urls))
results := make(chan int, len(urls))
var wg sync.WaitGroup
for w := 1; w <= 10; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for _, u := range urls { jobs <- u }
close(jobs)
wg.Wait()
close(results)
for r := range results { fmt.Println(r) }
}
لاحظ النقاط دي: chan<- int و <-chan string بيحدّدوا اتجاه القناة داخل الدالة — ده بيمنع الـ worker من قفل القناة بالغلط. close(jobs) بيقول للـ range في الـ worker "خلاص، ماتستنّاش تاني". sync.WaitGroup بيضمن إن الـ main بتستنّى كل الـ workers يخلّصوا قبل ما تقفل results.
select: الأداة اللي بتمنع الـ deadlock
أكبر gotcha في channels: goroutine بتستنى رسالة على قناة مش هتوصل أبدًا. select بيحل المشكلة دي — بيخلّيك تستنّى من أكتر من قناة، مع default أو time.After كـ escape hatch.
select {
case msg := <-ch:
fmt.Println("received:", msg)
case <-time.After(2 * time.Second):
fmt.Println("timeout — بنكمّل من غير الرسالة")
case <-ctx.Done():
return ctx.Err()
}
القاعدة: أي channel receive في production لازم يكون عنده خطة للـ timeout أو الـ cancellation. context.Context هو الـ idiom القياسي في Go 1.7+.
Channels ضد sync.Mutex: الأرقام الحقيقية
هنا بيبدأ الـ trade-off يتّضح. في benchmark بسيط لتحديث counter مشترك من goroutines متوازية:
sync.Mutex: ≈ 311 نانو ثانية/عملية.chan intمع goroutine واحدة بتـ serialize التحديثات: ≈ 644 نانو ثانية/عملية.sync/atomic: ≈ 10–20 نانو ثانية/عملية (أسرع بكتير لعداد بسيط).
الأرقام دي مصدرها قياسات منشورة على Go primitives في سيناريو hot path (راجع المصادر في آخر المقال). الخلاصة: channel بيعمل الشغل في ضعف الزمن اللي الـ mutex بياخده تقريبًا. الثمن ده بتدفعه مقابل وضوح تصميمي أكبر، مش مقابل أي سحر.
trade-off صريح: امتى الـ channel يستاهل الثمن
بيستاهل لما:
- بتنقل ملكية قيمة بين goroutines — المثال الكلاسيكي: pipeline من ثلاث مراحل.
- عندك fan-out/fan-in patterns (workers متوازية بتشتغل على نفس الـ queue).
- محتاج cancellation propagation عبر شجرة من الـ goroutines — هنا
context+ channels هو الـ idiom الوحيد المقبول. - الوضوح الهيكلي أهم من آخر 300 نانو ثانية.
ميستاهلش لما:
- بتحمي متغيّر بسيط من وصول متزامن (counter، boolean flag) —
atomicأوMutexأسرع وأوضح. - عندك hot path فعلي بملايين العمليات في الثانية — كل نانو ثانية بتفرق.
- بتعمل cache بـ key-value يقرا أكتر ما يكتب —
sync.RWMutexأوsync.Mapأنسب.
أخطاء شائعة اتقابل معاها في مراجعات كود
- إرسال على قناة مقفولة بيعمل panic فوري. القاعدة: اللي بيرسل هو اللي يقفل. اللي بيستقبل متيقفلش أبدًا.
- قراءة من قناة nil بتعلّق الـ goroutine للأبد. ده مفيد في
selectعشان "تعطّل" فرع، بس خطر لو بالغلط. - Buffered channel بحجم عشوائي كبير (زي 1000) بيخبّي مشاكل الـ backpressure. حجم الـ buffer لازم يكون قرار، مش رقم عشوائي.
- Goroutine بتكتب على قناة من غير ما حد يقرا = memory leak صامت. لازم كل sender يضمن إن في receiver هيصحّى عليه.
متى لا تستخدم channels
لو كل اللي محتاجه قفل على structure مشتركة (مش نقل ملكية)، استخدم sync.Mutex مباشرة. توثيق Go نفسه بيقول ده: "Use whichever is most expressive and/or most simple." الـ Mutex مش هزيمة — هو الأداة الصح لحماية state. الـ channel هو الأداة الصح لنقل بيانات أو تنسيق lifecycle.
الخطوة التالية
افتح أي مشروع Go عندك وابحث عن sync.Mutex. لكل واحد منهم، اسأل سؤال واحد: "أنا بحمي state، ولا بنسّق عمل بين goroutines؟" لو الإجابة الثانية، فكّر في إعادة الكتابة بـ channel. لو الأولى، خلّيه mutex وما تتكلّفش.
المصادر
- Go Wiki: Use a sync.Mutex or a channel? — التوصية الرسمية من فريق Go.
- A Tour of Go: Channels — التوثيق الأساسي لأنواع القنوات.
- Go Blog: Pipelines and cancellation — نمط fan-out/fan-in الأصلي.
- Channels vs Mutexes In Go — the Big Showdown — مقارنة أداء مفصّلة.
- Atomic vs Mutex vs Cond vs Channels — Benchmarking the Hot Path — مصدر أرقام الـ 311/644 نانو ثانية.
- When to Use sync vs. channel in Go — مرجع قرار الاختيار بين الاتنين.