ستقدّم هذه المقالة الـgoroutines واستدعاءات الدوال المؤجَّلة (deferred function calls). إن الـgoroutine واستدعاء الدالة المؤجَّل ميزتان فريدتان في Go. تشرح هذه المقالة أيضاً آلية panic وrecover. ولا تُغطّي هذه المقالة كل المعرفة المتعلقة بهذه الميزات، بل سيُقدَّم المزيد في مقالات قادمة.
الـGoroutines
تحتوي المعالجات الحديثة (CPUs) غالباً على عدة أنوية، وبعض أنوية المعالج تدعم الخيوط الفائقة (hyper-threading). بعبارة أخرى، يمكن للمعالجات الحديثة معالجة عدة مسارات تعليمات في آنٍ واحد. وللاستفادة الكاملة من قدرة المعالجات الحديثة، نحتاج إلى البرمجة المتزامنة عند كتابة برامجنا.
الحوسبة المتزامنة شكل من أشكال الحوسبة تُنفَّذ فيه عدة عمليات حسابية خلال فترات زمنية متداخلة. تصوّر الصورة التالية حالتين من الحوسبة المتزامنة. في الصورة، يمثّل A وB عمليتين حسابيتين منفصلتين. وتُسمّى الحالة الثانية أيضاً الحوسبة المتوازية، وهي حالة خاصة من الحوسبة المتزامنة. أما في الحالة الأولى، فإن A وB متوازيان فقط خلال جزء صغير من الزمن.

