تشغيل goroutine
ضع go أمام استدعاء دالة فيعمل ذلك الاستدعاء بالتزامن. تعود العبارة فورًا؛ ولا ينتظر المستدعي.
أربع goroutines تشغّل shout في الوقت نفسه. ترتيب انتهائها غير محدّد، لكن المخرجات دائمًا بالترتيب الأصلي، لأن كل goroutine تكتب في فهرسها الخاص ولا تقرأ main الشريحة إلا بعد عودة wg.Wait().
هذا المثال يحتوي أصلًا على الأشياء الثلاثة التي يحتاجها كل برنامج goroutines تقريبًا: طريقة لبدء العمل (go)، وطريقة لانتظاره (sync.WaitGroup)، وطريقة لاستعادة النتائج دون أن تلمس goroutine الذاكرة نفسها (عنصر شريحة واحد لكل منهما).
main لا تنتظر
عندما تعود main ينتهي البرنامج. الـ goroutines التي ما زالت تعمل تتوقّف حيثما كانت. لا شيء ينتظرها.
يطبع هذا عادة from main فقط. أحيانًا تُجدوَل الـ goroutine في الوقت المناسب فترى السطرين. كلمة "عادة" هي المشكلة: شيفرة تعمل على جهازك وتفشل على خادم مشغول.
عبارة time.Sleep في نهاية main تجعل المثال يطبع السطرين، وهي الإصلاح الخاطئ. إنها تخمّن مدة العمل. انتظر العمل نفسه، بـ WaitGroup (صفحة WaitGroup تشرحها بالتفصيل) أو بقناة.
استعادة النتائج
ترمي عبارة go القيم المعادة من الدالة. x := go f() لا تُترجم. هناك طريقتان معتادتان لإعادة البيانات.
خانة واحدة لكل goroutine، كما في المثال الأول. حدّد حجم الشريحة مسبقًا، وأعطِ كل goroutine فهرسها، واقرأ بعد Wait. يُحفظ الترتيب ولا حاجة لأقفال، لأن لا goroutine تكتبان العنصر نفسه.
قناة. كل goroutine ترسل نتيجتها؛ والمستقبل يجمعها. تصل النتائج بترتيب الانتهاء، لا بترتيب البدء.
استقبال len(nums) قيمة بالضبط يعمل كانتظار أيضًا: لا تستطيع main تجاوز الحلقة حتى ترسل كل goroutine. ترتيب الوصول يتغيّر بين التشغيلات، لذا يرتّب البرنامج قبل طباعة أي شيء يعتمد على الترتيب. صفحة القنوات تشرح القنوات المخزّنة والإغلاق وrange على القناة.
متغيّرات الحلقة والإغلاقات (تغيير Go 1.22)
منذ Go 1.22 يحصل كل تكرار لحلقة for على نسخة جديدة من متغيّرات الحلقة. الإغلاق الذي يبدأ في goroutine يلتقط قيمة ذلك التكرار، فهذا صحيح:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
قبل Go 1.22 كانت كل التكرارات تتشارك i واحدًا وw واحدًا، وكانت كل goroutine تميل إلى رؤية القيمة الأخيرة. الشيفرة القديمة تلتفّ على ذلك بتمرير القيم كوسائط، go func(i int, w string) { ... }(i, w)، أو بالتظليل، i := i. كلاهما غير مؤذٍ في Go 1.22 وما بعده، وستظل تراهما في الشيفرة الموجودة. ينطبق السلوك الجديد عندما يذكر go.mod للوحدة go 1.22 أو أعلى.
الـ goroutines رخيصة
تبدأ الـ goroutine بمكدّس صغير (بضعة كيلوبايتات) يكبّره وقت التشغيل ويصغّره عند الحاجة. يشغّل مجدول Go الـ goroutines على مجموعة من خيوط نظام التشغيل، لا ينفّذ منها شيفرة Go في آن واحد أكثر من GOMAXPROCS، وافتراضيًا يساوي GOMAXPROCS عدد المعالجات. التوقّف على قناة أو mutex أو sleep أو إدخال وإخراج شبكي يركن الـ goroutine ويحرّر الخيط لغيرها.
لذا فتشغيل goroutine لكل مهمة لا بأس به حتى بأعداد كبيرة:
مئة ألف goroutine تنتهي في جزء من الثانية. المجموع دائمًا 4999950000، لأن atomic.Int64 تجعل كل عملية جمع غير قابلة للتجزئة. لكن الرخيص لا يعني المجاني: كل goroutine ما زالت متوقّفة تُبقي مكدّسها وكل ما تشير إليه حيًّا.
| خيط نظام التشغيل | Goroutine | |
|---|---|---|
| من ينشئه | النواة | وقت تشغيل Go |
| المكدّس الأولي | ثابت، غالبًا 1 MB أو أكثر | بضعة KB، ينمو عند الطلب |
| التبديل | تبديل سياق في النواة | مجدول Go، في مساحة المستخدم |
| الهوية | له معرّف خيط | لا معرّف يمكنك قراءته، عن قصد |
| العدد المعتاد | مئات | من آلاف إلى ملايين |
سباقات البيانات
وصول goroutine إلى المتغيّر نفسه في الوقت نفسه، وإحداهما على الأقل تكتب، هو سباق بيانات (data race). النتيجة غير متوقّعة، لا "منحرفة قليلًا" فحسب: تضيع التحديثات، والسباق على نص أو شريحة أو خريطة أو قيمة واجهة قد يُسقط البرنامج أو يفسد الذاكرة.
على جهاز متعدّد الأنوية يطبع هذا رقمًا مختلفًا أقل من 10000 في معظم التشغيلات، لأن goroutine تقرآن القيمة القديمة نفسها وتكتب كل منهما تلك القيمة زائد واحد. وعلى نواة واحدة قد يطبع 10000، وهذا أسوأ: يجتاز الخطأ اختبارك ويظهر في الإنتاج.
تأتي Go مع كاشف سباقات. شغّل برنامجك أو اختباراتك مع -race:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
يشير إلى السطر بالضبط (counter++) وإلى الـ goroutine كلتيهما. لا يبلغ إلا عن السباقات التي تحدث فعلًا أثناء التشغيل، فشغّله على اختبارات تمرّ بالمسارات المتزامنة. ويبطئ البرنامج عدة أضعاف، فهو للاختبارات وبيئة ما قبل الإنتاج، لا للإنتاج.
الإصلاحات، من الأبسط إلى الأعم:
- لا تشارك. أعطِ كل goroutine بياناتها الخاصة واجمعها في النهاية (نمط خانة لكل goroutine).
- استخدم
sync/atomicلعدّاد أو علم واحد:var n atomic.Int64; n.Add(1). - استخدم
sync.Mutexحول أي شيء أكبر، مثل خريطة أو بنية بعدة حقول. صفحة mutex تشرحRWMutexوsync.Onceأيضًا. - أرسل البيانات عبر قناة حتى لا تملكها في كل لحظة إلا goroutine واحدة.
panic في goroutine يقتل البرنامج
إذا سبّبت goroutine حالة panic ولم يستعدها أي recover داخل الـ goroutine نفسها، ينهار البرنامج كله، بما في ذلك main وكل goroutine أخرى. لا يفيد recover في main، لأن recover لا يلتقط إلا حالات panic في الـ goroutine الخاصة به.
الاستعادة بهذا الشكل منطقية عند حافة خادم يعمل لفترة طويلة، حيث يجب ألا يُسقط طلب سيئ واحد البقية. داخل الشيفرة العادية تعني حالة panic غالبًا خطأ برمجيًا، والانهيار بصوت عالٍ هو النتيجة الصحيحة.
تسرّب الـ goroutines
الـ goroutine التي تتوقّف إلى الأبد لا تخرج أبدًا ولا تحرّر ذاكرتها. السبب الكلاسيكي إرسال لن يستقبله أحد أبدًا:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
كل استدعاء يسرّب len(urls) - 1 goroutine. في خادم يعالج هذا الطلب آلاف المرات تتصاعد الذاكرة حتى تموت العملية. إصلاحان: اجعل القناة كبيرة بما يكفي لينتهي كل مرسل (make(chan string, len(urls)))، أو أعطِ الـ goroutines طريقة للاستسلام، عادة context.Context مع select على ctx.Done(). يمكنك مراقبة التسرّبات بـ runtime.NumGoroutine() في الاختبارات.
تحديد عدد ما يعمل في آن واحد
نهج "goroutine لكل عنصر" مقبول لـ 10,000 عملية حسابية رخيصة. وغير مقبول لـ 10,000 طلب HTTP إلى الخادم نفسه أو 10,000 ملف مفتوح. حدّد التزامن بقناة مخزّنة تُستخدم كإشارة (semaphore):
تحمل القناة المخزّنة 3 رموز على الأكثر، فلا تتجاوز سطر sem <- أكثر من 3 goroutines في أي لحظة. لا يمكن أن تتجاوز الذروة 3، ومع اثنتي عشرة مهمة تنام كل منها تصل إلى 3 عمليًا. مجموعة ثابتة من goroutines العاملة تقرأ من قناة مهام هي الشكل الشائع الآخر؛ وصفحة WaitGroup تبني واحدة.
خارج المكتبة القياسية تجمع golang.org/x/sync/errgroup بين WaitGroup وأول خطأ وإلغاء السياق وحد التزامن (g.SetLimit(n)) في نوع واحد. وهي الخيار المعتاد في شيفرة الإنتاج التي تحتاج الأربعة.
أخطاء شائعة
- نسيان الانتظار. تعود
mainولا يحدث العمل أبدًا دون أي إشارة. كل عبارةgoتحتاج طريقة مقابلة لمعرفة أنها انتهت. - استدعاء
wg.Addداخل الـ goroutine. قد تُنفَّذWaitقبلAdd، فترى العدّاد صفرًا وتعود مبكرًا. استدعِAddقبل عبارةgo. - مشاركة متغيّر دون تزامن. الخرائط هي الحالة الشائعة: يكتشف وقت التشغيل عادة الكتابات المتزامنة على خريطة ويُسقط البرنامج برسالة
fatal error: concurrent map writes، التي لا يستطيعrecoverالتقاطها. - افتراض ترتيب. تعمل الـ goroutines بالترتيب الذي يختاره المجدول. إذا كان يجب أن تكون المخرجات مرتّبة، فاجمعها ورتّبها، أو اكتب في خانات مفهرسة.
- استخدام
time.Sleepللتزامن. يجعل الاختبارات بطيئة وغير مستقرّة رغم ذلك. انتظر الحدث، لا تخمينًا. - تشغيل goroutine دون طريقة لإيقافها. كل ما يدور في حلقة أو ينتظر إدخالًا وإخراجًا يجب أن يأخذ
context.Contextليتمكّن المستدعي من إلغائه.
الأسئلة الشائعة
ما هي الـ goroutine في Go؟
الـ goroutine استدعاء دالة يعمل بالتزامن مع بقية البرنامج. تبدأ واحدة بوضع go قبل الاستدعاء: go work(). يدير الـ goroutines وقت تشغيل Go لا نظام التشغيل، ويوزّع وقت التشغيل الكثير منها على عدد صغير من خيوط نظام التشغيل، فتشغيل الآلاف منها أمر عادي.
كيف أنتظر انتهاء الـ goroutines في Go؟
استخدم sync.WaitGroup: استدعِ wg.Add(1) قبل كل عبارة go، وdefer wg.Done() في أول الـ goroutine، وwg.Wait() حيث تحتاج أن تنتهي جميعها. وإذا كانت الـ goroutines تنتج قيمًا، فاستقبال قيمة واحدة لكل goroutine من قناة يعمل كانتظار أيضًا.
ما الفرق بين الـ goroutine والخيط (thread)؟
لخيط نظام التشغيل مكدّس ثابت (غالبًا 1 MB أو أكثر) وتجدوله النواة. أما الـ goroutine فتبدأ بمكدّس من بضعة كيلوبايتات ينمو عند الحاجة، ويبدّل مجدول Go بين الـ goroutines في مساحة المستخدم. يشغّل وقت التشغيل الـ goroutines على ما يصل إلى GOMAXPROCS خيطًا في آن واحد (افتراضيًا عدد المعالجات).
كيف أحصل على قيمة معادة من goroutine؟
عبارة go تتجاهل القيم المعادة من الدالة. أرسل النتيجة على قناة (results <- compute(x)) أو اكتبها في خانتك الخاصة من شريحة محدّدة الحجم مسبقًا (out[i] = compute(x)) واقرأها بعد wg.Wait().
لماذا ينتهي برنامج Go قبل أن تطبع الـ goroutine أي شيء؟
عندما تعود main ينتهي البرنامج وتتوقّف كل goroutine أخرى دون تنفيذ بقية شيفرتها. لا شيء ينتظر الـ goroutines تلقائيًا. أوقف main حتى ينتهي العمل بـ WaitGroup أو باستقبال من قناة. إضافة time.Sleep تخفي المشكلة فقط.