تصرّح الكلمة المفتاحية chan عن نوع channel. والـ channel أنبوب له نوع محدد تستخدمه goroutines لتمرير القيم فيما بينها، وكل عملية إرسال واستقبال تزامن أيضًا بين الـ goroutines المعنيّتين. النوع chan Job ينقل قيم Job في الاتجاهين، و chan<- Job يرسل فقط، و <-chan Job يستقبل فقط. ويجب إنشاء الـ channel باستخدام make قبل استخدامها، لأن القيمة الصفرية لنوع channel هي nil (مواصفات Go).
الخلاصة
- التعبير
make(chan Job)ينشئ channel بلا مخزن مؤقت. ويبقى الإرسال حاجبًا حتى تستقبل goroutine أخرى القيمة، فيصبح كل إرسال عملية تسليم مباشرة. - التعبير
make(chan Job, 100)ينشئ channel ذات مخزن مؤقت (buffered). ولا يُحجب الإرسال إلا حين يمتلئ المخزن، ولا يُحجب الاستقبال إلا حين يفرغ. - وجود
chan<- Jobأو<-chan Jobفي توقيع دالة يقصرها على الإرسال أو على الاستقبال. ويرفض المترجم أي شيء آخر. - المرسِل هو من يغلق الـ channel، وليس المستقبِل أبدًا. وتتوقف الحلقة
for job := range jobsبعد الإغلاق، ويعيدv, ok := <-chالقيمةok == falseحين تكون الـ channel مغلقة وفارغة. - الإرسال على channel مغلقة يسبب panic، وكذلك إغلاق channel مرتين. وكل عملية على channel قيمتها
nilباستثناءcloseتبقى حاجبة إلى الأبد. - الرسالة
fatal error: all goroutines are asleep - deadlock!تعني أن كل goroutines حاجبة. والتسرّب (leak) هو النسخة الأهدأ، حيث تعلق بعض goroutines فقط. وتكتشفها Go 1.27 عبر ملف التحليلgoroutineleak. - تنتظر
selectعدة عمليات على channels في آن واحد. وتمنحك المهل الزمنية والإلغاء والإرسال غير الحاجب.
كيف تصرّح عن channel وتنشئها في Go؟
اكتب chan ثم نوع العناصر. والمتغير المُصرَّح عنه بهذه الطريقة قيمته nil حتى تُسند إليه channel من make:
example.gogopackage main import "fmt" type Job struct { ID int URL string } func main() { var queue chan Job fmt.Println(queue == nil, len(queue), cap(queue)) queue = make(chan Job, 100) queue <- Job{ID: 1, URL: "https://api.example.com/webhooks/stripe"} queue <- Job{ID: 2, URL: "https://api.example.com/webhooks/github"} fmt.Println(len(queue), cap(queue)) job := <-queue fmt.Println(job.ID, job.URL) }
example.texttexttrue 0 0 2 100 1 https://api.example.com/webhooks/stripe
التعبير ch <- v يرسل، و <-ch يستقبل. ويشير السهم دائمًا إلى الاتجاه الذي تتحرك فيه البيانات. وتعيد cap حجم المخزن المؤقت الذي مرّرته إلى make، وتعيد len عدد القيم الموجودة في المخزن الآن. وتخرج القيم بالترتيب الذي دخلت به.
الـ channel مرجع إلى بنية داخلية في وقت التشغيل، مثل الـ map. وتمريرها إلى دالة أو تخزينها في struct ينسخ المرجع، لذا تتعامل كل نسخة مع الـ channel نفسها. ويشرح مقالا الكلمة المفتاحية var في Go و var مقابل make الـ channels والـ maps والـ slices بوصفها الأنواع الثلاثة التي تُنشأ عادةً باستخدام make.
ما الفرق بين الـ channels ذات المخزن المؤقت والـ channels من دونه؟
الـ channel التي بلا مخزن مؤقت (unbuffered) لا تخزّن شيئًا. فالإرسال ينتظر حتى يأخذ مستقبِل القيمة، والاستقبال ينتظر حتى يعرض مرسِل قيمة. ويلتقي الطرفان في اللحظة نفسها. وتصوغ مواصفات Go ذلك هكذا: «لا ينجح الاتصال إلا حين يكون المرسِل والمستقبِل كلاهما جاهزين».
هنا يمرّر معالج رفع ملفات اسم ملف إلى ماسح فيروسات يحتاج إلى 100ms كي يبدأ العمل:
example.gogofunc main() { uploads := make(chan string) go func() { time.Sleep(100 * time.Millisecond) fmt.Println("scanner: ready") for name := range uploads { _ = name // virus-scan the file } }() start := time.Now() uploads <- "invoice-1001.pdf" fmt.Println("handler: send returned after", time.Since(start).Round(10*time.Millisecond)) }
example.texttextscanner: ready handler: send returned after 100ms
لم يكن ممكنًا أن يكتمل الإرسال قبل أن يصبح الماسح جاهزًا للاستقبال. وحين يعود الإرسال، يعرف المعالج أن الماسح استلم القيمة. وهذا الضمان مفيد في حد ذاته، وينص عليه نموذج الذاكرة في Go رسميًا: الاستقبال من channel بلا مخزن مؤقت يتزامن قبل اكتمال الإرسال المقابل له.
أما الـ channel ذات المخزن المؤقت فتخزّن ما يصل إلى cap من القيم. ومع make(chan string, 10) كان إرسال المعالج سيعود فورًا، وكان الماسح سيلتقط الملف بعد 100ms. ولا يُحجب المرسِل إلا حين تمتلئ الخانات العشر كلها.
ما حجم المخزن المؤقت المناسب لـ channel في Go؟
ابدأ بصفر أو واحد، ولا تختر رقمًا أكبر إلا حين تستطيع أن تقول ما الغرض منه. فالمخزن المؤقت لا يجعل المستهلك البطيء أسرع. وإذا كان المنتجون أسرع من المستهلكين باستمرار، يمتلئ مخزن بحجم 1,000 ثم يتصرف كـ channel بلا مخزن مؤقت، مع تأخير إضافي بمقدار 1,000 قيمة أمامه.
يفيد المخزن المؤقت في ثلاث حالات:
- goroutine تُنتج نتيجة واحدة. يتيح لها
make(chan Result, 1)أن ترسل وتنتهي حتى إن لم يستقبل أحد، وهذا هو حل التسرّب الذي يظهر لاحقًا في المقال. - الدفعات المفاجئة. مُصدِّر المقاييس الذي يتلقى 50 عيّنة دفعة واحدة ويرسلها على دفعات كل ثانية يمكنه استخدام مخزن بحجم الدفعة المعتادة.
- تقييد التزامن. تعمل الـ channel ذات المخزن المؤقت بسعة N كسيمافور يسمح بتشغيل N من goroutines في الوقت نفسه. وفي قسم الأنماط مثال على ذلك.
ماذا يعني chan<- و <-chan في Go؟
هما نوعا channel ذوا اتجاه. فالنوع chan<- Job للإرسال فقط، و <-chan Job للاستقبال فقط. وتتحول الـ channel ثنائية الاتجاه chan Job إلى أي منهما تلقائيًا، لذا تنشئ الـ channel مرة واحدة وتعطي كل دالة الاتجاه الذي تحتاج إليه فقط.
هكذا يبدو خط معالجة webhooks فيه مرحلة إنتاج ومرحلة تسليم:
example.gogotype Result struct { JobID int Status int } func produce(ids []int, out chan<- Job) { for _, id := range ids { out <- Job{ID: id, URL: fmt.Sprintf("https://hooks.example.com/%d", id)} } close(out) } func deliver(in <-chan Job, results chan<- Result) { for job := range in { results <- Result{JobID: job.ID, Status: 200} } close(results) } func main() { jobs := make(chan Job) results := make(chan Result) go produce([]int{101, 102, 103}, jobs) go deliver(jobs, results) for r := range results { fmt.Printf("job %d: %d\n", r.JobID, r.Status) } }
example.texttextjob 101: 200 job 102: 200 job 103: 200
توثّق التواقيع من يملك كل طرف. فحين تقرأ deliver تعرف أنها لا تكتب إلى in أبدًا ولا تغلقها. والمترجم يفرض ذلك. فإذا حاولت deliver أن ترسل على مدخلها أو تغلقه:
example.gogopackage main type Job struct{ ID int } func deliver(in <-chan Job) { in <- Job{ID: 1} close(in) }
example.texttext./main.go:6:2: invalid operation: cannot send to receive-only channel <-chan Job in (variable of type <-chan Job) ./main.go:7:8: invalid operation: cannot close receive-only channel in (variable of type <-chan Job)
يُعدّ الإغلاق عملية من جهة الإرسال، لذا لا يمكن إغلاق إلا chan<- أو channel ثنائية الاتجاه. وهكذا تصبح قاعدة الملكية المعتادة، «المرسِل هو من يغلق»، شيئًا يتحقق منه نظام الأنواع نيابةً عنك.
كيف تغلق channel في Go؟
استدعِ close(ch). يخبر الإغلاقُ المستقبِلين بأنه لن تأتي قيم أخرى. ولا يتخلص من القيم الموجودة في المخزن المؤقت. فالمستقبِلون يحصلون عليها أولًا، وبعد ذلك يعود كل استقبال فورًا بالقيمة الصفرية:
example.gogofunc main() { events := make(chan string, 2) events <- "user.created" events <- "user.deleted" close(events) for i := 0; i < 3; i++ { ev, ok := <-events fmt.Printf("%q %v\n", ev, ok) } }
example.texttext"user.created" true "user.deleted" true "" false
القيمة الثانية، ok، تكون true مع القيمة الحقيقية و false بمجرد أن تُغلق الـ channel وتفرغ. أما ev := <-events وحدها فلا تستطيع التمييز بين سلسلة نصية فارغة أُرسلت فعلًا والقيمة الصفرية لـ channel مغلقة، لذا استخدم صيغة القيمتين كلما كان من الممكن أن تكون الـ channel مغلقة.
تُجري for v := range ch هذا التحقق نيابةً عنك. فهي تستقبل حتى تُغلق الـ channel وتفرغ، ثم تخرج من الحلقة. وبهذا تعرف deliver أعلاه متى تتوقف، ولهذا يجب على produce أن تغلق jobs حين تنفد المعرّفات لديها. ودون الإغلاق ستنتظر deliver مهمة رابعة إلى الأبد.
من يجب أن يغلق الـ channel في Go؟
الـ goroutine التي ترسل. فالمستقبِل لا يستطيع أن يعرف هل هناك قيمة أخرى في الطريق، والإغلاق من جهة الاستقبال يؤدي مباشرةً إلى حالات الـ panic أدناه. وحين ترسل عدة goroutines على channel واحدة، لا تستطيع أي منها إغلاقها وحدها. بل تنتظرها كلها goroutine منفصلة باستخدام sync.WaitGroup ثم تغلق الـ channel. وتعرض مجموعة العمّال أدناه هذا الشكل.
ثلاثة أخطاء تسبب panic وقت التشغيل:
example.texttextpanic: send on closed channel panic: close of closed channel panic: close of nil channel
لست مضطرًا إلى إغلاق كل channel. فالـ channel التي لا يشير إليها أحد يجمعها جامع المهملات (garbage collector) سواء أُغلقت أم لا. أغلق الـ channel حين يحتاج المستقبِلون إلى معرفة أن التدفق انتهى، كما في حلقة range أو إشارة إيقاف التشغيل.
ماذا يحدث عند الإرسال إلى channel قيمتها nil أو مغلقة؟
إليك كل الحالات في مكان واحد:
| العملية | channel قيمتها nil | channel مفتوحة | channel مغلقة |
|---|---|---|---|
الإرسال ch <- v | حاجب إلى الأبد | حاجب حتى يأخذ مستقبِل القيمة أو تتوفر مساحة في المخزن المؤقت | panic |
الاستقبال <-ch | حاجب إلى الأبد | يعيد قيمة من المخزن المؤقت، أو يبقى حاجبًا حتى تصل قيمة | يعيد القيم المخزنة، ثم القيمة الصفرية مع ok == false |
close(ch) | panic | ينجح | panic |
لا تُختار أبدًا حالة select على channel قيمتها nil، وعلى هذا يعتمد مثال الدمج أدناه. أما الـ channel المغلقة فتعود فورًا لكل مستقبِل، وهذا يجعل close تعمل كبثّ جماعي (broadcast). فكل goroutine تستقبل من الـ channel تستيقظ في الوقت نفسه.
لماذا تقول Go "all goroutines are asleep - deadlock!"؟
لأن بيئة التشغيل وجدت أن كل goroutine في البرنامج حاجبة ولا يمكن لأي منها أن تستيقظ أبدًا. وأبسط نسخة من ذلك إرسال على channel بلا مخزن مؤقت دون أي مستقبِل:
example.gogofunc main() { uploads := make(chan string) uploads <- "invoice-1001.pdf" fmt.Println("queued") }
example.texttextfatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() /app/main.go:7 +0x34
الـ goroutine الوحيدة هي main، وهي تنتظر إرسالًا لن يستقبله أحد. ويذكر الأثر (trace) العملية الحاجبة (chan send) والسطر. والحل أن تستقبل في goroutine أخرى، أو أن تعطي الـ channel مخزنًا مؤقتًا إذا كان كل ما تحتاج إليه هو إيداع قيمة واحدة. ويذكر مقال أخطاء Go الشائعة هذا الخطأ في المرتبة العاشرة.
لا يعمل هذا الكاشف إلا حين تعلق كل goroutines. وخادم الويب لديه دائمًا goroutine تنتظر في مراقب الشبكة (network poller) الاتصال التالي، ولا يعدّها الكاشف عالقة، لذا لا يطلقه أبدًا معالج حاجب على channel. فالمعالج يتوقف فحسب، وتبقى الـ goroutine الخاصة به في الذاكرة حتى تنتهي العملية. ويُسمّى هذا تسرّب goroutine.
كيف تكتشف تسرّب goroutines في Go؟
هذا شكل شائع من أشكال التسرّب. صفحة إتمام الشراء تطلب عرض سعر من ثلاث شركات شحن وتأخذ أسرع إجابة:
example.gogofunc fastestQuote(ctx context.Context) (Quote, error) { ch := make(chan Quote) go func() { ch <- fetchQuote("ups", 50*time.Millisecond) }() go func() { ch <- fetchQuote("dhl", 80*time.Millisecond) }() go func() { ch <- fetchQuote("fedex", 300*time.Millisecond) }() select { case q := <-ch: return q, nil case <-ctx.Done(): return Quote{}, ctx.Err() } }
تستقبل الدالة مرة واحدة ثم تعود. وبعدها تحاول الـ goroutines الاثنتان الأبطأ أن ترسلا على channel بلا مخزن مؤقت لن يقرأ منها أحد. فتبقيان حاجبتين طوال عمر العملية. وبعد 100 عملية شراء تكون هناك 200 goroutine عالقة.
جعلت Go 1.27 ملف التحليل goroutineleak متاحًا للجميع، بعد إصدار واحد كان فيه تجريبيًا في Go 1.26 (ملاحظات إصدار Go 1.27). يبحث جامع المهملات عن goroutines حاجبة على channel أو قفل لم يعد يمكن الوصول إليه من أي goroutine قابلة للتشغيل، ويبلّغ عنها مجمّعة حسب المكدّس (stack):
example.gogopprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
example.texttextgoroutineleak profile: total 200 100 @ 0x1044f99a8 0x104492854 0x104492468 0x104550560 0x1044ffc74 # 0x10455055f main.fastestQuote.func3+0x4f /app/main.go:27 100 @ 0x1044f99a8 0x104492854 0x104492468 0x1045505d0 0x1044ffc74 # 0x1045505cf main.fastestQuote.func2+0x4f /app/main.go:26
يشير الملف إلى goroutines الخاصة بـ DHL و FedEx بأرقام أسطرها. والخادم الذي يستورد net/http/pprof يعرض البيانات نفسها على /debug/pprof/goroutineleak. ويشرح مقال ما الجديد في Go 1.27 هذا الملف بتفصيل أكبر.
الحل مخزن مؤقت يتسع لكل المرسِلين:
example.gogoch := make(chan Quote, 3)
الآن تستطيع كل goroutine أن ترسل وتنتهي، سواء قرأ أحد قيمتها أم لا. ومع هذا التغيير يبلّغ البرنامج نفسه عن goroutineleak profile: total 0. وبصورة أعم، كل goroutine تبدؤها تحتاج إلى طريقة ما للانتهاء. وعادةً ما تكون هذه الطريقة مستقبِلًا يقرأ دائمًا، أو مخزنًا مؤقتًا يتسع لكل إرسال، أو select على ctx.Done().
كيف تعمل select مع الـ channels في Go؟
تنتظر select عدة عمليات على channels وتنفّذ أول عملية يمكنها المتابعة. وإن كانت عدة عمليات جاهزة في الوقت نفسه، تختار إحداها عشوائيًا، فلا تستطيع أي حالة أن تحرم الحالات الأخرى من الدور (مواصفات Go).
كيف تضيف مهلة زمنية إلى الاستقبال من channel؟
ضع الاستقبال و channel المهلة في select واحدة. ومع context.Context تكون channel المهلة هي ctx.Done():
example.gogofunc lookupPrice(ctx context.Context, sku string) (int, error) { result := make(chan int, 1) go func() { result <- slowPriceService(sku) }() select { case cents := <-result: return cents, nil case <-ctx.Done(): return 0, fmt.Errorf("price for %s: %w", sku, ctx.Err()) } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() fmt.Println(lookupPrice(ctx, "SKU-1001")) fmt.Println(lookupPrice(context.Background(), "SKU-1001")) }
example.texttext0 price for SKU-1001: context deadline exceeded 4999 <nil>
تستغرق slowPriceService مدة 300ms، لذا يستسلم الاستدعاء الأول بعد 100ms وينتظر الثاني الإجابة. والمخزن المؤقت بحجم 1 على result هو حل التسرّب من القسم السابق. فبعد انتهاء المهلة ما زالت الـ goroutine قادرة على إرسال إجابتها المتأخرة والانتهاء.
وتعمل case <-time.After(2 * time.Second): أيضًا حين لا يوجد context. فمنذ Go 1.23 يمكن لجامع المهملات أن يجمع المؤقّت الذي لا يشير إليه أحد قبل أن ينطلق، لذا لم يعد استخدام time.After داخل حلقة يراكم المؤقّتات (ملاحظات إصدار Go 1.23). وأزالت Go 1.27 إعداد GODEBUG المسمّى asynctimerchan الذي كان يعيد السلوك القديم. وفي الكود الذي يعالج الطلبات فضّل الـ context، لأنه يحمل أيضًا الإلغاء القادم من العميل.
كيف تُجري إرسالًا أو استقبالًا غير حاجب؟
أضف حالة default. تنفّذ select الحالة default حين لا تكون أي حالة أخرى جاهزة، لذا لا تنتظر العملية أبدًا. ومُصدِّر المقاييس الذي يجب ألا يبطئ مسار الطلبات أبدًا يستخدم هذا لإسقاط العيّنات حين يمتلئ طابوره:
example.gogotype Exporter struct { queue chan Metric dropped atomic.Int64 } func (e *Exporter) Record(m Metric) { select { case e.queue <- m: default: e.dropped.Add(1) } } func main() { e := &Exporter{queue: make(chan Metric, 3)} for i := range 5 { e.Record(Metric{Name: "http_requests_total", Value: float64(i)}) } fmt.Println("queued:", len(e.queue), "dropped:", e.dropped.Load()) }
example.texttextqueued: 3 dropped: 2
تعمل Record في معالجات طلبات كثيرة في الوقت نفسه، لذا يكون عدّاد الإسقاط من النوع atomic.Int64 بدلًا من int عادي. واحتفظ بعدد العيّنات المُسقَطة، لأن حالة default الصامتة تخفي اللحظة التي يبدأ فيها المستهلك بالتأخر.
لماذا تُسند nil إلى channel داخل select؟
لتعطيل حالة. فالاستقبال من channel قيمتها nil يبقى حاجبًا إلى الأبد، لذا لا يمكن أبدًا اختيار حالة select على channel قيمتها nil. وحين تدمج تدفقين، أسند nil إلى كل منهما بمجرد إغلاقه، وتوقف حين يُغلق الاثنان:
example.gogofunc merge(a, b <-chan string) []string { var out []string for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil continue } out = append(out, v) case v, ok := <-b: if !ok { b = nil continue } out = append(out, v) } } return out }
دون إسناد nil تكون الـ channel المغلقة جاهزة دائمًا، وستدور الحلقة على القيم الصفرية مستهلكةً المعالج بالكامل.
ما الأنماط الشائعة لاستخدام الـ channels في Go؟
أربعة أنماط تغطي معظم كود الـ channels في خدمات بيئة الإنتاج.
مجموعة العمّال (worker pool)
عدد ثابت من goroutines يقرأ من channel مهام واحدة. وهذه المجموعة تسلّم webhooks باستخدام ثلاثة عمّال:
example.gogofunc main() { hooks := make(chan Webhook) deliveries := make(chan Delivery) var wg sync.WaitGroup for worker := range 3 { wg.Go(func() { for h := range hooks { deliveries <- Delivery{WebhookID: h.ID, Worker: worker, Status: send(h)} } }) } go func() { for id := 1; id <= 9; id++ { hooks <- Webhook{ID: id, URL: fmt.Sprintf("https://partner.example.com/hooks/%d", id)} } close(hooks) }() go func() { wg.Wait() close(deliveries) }() ok := 0 for d := range deliveries { if d.Status == 200 { ok++ } } fmt.Println("delivered:", ok) }
example.texttextdelivered: 9
أربعة تفاصيل تجعل هذا الكود صحيحًا. المنتج يغلق hooks، فتنتهي حلقة range لدى كل عامل. والدالة wg.Go، التي أُضيفت في Go 1.25، تبدأ كل عامل وتتتبّعه في استدعاء واحد. وتنتظر goroutine منفصلة كل العمّال قبل إغلاق deliveries، لأنه لا يوجد عامل بمفرده يعرف متى ينتهي الآخرون. وتلتقط الـ closures المتغير worker بأمان منذ أن أعطت Go 1.22 كل تكرار للحلقة متغيره الخاص (راجع كيف تعمل الـ closures).
التوزيع والتجميع (fan-out و fan-in)
مجموعة العمّال هي توزيع (fan-out): channel واحدة تغذي goroutines كثيرة. والـ channel المسمّاة deliveries هي تجميع (fan-in): goroutines كثيرة تكتب إلى channel واحدة تقرأ منها حلقة واحدة. والدالة merge أعلاه تجميع لعدد ثابت من channels المدخلات. ويبني مقال خطوط المعالجة (pipelines) في مدونة Go سلاسل أطول من هاتين القطعتين نفسيهما.
السيمافور (semaphore)
تقيّد channel ذات مخزن مؤقت من structs فارغة عدد goroutines التي تنفّذ عملًا ما في الوقت نفسه. وهذه خدمة صور يجب ألا تعيد تحجيم أكثر من ثلاث صور بالتوازي:
example.gogosem := make(chan struct{}, 3) var wg sync.WaitGroup for _, img := range images { wg.Go(func() { sem <- struct{}{} defer func() { <-sem }() resizeImage(img) }) } wg.Wait()
تبقى الـ goroutine الرابعة حاجبة عند sem <- struct{}{} حتى تحرّر إحدى الثلاث الأولى خانتها. ويضمن نموذج الذاكرة ذلك للـ channel ذات السعة C: الاستقبال رقم k يحدث قبل اكتمال الإرسال رقم (k+C) (نموذج الذاكرة في Go). وحين تعيد الـ goroutines أخطاءً أيضًا، يؤدي errgroup.Group مع SetLimit من golang.org/x/sync المهمة نفسها ويجمع أول خطأ.
إشارة الانتهاء باستخدام chan struct{}
الـ channel التي تُغلق فقط ولا يُرسل عليها أبدًا تبثّ إشارة إلى كل مستقبِل. ونوع العناصر هو struct{} لأنه لا يشغل أي بايت ويوضح أنه لا تنتقل أي بيانات (راجع الكلمة المفتاحية struct في Go):
example.gogofunc flushLoop(done <-chan struct{}, flushed chan<- int) { ticker := time.NewTicker(50 * time.Millisecond) defer ticker.Stop() n := 0 for { select { case <-ticker.C: n++ case <-done: flushed <- n return } } } func main() { done := make(chan struct{}) flushed := make(chan int) go flushLoop(done, flushed) time.Sleep(175 * time.Millisecond) close(done) fmt.Println("flushes before shutdown:", <-flushed) }
example.texttextflushes before shutdown: 3
والنوع context.Context مبني على الفكرة نفسها. فالدالة ctx.Done() تعيد <-chan struct{} تُغلق عند الإلغاء، لذا تعمل في أي select بالطريقة التي تعمل بها done أعلاه.
هل تستخدم channel أم mutex في Go؟
استخدم channel حين تنقل ملكية البيانات أو تنسّق بين goroutines. واستخدم sync.Mutex حين تحدّث عدة goroutines حالة واحدة في مكانها. ويصف مثل Go القائل «لا تتواصل عبر مشاركة الذاكرة، بل شارك الذاكرة عبر التواصل» (Go Proverbs) الحالة الأولى، وتقول صفحة ويكي Go بعنوان Use a sync.Mutex or a channel? صراحةً إن هذا المثل لا يستبعد الـ mutexes.
عدّاد الطلبات لكل مسار (route) حالة، وليس تدفقًا من القيم:
example.gogotype Stats struct { mu sync.Mutex counts map[string]int } func (s *Stats) Inc(route string) { s.mu.Lock() defer s.mu.Unlock() s.counts[route]++ }
نسخة الـ channel تحتاج إلى goroutine تملك الـ map، و channel للطلبات، و channel للردود من أجل القراءة. وهي كود أكثر، وكل زيادة تنتظر goroutine أخرى كي تعالجها. أما مع طابور المهام فالأمر معكوس. فالـ slice المحمية بـ mutex تحتاج إلى sync.Cond أو استطلاع دوري (polling) كي ينتظر العمّال وصول العمل، بينما تحجب الـ channel العمّال وتوقظهم دون أي جهد إضافي.
| استخدم channel من أجل | استخدم mutex من أجل |
|---|---|
| تسليم مهمة أو نتيجة إلى goroutine أخرى | العدّادات والذاكرات المؤقتة (caches) والـ maps التي تُحدَّث في مكانها |
| الإشارة إلى إيقاف التشغيل أو الإلغاء أو الاكتمال | حماية بضعة حقول داخل struct |
| تقييد التزامن أو بناء خطوط المعالجة (pipelines) | الأقسام الحرجة القصيرة في المسارات كثيرة الاستخدام |
اختبر الحالتين باستخدام go test -race. فهو يبلّغ عن map مشتركة دون قفل، ويبلّغ أيضًا عن مرسِل يواصل تعديل struct بعد إرسال مؤشر إليها بينما يقرؤها المستقبِل.
أين يأتي دور LevelUpGo
يعلّم LevelUpGo لغة Go عبر تمارين تشغّل كود Go حقيقيًا في المتصفح. تغطي دورة Go Concurrency Fundamentals الـ goroutines والـ channels بلا مخزن مؤقت وذات المخزن المؤقت واتجاهات الـ channels و select و context وكاشف التسابق (race detector)، مع تمارين تقع في deadlock حتى تصلحها. وتبني دورة Go Design Patterns أنماط مجموعة العمّال والتوزيع والتجميع وإيقاف التشغيل السلس الواردة في هذا المقال على شكل تمارين كاملة. ويقدّم Training Ground تمارين تزامن قصيرة مستقلة فيها كود يقع في deadlock أو يتسرّب أو يُسقط العمل. وللتعرّف على الكلمات المحجوزة الـ 24 الأخرى، راجع الكلمات المفتاحية في Go: شرح جميع الكلمات الـ 25.
الأسئلة الشائعة
هل chan كلمة مفتاحية في Go؟
نعم. chan واحدة من الكلمات المفتاحية المحجوزة الـ 25 في Go، لذا لا يمكنك استخدامها اسمًا لمتغير أو دالة. وهي تظهر في أنواع الـ channels مثل chan Job و chan<- Job و <-chan Job. أما العامل <- فليس كلمة مفتاحية، ولا make كذلك، فهي دالة مُصرَّح عنها مسبقًا.
هل يجب إغلاق الـ channel في Go؟
لا. لا تغلق الـ channel إلا حين يحتاج المستقبِلون إلى معرفة أنه لن تأتي قيم أخرى، مثلًا لإنهاء حلقة for range أو لبثّ إشارة إيقاف التشغيل. والـ channel غير المغلقة التي لا يشير إليها شيء يجمعها جامع المهملات مثل أي قيمة أخرى.
ما الفرق بين الـ channel ذات المخزن المؤقت والـ channel من دونه؟
الـ channel بلا مخزن مؤقت، make(chan T)، تجعل المرسِل ينتظر حتى يأخذ مستقبِل القيمة، فتلتقي الـ goroutines الاثنتان. أما الـ channel ذات المخزن المؤقت، make(chan T, n)، فتسمح بانتظار ما يصل إلى n من القيم داخلها، ولا يُحجب المرسِل إلا حين يمتلئ المخزن.
ماذا يحدث عند القراءة من channel مغلقة في Go؟
تحصل أولًا على أي قيم ما زالت في المخزن المؤقت. وبعد ذلك يعود كل استقبال فورًا بالقيمة الصفرية لنوع العناصر. وصيغة القيمتين v, ok := <-ch تجعل ok مساوية لـ false مع تلك القيم الصفرية، لذا يمكنك تمييزها عن القيم الحقيقية.
كيف تتحقق من أن الـ channel مغلقة في Go؟
استقبل منها باستخدام v, ok := <-ch وتحقق من ok. لا توجد دالة تخبرك هل الـ channel مغلقة دون استقبال، و len(ch) لا تحسب إلا القيم الموجودة في المخزن المؤقت. والكود الذي يحتاج إلى هذا التحقق فيه عادةً أكثر من goroutine تغلق الـ channel، وهذا هو التصميم الذي يجب إصلاحه.
هل يمكن المرور على channel باستخدام range في Go؟
نعم. تستقبل for v := range ch القيم حتى تُغلق الـ channel وتفرغ، ثم تخرج من الحلقة. وإذا لم يغلق أحد الـ channel، تنتظر الحلقة إلى الأبد، لذا يجب على المرسِل أن يستدعي close حين ينتهي.
المصادر
- The Go Programming Language Specification, Channel types: https://go.dev/ref/spec#Channel_types
- The Go Programming Language Specification, Send statements: https://go.dev/ref/spec#Send_statements
- The Go Programming Language Specification, Receive operator: https://go.dev/ref/spec#Receive_operator
- The Go Programming Language Specification, Close: https://go.dev/ref/spec#Close
- The Go Programming Language Specification, Select statements: https://go.dev/ref/spec#Select_statements
- The Go Memory Model, Channel communication: https://go.dev/ref/mem#chan
- Effective Go, Concurrency: https://go.dev/doc/effective_go#concurrency
- The Go Blog, Go Concurrency Patterns: Pipelines and cancellation: https://go.dev/blog/pipelines
- Go Wiki, Use a sync.Mutex or a channel?: https://go.dev/wiki/MutexOrChannel
- Go 1.23 Release Notes, Timer changes: https://go.dev/doc/go1.23#timer-changes
- Go 1.27 Release Notes: https://go.dev/doc/go1.27
- Go Proverbs: https://go-proverbs.github.io/