قد تحدث الحوسبة المتزامنة في برنامج أو حاسوب أو شبكة. وفي Go 101، نتحدث فقط عن الحوسبة المتزامنة على نطاق البرنامج. والـgoroutine هي طريقة Go لإنشاء عمليات حسابية متزامنة في برمجة Go.
كثيراً ما يُطلق على الـgoroutines أيضاً اسم الخيوط الخضراء (green threads). تُدار الخيوط الخضراء وتُجدوَل بواسطة وقت تشغيل اللغة بدلاً من أنظمة التشغيل. وتكلفة استهلاك الذاكرة وتبديل السياق للـgoroutine أقل بكثير من خيط نظام التشغيل. لذا، ليست مشكلة أن يحتفظ برنامج Go بعشرات الآلاف من الـgoroutines في الوقت نفسه، ما دامت ذاكرة النظام كافية.
لا تدعم Go إنشاء خيوط النظام في شيفرة المستخدم. لذا، فإن استخدام الـgoroutines هو الطريقة الوحيدة للقيام بالبرمجة المتزامنة (على نطاق البرنامج) في Go.
يبدأ كل برنامج Go بـgoroutine واحدة فقط، نسمّيها الـgoroutine الرئيسية (main goroutine). ويمكن للـgoroutine إنشاء goroutines جديدة. إن إنشاء goroutine جديدة في Go سهل للغاية، ما عليك سوى استخدام الكلمة المفتاحية go متبوعة باستدعاء دالة. عندئذٍ يُنفَّذ استدعاء الدالة في goroutine مُنشأة حديثاً. وتنتهي الـgoroutine المُنشأة حديثاً مع انتهاء الدالة المستدعاة.
يجب إهمال جميع قيم النتائج لاستدعاء دالة الـgoroutine (إذا كانت الدالة المستدعاة تُرجع قيماً) في جملة استدعاء الدالة. وفي ما يلي مثال يُنشئ goroutines جديدتين في الـgoroutine الرئيسية. في المثال، time.Duration نوع مخصّص معرَّف في الحزمة القياسية time. ونوعه الكامن هو النوع المدمج int64. ستُشرح الأنواع الكامنة في المقالة التالية.
package main
import (
"log"
"math/rand"
"time"
)
func SayGreetings(greeting string, times int) {
for i := 0; i < times; i++ {
log.Println(greeting)
d := time.Second * time.Duration(rand.Intn(5)) / 2
time.Sleep(d) // النوم من 0 إلى 2.5 ثانية
}
}
func main() {
log.SetFlags(0)
go SayGreetings("hi!", 10)
go SayGreetings("hello!", 10)
time.Sleep(2 * time.Second)
}
سهل جداً، أليس كذلك؟ نحن الآن نبرمج بشكل متزامن! قد يكون للبرنامج أعلاه ثلاث goroutines من إنشاء المستخدم تعمل في وقت واحد عند ذروة تشغيله. لنشغّله. أحد النتائج المحتملة للإخراج:
hi!
hello!
hello!
hello!
hello!
hi!
عندما تنتهي الـgoroutine الرئيسية، ينتهي البرنامج كله أيضاً، حتى لو كانت هناك بعض الـgoroutines الأخرى التي لم تنتهِ بعد.
بخلاف المقالات السابقة، يستخدم هذا البرنامج دالة Println في الحزمة القياسية log بدلاً من الدالة المقابلة لها في الحزمة القياسية fmt. والسبب أن دوال الطباعة في الحزمة القياسية log متزامنة (سيشرح القسم التالي ما هي المزامنات)، لذا لن تختلط النصوص التي تطبعها الـgoroutines في سطر واحد (مع أن احتمال اختلاط النصوص المطبوعة باستخدام دوال الطباعة في الحزمة القياسية fmt ضعيف جداً في هذا البرنامج تحديداً).
مزامنة التزامن
قد تتشارك العمليات الحسابية المتزامنة موارد، وهي عادةً مورد الذاكرة. وفي ما يلي بعض الحالات التي قد تحدث أثناء الحوسبة المتزامنة:
- في الفترة نفسها التي تكتب فيها عملية حسابية بيانات إلى مقطع ذاكرة، تقرأ عملية حسابية أخرى بيانات من مقطع الذاكرة نفسه. عندئذٍ قد لا تكون سلامة البيانات التي قرأتها العملية الأخرى محفوظة.
- في الفترة نفسها التي تكتب فيها عملية حسابية بيانات إلى مقطع ذاكرة، تكتب عملية حسابية أخرى أيضاً بيانات إلى مقطع الذاكرة نفسه. عندئذٍ قد لا تكون سلامة البيانات المخزّنة في مقطع الذاكرة محفوظة.
تُسمّى هذه الحالات تسابقات البيانات (data races). ومن واجبات البرمجة المتزامنة التحكم في مشاركة الموارد بين العمليات الحسابية المتزامنة، بحيث لا تحدث تسابقات البيانات أبداً. وتُسمّى الطرق التي تُنفَّذ بها هذه المهمة مزامنات التزامن، أو مزامنات البيانات، وسيُقدَّم كل منها على حدة في مقالات لاحقة من Go 101.
ومن الواجبات الأخرى في البرمجة المتزامنة
- تحديد عدد العمليات الحسابية المطلوبة.
- تحديد متى تبدأ العملية الحسابية ومتى تُحجب ومتى تُفكّ حجبها ومتى تنتهي.
- تحديد كيفية توزيع عبء العمل بين العمليات الحسابية المتزامنة.
البرنامج المعروض في القسم السابق ليس مثالياً. فالـgoroutines الجديدتان تهدفان إلى طباعة عشر تحيات كل منهما. غير أن الـgoroutine الرئيسية ستنتهي بعد ثانيتين، لذا لا تسنح لكثير من التحيات فرصة الطبع. فكيف نجعل الـgoroutine الرئيسية تعرف متى أنجزت الـgoroutines الجديدتان مهامهما كلتاهما؟ يجب أن نستخدم شيئاً يُسمّى تقنيات مزامنة التزامن.
تدعم Go عدة تقنيات لمزامنة التزامن. ومن بينها تقنية القنوات هي الأكثر تفرداً وشيوعاً في الاستخدام. لكن للتبسيط، سنستخدم هنا تقنية أخرى، هي النوع WaitGroup في الحزمة القياسية sync، لمزامنة التنفيذ بين الـgoroutines الجديدتين والـgoroutine الرئيسية.
للنوع WaitGroup ثلاث طرق (دوال خاصة، ستُشرح لاحقاً): Add وDone وWait. وسيُشرح هذا النوع بالتفصيل لاحقاً في مقال آخر. وهنا يمكننا ببساطة أن نعتبر
- أن الطريقة
Addتُستخدم لتسجيل عدد المهام الجديدة. - أن الطريقة
Doneتُستخدم للإشعار بانتهاء مهمة. - أن الطريقة
Waitتجعل الـgoroutine المستدعية في حالة حجب حتى تنتهي جميع المهام المسجّلة.
مثال:
package main
import (
"log"
"math/rand"
"time"
"sync"
)
var wg sync.WaitGroup
func SayGreetings(greeting string, times int) {
for i := 0; i < times; i++ {
log.Println(greeting)
d := time.Second * time.Duration(rand.Intn(5)) / 2
time.Sleep(d)
}
// الإشعار بانتهاء مهمة.
wg.Done() // <=> wg.Add(-1)
}
func main() {
log.SetFlags(0)
wg.Add(2) // تسجيل مهمتين.
go SayGreetings("hi!", 10)
go SayGreetings("hello!", 10)
wg.Wait() // الحجب حتى انتهاء جميع المهام.
}
شغّله، فنجد أن كلاً من الـgoroutines الجديدتين تطبع عشر تحيات قبل انتهاء البرنامج.
حالات الـgoroutine
يُظهر المثال الأخير أن الـgoroutine الحيّة قد تبقى في حالتين، التشغيل والحجب، وتنتقل بينهما. وفي ذلك المثال، تدخل الـgoroutine الرئيسية حالة الحجب عند استدعاء الطريقة wg.Wait، وتدخل حالة التشغيل مجدداً عندما تنتهي الـgoroutines الأخريان من مهامهما.
تصوّر الصورة التالية دورة حياة محتملة للـgoroutine.

لاحظ أن الـgoroutine لا تزال تُعدّ «قيد التشغيل» إذا كانت نائمة (بعد استدعاء الدالة time.Sleep) أو تنتظر استجابة استدعاء نظام أو اتصال شبكة.
عند إنشاء goroutine جديدة، تدخل حالة «التشغيل» تلقائياً. ولا يمكن للـgoroutines الخروج إلا من حالة التشغيل، وليس من حالة الحجب أبداً. وإذا بقيت الـgoroutine في حالة الحجب إلى الأبد لأي سبب، فلن تنتهي أبداً. وينبغي تجنّب مثل هذه الحالات في البرمجة المتزامنة، باستثناء بعض الحالات النادرة.
لا يمكن فكّ حجب goroutine محجوبة إلا بعملية تُجرى في goroutine أخرى. وإذا كانت جميع الـgoroutines في برنامج Go في حالة حجب، فستبقى كلها في حالة الحجب إلى الأبد. ويمكن اعتبار ذلك جموداً شاملاً (deadlock). وعندما يحدث هذا في برنامج، يحاول وقت تشغيل Go القياسي إسقاط البرنامج.
سيسقط البرنامج التالي بعد ثانيتين:
package main
import (
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
go func() {
time.Sleep(time.Second * 2)
wg.Wait()
}()
wg.Wait()
}
الناتج:
fatal error: all goroutines are asleep - deadlock!
...
لاحقاً، سنتعلّم المزيد من العمليات التي تجعل الـgoroutines تدخل حالة الحجب.
جدولة الـgoroutine
ليست كل الـgoroutines في حالة التشغيل قيد التنفيذ في لحظة معيّنة. وفي أي لحظة، لن يتجاوز الحد الأقصى لعدد الـgoroutines قيد التنفيذ عدد المعالجات المنطقية (logical CPUs) المتاحة للبرنامج الحالي. ويمكننا استدعاء الدالة runtime.NumCPU للحصول على عدد المعالجات المنطقية المتاحة للبرنامج الحالي. ولا يمكن لكل معالج منطقي تنفيذ سوى goroutine واحدة في أي لحظة. ويجب على وقت تشغيل Go تبديل سياقات التنفيذ بين الـgoroutines بشكل متكرر لتمنح كل goroutine قيد التشغيل فرصة للتنفيذ. وهذا مشابه لكيفية تبديل أنظمة التشغيل لسياقات التنفيذ بين خيوط نظام التشغيل.
تصوّر الصورة التالية دورة حياة أكثر تفصيلاً للـgoroutine. وفي الصورة، تُقسَّم حالة التشغيل إلى عدة حالات فرعية. فالـgoroutine في الحالة الفرعية للانتظار في الطابور تنتظر التنفيذ. وقد تدخل الـgoroutine في الحالة الفرعية للتنفيذ إلى حالة الانتظار في الطابور مجدداً بعد تنفيذها لفترة (جزء صغير جداً من الزمن).

يُرجى ملاحظة أن الحالات الفرعية الموضّحة في الصورة أعلاه لن تُذكر في مقالات أخرى من Go 101، للتبسيط. ومرة أخرى، في Go 101، لا تُعدّ الحالتان الفرعيتان للنوم واستدعاء النظام جزءاً من حالة الحجب.
يعتمد وقت تشغيل Go القياسي على نموذج M-P-G للقيام بمهمة جدولة الـgoroutines، حيث يمثّل M خيوط نظام التشغيل، ويمثّل P المعالجات المنطقية/الافتراضية (وليست المعالجات المنطقية CPUs)، ويمثّل G الـgoroutines. ويُنجَز معظم عمل الجدولة بواسطة المعالجات المنطقية (Ps)، التي تعمل كوسطاء بربط الـgoroutines (Gs) بخيوط نظام التشغيل (Ms). ولا يمكن ربط كل خيط نظام تشغيل بأكثر من goroutine واحدة في أي لحظة، ولا يمكن ربط كل goroutine بأكثر من خيط نظام تشغيل واحد في أي لحظة. ولا يمكن للـgoroutine أن تُنفَّذ إلا عندما تكون مرتبطة بخيط نظام تشغيل. وتحاول الـgoroutine التي نُفِّذت لفترة فصل نفسها عن خيط نظام التشغيل المقابل، حتى تسنح لـgoroutines أخرى قيد التشغيل فرصة الارتباط والتنفيذ.
في وقت التشغيل، يمكننا استدعاء الدالة runtime.GOMAXPROCS للحصول على عدد المعالجات المنطقية (Ps) وضبطه.
ويمكن أيضاً ضبط القيمة الأولية الافتراضية لـruntime.GOMAXPROCS عبر متغير البيئة GOMAXPROCS.
في أي وقت، لا يتجاوز عدد الـgoroutines في الحالة الفرعية للتنفيذ أصغر القيمتين runtime.NumCPU وruntime.GOMAXPROCS.
استدعاءات الدوال المؤجَّلة
استدعاء الدالة المؤجَّل هو استدعاء دالة تتبعه كلمة defer المفتاحية. وتشكّل كلمة defer المفتاحية واستدعاء الدالة المؤجَّل معاً جملة defer. ومثل استدعاءات دوال الـgoroutine، يجب إهمال جميع نتائج استدعاء الدالة (إذا كانت الدالة المستدعاة لها نتائج إرجاع) في جملة استدعاء الدالة.
عند تنفيذ جملة defer، لا يُنفَّذ استدعاء الدالة المؤجَّل فوراً. بل يُدفع إلى مكدس استدعاءات defer الذي يحتفظ به استدعاء الدالة الأعمق احتواءً. وبعد أن يعود استدعاء دالة fc(...) (لكنه لم يخرج تماماً بعد) ويدخل مرحلة الخروج، تُزال جميع استدعاءات الدوال المؤجَّلة المدفوعة إلى مكدس استدعاءات defer الخاص به من المكدس وتُنفَّذ، بترتيب أول داخل آخر خارج، أي عكس الترتيب الذي دُفعت به إلى مكدس استدعاءات defer. وبمجرد تنفيذ كل هذه الاستدعاءات المؤجَّلة، يخرج استدعاء الدالة fc(...) خروجاً تاماً.
في ما يلي مثال بسيط يوضّح كيفية استخدام استدعاءات الدوال المؤجَّلة.
package main
import "fmt"
func main() {
defer fmt.Println("The third line.")
defer fmt.Println("The second line.")
fmt.Println("The first line.")
}
الناتج:
The first line.
The second line.
The third line.
وفي ما يلي مثال آخر أكثر تعقيداً قليلاً. سيطبع المثال الأعداد من 0 إلى 9، كل عدد في سطر، بترتيبها الطبيعي.
package main
import "fmt"
func main() {
defer fmt.Println("9")
fmt.Println("0")
defer fmt.Println("8")
fmt.Println("1")
if false {
defer fmt.Println("not reachable")
}
defer func() {
defer fmt.Println("7")
fmt.Println("3")
defer func() {
fmt.Println("5")
fmt.Println("6")
}()
fmt.Println("4")
}()
fmt.Println("2")
return
defer fmt.Println("not reachable")
}
يمكن لاستدعاءات الدوال المؤجَّلة تعديل نتائج الإرجاع المسمّاة للدوال المحيطة
على سبيل المثال،
package main
import "fmt"
func Triple(n int) (r int) {
defer func() {
r += n // تعديل قيمة الإرجاع
}()
return n + n // <=> r = n + n; return
}
func main() {
fmt.Println(Triple(5)) // 15
}
لحظة تقييم معطيات استدعاءات الدوال المؤجَّلة
تُقيَّم جميع معطيات استدعاء الدالة المؤجَّل في اللحظة التي تُنفَّذ فيها جملة defer المقابلة (أي عندما يُدفع الاستدعاء المؤجَّل إلى مكدس استدعاءات defer). وتُستخدم نتائج التقييم عندما يُنفَّذ الاستدعاء المؤجَّل لاحقاً خلال مرحلة خروج الاستدعاء المحيط (مستدعي الاستدعاء المؤجَّل).
تُقيَّم التعابير داخل جسم استدعاء دالة مجهولة، سواء كان الاستدعاء عادياً أم مؤجَّلاً/خاصاً بـgoroutine، أثناء تنفيذ استدعاء الدالة المجهولة.
في ما يلي مثال.
// eval-moment.go
package main
import "fmt"
func main() {
func() {
var x = 0
for i := 0; i < 3; i++ {
defer fmt.Println("a:", i + x)
}
x = 10
}()
fmt.Println()
func() {
var x = 0
for i := 0; i < 3; i++ {
defer func() {
fmt.Println("b:", i + x)
}()
}
x = 10
}()
}
استخدم إصدارات مختلفة من سلسلة أدوات Go لتشغيل الشيفرة (gotv أداة تُستخدم لإدارة واستخدام تثبيتات متعددة متعايشة من إصدارات سلسلة أدوات Go الرسمية). النواتج:
$ gotv 1.21. run eval-moment.go
[Run]: $HOME/.cache/gotv/tag_go1.21.8/bin/go run eval-moment.go
a: 2
a: 1
a: 0
b: 13
b: 13
b: 13
$ gotv 1.22. run eval-moment.go
[Run]: $HOME/.cache/gotv/tag_go1.22.1/bin/go run eval-moment.go
a: 2
a: 1
a: 0
b: 12
b: 11
b: 10
يُرجى ملاحظة تغيّر السلوك الناتج عن التغيير الدلالي (لكتل حلقة for) الذي أُجري في Go 1.22.
وتنطبق قواعد لحظة تقييم المعطيات نفسها أيضاً على استدعاءات دوال الـgoroutine. سيُخرج البرنامج التالي 123 789.
package main
import "fmt"
import "time"
func main() {
var a = 123
go func(x int) {
time.Sleep(time.Second)
fmt.Println(x, a) // 123 789
}(a)
a = 789
time.Sleep(2 * time.Second)
}
بالمناسبة، ليس من الجيد القيام بالمزامنة باستخدام استدعاءات time.Sleep في المشاريع الرسمية. فإذا شُغّل البرنامج على حاسوب تشغل معالجاته برامج أخرى كثيرة، فقد لا تسنح للـgoroutine المُنشأة حديثاً فرصة للتنفيذ قبل انتهاء البرنامج. وينبغي أن نستخدم تقنيات مزامنة التزامن المقدَّمة في مقال نظرة عامة على مزامنة التزامن للقيام بالمزامنة في المشاريع الرسمية.
ضرورة ميزة الدوال المؤجَّلة
في الأمثلة أعلاه، لم تكن استدعاءات الدوال المؤجَّلة ضرورية تماماً. غير أن ميزة استدعاء الدالة المؤجَّل ميزة ضرورية لآلية panic وrecover التي ستُقدَّم أدناه.
يمكن أن تساعدنا استدعاءات الدوال المؤجَّلة أيضاً في كتابة شيفرة أنظف وأكثر متانة. ويمكننا قراءة المزيد من أمثلة الشيفرة التي تستفيد من استدعاءات الدوال المؤجَّلة وتعلّم المزيد من التفاصيل عنها في مقال المزيد عن الدوال المؤجَّلة لاحقاً. وسنستكشف الآن أهمية الدوال المؤجَّلة لـpanic والاستعادة.
Panic وRecover
لا تدعم Go طرح الاستثناءات والتقاطها، بل يُفضَّل استخدام معالجة الأخطاء الصريحة في برمجة Go. وفي الواقع، تدعم Go آلية شبيهة بطرح/التقاط الاستثناءات. وتُسمّى هذه الآلية panic/recover.
يمكننا استدعاء الدالة المدمجة panic لإنشاء panic لجعل الـgoroutine الحالية تدخل حالة panic.
إن الدخول في حالة panic طريقة أخرى لإرجاع دالة. فبمجرد حدوث panic في استدعاء دالة، يعود استدعاء الدالة فوراً ويدخل مرحلة خروجه.
وباستدعاء الدالة المدمجة recover في استدعاء مؤجَّل، يمكن إزالة panic قائمة في الـgoroutine الحالية بحيث تدخل الـgoroutine الحالية حالة الهدوء الطبيعية مجدداً.
وإذا انتهت goroutine في حالة panic دون استعادتها، فستجعل البرنامج كله يسقط.
تُعرَّف الدالتان المدمجتان panic وrecover كما يلي
func panic(v interface{})
func recover() interface{}
ستُشرح أنواع الواجهات وقيمها في مقال الواجهات في Go لاحقاً. وهنا، نحتاج فقط إلى معرفة أن نوع الواجهة الفارغ interface{} يمكن اعتباره النوع any أو النوع Object في كثير من اللغات الأخرى. بعبارة أخرى، يمكننا تمرير قيمة من أي نوع إلى استدعاء الدالة panic.
القيمة التي يُرجعها استدعاء الدالة recover هي القيمة التي استهلكها استدعاء الدالة panic.
يوضّح المثال أدناه كيفية إنشاء panic وكيفية الاستعادة منه.
package main
import "fmt"
func main() {
defer func() {
fmt.Println("exit normally.")
}()
fmt.Println("hi!")
defer func() {
v := recover()
fmt.Println("recovered:", v)
}()
panic("bye!")
fmt.Println("unreachable")
}
الناتج:
hi!
recovered: bye!
exit normally.
وفي ما يلي مثال آخر يُظهر goroutine في حالة panic تنتهي دون استعادتها. لذا يسقط البرنامج كله.
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("hi!")
go func() {
time.Sleep(time.Second)
panic(123)
}()
for {
time.Sleep(time.Second)
}
}
الناتج:
hi!
panic: 123
goroutine 5 [running]:
...
سينشئ وقت تشغيل Go حالات panic لبعض الظروف، مثل قسمة عدد صحيح على صفر. على سبيل المثال،
package main
func main() {
a, b := 1, 0
_ = a/b
}
الناتج:
panic: runtime error: integer divide by zero
goroutine 1 [running]:
...
ستُذكر المزيد من ظروف panic في وقت التشغيل في مقالات لاحقة من Go 101.
بشكل عام، تُستخدم حالات panic للأخطاء المنطقية، مثل الأخطاء البشرية الناتجة عن الإهمال. وينبغي ألا تحدث الأخطاء المنطقية أبداً في وقت التشغيل. وإذا حدثت، فلا بد أن تكون هناك عيوب في الشيفرة. ومن ناحية أخرى، يصعب تجنّب الأخطاء غير المنطقية تجنّباً مطلقاً في وقت التشغيل. بعبارة أخرى، الأخطاء غير المنطقية أخطاء تحدث في الواقع. وينبغي ألا تسبب مثل هذه الأخطاء حالات panic، بل ينبغي إرجاعها صراحةً ومعالجتها بشكل سليم.
يمكننا تعلّم بعض حالات استخدام panic/recover والمزيد عن آلية panic/recover لاحقاً.
بعض الأخطاء الفادحة ليست حالات panic ولا يمكن الاستعادة منها
بالنسبة إلى مصرِّف Go القياسي، لا يمكن الاستعادة من بعض الأخطاء الفادحة، مثل تجاوز سعة المكدس ونفاد الذاكرة. وبمجرد حدوثها، سيسقط البرنامج.