الكتاب الأساسي

أخطاء شائعة في البرمجة المتزامنة

Go لغة تدعم البرمجة المتزامنة المدمجة. فباستخدام الكلمة المفتاحية go لإنشاء goroutines (خيوط خفيفة الوزن)، وبـاستخدام القنوات والتزامن وتقنيات المزامنة الأخرى التي توفّرها Go، تصبح البرمجة المتزامنة سهلة ومرنة وممتعة.

من ناحية أخرى، لا تمنع Go مبرمجيها من ارتكاب بعض أخطاء البرمجة المتزامنة الناتجة عن الإهمال أو نقص الخبرة. ستعرض بقية هذه المقالة بعض الأخطاء الشائعة في البرمجة المتزامنة بلغة Go، لمساعدة مبرمجي Go على تجنّب ارتكاب مثل هذه الأخطاء.

عدم استخدام المزامنة عند الحاجة إليها

قد لا تُنفَّذ أسطر الشيفرة حسب ترتيب ظهورها.

يوجد خطآن في البرنامج التالي.

  • أولًا، قد تتسبب قراءة b في الـ goroutine الرئيسية وكتابة b في الـ goroutine الجديدة في حدوث تسابقات بيانات.
  • ثانيًا، لا يمكن للشرط b == true أن يضمن أن a != nil في الـ goroutine الرئيسية. فقد يقوم المصرِّف ووحدات CPU بتحسينات عبر إعادة ترتيب التعليمات في الـ goroutine الجديدة، لذا قد يحدث إسناد b قبل إسناد a في وقت التشغيل، مما يجعل الشريحة a لا تزال nil عند تعديل عناصر a في الـ goroutine الرئيسية.

package main

import (
	"time"
	"runtime"
)

func main() {
	var a []int // nil
	var b bool  // false

	// goroutine جديدة
	go func () {
		a = make([]int, 3)
		b = true // كتابة b
	}()

	for !b { // قراءة b
		time.Sleep(time.Second)
		runtime.Gosched()
	}
	a[0], a[1], a[2] = 0, 1, 2 // قد يحدث panic
}

قد يعمل البرنامج أعلاه جيدًا على حاسوب ما، لكنه قد يحدث فيه panic على حاسوب آخر، أو يعمل جيدًا عند تصريفه بمصرِّف معيّن، لكنه يحدث فيه panic عند استخدام مصرِّف آخر.

ينبغي أن نستخدم القنوات أو تقنيات المزامنة التي توفّرها الحزمة القياسية sync لضمان ترتيبات الذاكرة. على سبيل المثال،


package main

func main() {
	var a []int = nil
	c := make(chan struct{})

	go func () {
		a = make([]int, 3)
		c <- struct{}{}
	}()

	<-c
	// لن يحدث panic في السطر التالي بالتأكيد.
	a[0], a[1], a[2] = 0, 1, 2
}

استخدام استدعاءات time.Sleep لتحقيق المزامنة

لنلقِ نظرة على مثال بسيط.


package main

import (
	"fmt"
	"time"
)

func main() {
	var x = 123

	go func() {
		x = 789 // كتابة x
	}()

	time.Sleep(time.Second)
	fmt.Println(x) // قراءة x
}

نتوقّع أن يطبع هذا البرنامج 789. في الواقع، إنه يطبع 789 فعلًا، في جميع الحالات تقريبًا، أثناء التشغيل. لكن هل هو برنامج بمزامنة جيدة؟ لا! السبب هو أن وقت تشغيل Go لا يضمن حدوث كتابة x قبل قراءة x بشكل مؤكد. وفي ظروف معيّنة، مثل استهلاك معظم موارد CPU بواسطة برامج أخرى كثيفة الحساب تعمل على نظام التشغيل نفسه، قد تحدث كتابة x بعد قراءة x. لهذا ينبغي ألا نستخدم أبدًا استدعاءات time.Sleep لتحقيق المزامنة في المشاريع الرسمية.

لنلقِ نظرة على مثال آخر.


package main

import (
	"fmt"
	"time"
)

func main() {
	var n = 123

	c := make(chan int)

	go func() {
		c <- n + 0
	}()

	time.Sleep(time.Second)
	n = 789
	fmt.Println(<-c)
}

ما الذي تتوقّع أن يطبعه البرنامج؟ 123 أم 789؟ في الواقع، يعتمد المخرج على المصرِّف. فبالنسبة إلى مصرِّف Go القياسي 1.25.n، من المحتمل جدًا أن يطبع البرنامج 123. لكن من الناحية النظرية، قد يطبع أيضًا 789.

الآن، لنغيّر c <- n + 0 إلى c <- n ونشغّل البرنامج مرة أخرى، ستجد أن المخرج يصبح 789 (بالنسبة إلى مصرِّف Go القياسي 1.25.n). ومرة أخرى، يعتمد المخرج على المصرِّف.

نعم، توجد تسابقات بيانات في البرنامج أعلاه. فقد يُقيَّم التعبير n قبل جملة الإسناد n = 789 أو بعدها أو أثناء معالجتها. ولا يمكن لاستدعاء time.Sleep أن يضمن حدوث تقييم n قبل معالجة الإسناد.

في هذا المثال تحديدًا، ينبغي تخزين القيمة المراد إرسالها في قيمة مؤقتة قبل إنشاء الـ goroutine الجديدة، ثم إرسال القيمة المؤقتة بدلًا منها في الـ goroutine الجديدة لإزالة تسابقات البيانات.


...
	tmp := n
	go func() {
		c <- tmp
	}()
...

ترك الـ goroutines معلّقة

الـ goroutines المعلّقة هي الـ goroutines التي تبقى في حالة حجب إلى الأبد. وهناك أسباب كثيرة تؤدي إلى تعليق الـ goroutines. على سبيل المثال،

  • أن تحاول goroutine استقبال قيمة من قناة لن ترسل إليها أي goroutines أخرى قيمًا بعد الآن.
  • أن تحاول goroutine إرسال قيمة إلى قناة nil أو إلى قناة لن تستقبل منها أي goroutines أخرى قيمًا بعد الآن.
  • أن تُصاب goroutine بجمود من نفسها.
  • أن تُصاب مجموعة من الـ goroutines بجمود متبادل.
  • أن تُحجب goroutine عند تنفيذ كتلة شيفرة select بدون فرع default، وتبقى جميع عمليات القنوات التابعة للكلمات المفتاحية case في كتلة الشيفرة select محجوبة إلى الأبد.

باستثناء أننا نترك أحيانًا الـ goroutine الرئيسية في برنامج ما معلّقة عن قصد لتجنّب خروج البرنامج، فإن معظم حالات تعليق الـ goroutines الأخرى غير متوقّعة. ويصعب على وقت تشغيل Go أن يحكم على ما إذا كانت goroutine في حالة حجب معلّقة أم أنها تبقى في حالة الحجب مؤقتًا، لذا لن يُحرّر وقت تشغيل Go أبدًا الموارد التي تستهلكها goroutine معلّقة.

في حالة استخدام القناة الأولوية لأول استجابة، إذا لم تكن سعة القناة المستخدمة كـ future كبيرة بما يكفي، فستُعلَّق بعض الـ goroutines الأبطأ في الاستجابة عند محاولة إرسال نتيجة إلى قناة الـ future. على سبيل المثال، إذا استُدعيت الدالة التالية، فستبقى 4 goroutines في حالة حجب إلى الأبد.


func request() int {
	c := make(chan int)
	for i := 0; i < 5; i++ {
		go func(n int) {
			c <- n // ستُعلَّق 4 goroutines هنا.
		}(i)
	}
	return <-c
}

لتجنّب تعليق الـ goroutines الأربع، يجب أن تكون سعة القناة c على الأقل 4.

في الطريقة الثانية لتنفيذ الأولوية لأول استجابة، إذا كانت القناة المستخدمة كـ future/promise قناة غير مُخزَّنة، كما تُظهر الشيفرة التالية، فمن الممكن أن يفوت مستقبِل القناة جميع الاستجابات ويُعلَّق.


func request() int {
	c := make(chan int)
	for i := 0; i < 5; i++ {
		go func(n int) {
			select {
			case c <- n:
			default:
			}
		}(i)
	}
	return <-c // قد يُعلَّق هنا
}

السبب في أن goroutine المستقبِلة قد تُعلَّق هو أنه إذا حدثت عمليات الإرسال التجريبية الخمس كلها قبل أن تصبح عملية الاستقبال <-c جاهزة، فستفشل عمليات الإرسال التجريبية الخمس كلها في إرسال قيم، بحيث لن تتلقى goroutine المستدعية قيمة أبدًا.

سيضمن تحويل القناة c إلى قناة مُخزَّنة نجاح واحدة على الأقل من عمليات الإرسال التجريبية الخمس، بحيث لن تُعلَّق goroutine المستدعية أبدًا في الدالة أعلاه.

نسخ قيم الأنواع في الحزمة القياسية sync

عمليًا، ينبغي ألا تُنسخ أبدًا قيم الأنواع (باستثناء قيم الواجهة Locker) في الحزمة القياسية sync. ينبغي أن ننسخ فقط مؤشرات هذه القيم.

المثال التالي مثال سيئ للبرمجة المتزامنة. في هذا المثال، عند استدعاء طريقة Counter.Value، ستُنسخ قيمة مستقبِل من النوع Counter. وبوصف حقل Mutex المقابل في قيمة مستقبِل Counter حقلًا في قيمة المستقبِل، فسيُنسخ هو أيضًا. والنسخ غير متزامن، لذا قد تتلف قيمة Mutex المنسوخة. وحتى إن لم تتلف، فإن ما تحميه هو استخدام الحقل المنسوخ n، وهو أمر بلا معنى عمومًا.


import "sync"

type Counter struct {
	sync.Mutex
	n int64
}

// هذه الطريقة سليمة.
func (c *Counter) Increase(d int64) (r int64) {
	c.Lock()
	c.n += d
	r = c.n
	c.Unlock()
	return
}

// هذه الطريقة سيئة. عند استدعائها،
// ستُنسخ قيمة مستقبِل Counter.
func (c Counter) Value() (r int64) {
	c.Lock()
	r = c.n
	c.Unlock()
	return
}

ينبغي أن نغيّر نوع مستقبِل طريقة Value إلى نوع المؤشر *Counter لتجنّب نسخ قيم sync.Mutex.

سيبلّغ أمر go vet الموفَّر في سلسلة أدوات Go عن عمليات نسخ القيم السيئة المحتملة.

استدعاء طريقة sync.WaitGroup.Add في أماكن خاطئة

تحافظ كل قيمة sync.WaitGroup داخليًا على عدّاد، والقيمة الابتدائية لهذا العدّاد هي صفر. إذا كان عدّاد قيمة WaitGroup صفرًا، فلن يُحجب استدعاء طريقة Wait لتلك القيمة، وإلا فسيُحجب الاستدعاء حتى تصبح قيمة العدّاد صفرًا.

وليكون استخدام قيمة WaitGroup ذا معنى، عندما يكون عدّاد قيمة WaitGroup صفرًا، يجب أن يحدث الاستدعاء التالي لطريقة Add لتلك القيمة قبل الاستدعاء التالي لطريقة Wait لتلك القيمة.

على سبيل المثال، في البرنامج التالي، تُستدعى طريقة Add في مكان غير مناسب، مما يجعل الرقم المطبوع نهائيًا ليس دائمًا 100. في الواقع، قد يكون الرقم المطبوع نهائيًا للبرنامج أي عدد اعتباطي في المجال [0, 100). والسبب هو أنه لا يُضمن حدوث أي من استدعاءات طريقة Add قبل استدعاء طريقة Wait، مما يؤدي إلى عدم ضمان حدوث أي من استدعاءات طريقة Done قبل عودة استدعاء طريقة Wait.


package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var wg sync.WaitGroup
	var x int32 = 0
	for i := 0; i < 100; i++ {
		go func() {
			wg.Add(1)
			atomic.AddInt32(&x, 1)
			wg.Done()
		}()
	}

	fmt.Println("Wait ...")
	wg.Wait()
	fmt.Println(atomic.LoadInt32(&x))
}

وليسلك البرنامج كما هو متوقّع، ينبغي نقل استدعاءات طريقة Add خارج الـ goroutines الجديدة المنشأة في حلقة for، كما تُظهر الشيفرة التالية.


...
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			atomic.AddInt32(&x, 1)
			wg.Done()
		}()
	}
...

استخدام القنوات كـ futures/promises بشكل غير صحيح

من مقالة حالات استخدام القنوات، نعلم أن بعض الدوال تُرجع قنوات كـ futures. لنفترض أن fa وfb دالتان من هذا النوع، فإن الاستدعاء التالي يستخدم معطيات future بشكل غير صحيح.


doSomethingWithFutureArguments(<-fa(), <-fb())

في سطر الشيفرة أعلاه، تُعالَج توليدات المعطيين بالتتابع بدلًا من التزامن.

ينبغي تعديله كما يلي لمعالجتهما بشكل متزامن.


ca, cb := fa(), fb()
doSomethingWithFutureArguments(<-ca, <-cb)

إغلاق القنوات من غير آخر goroutine مُرسِلة نشطة

من الأخطاء الشائعة التي يرتكبها مبرمجو Go إغلاق قناة بينما لا تزال هناك بعض الـ goroutines الأخرى التي قد ترسل قيمًا إلى القناة لاحقًا. وعندما يحدث هذا الإرسال المحتمل (إلى القناة المغلقة) فعلًا، قد يحدث panic.

لقد ارتُكب هذا الخطأ سابقًا في بعض مشاريع Go الشهيرة، مثل هذا الخطأ وهذا الخطأ في مشروع kubernetes.

يُرجى قراءة هذه المقالة للحصول على شروح حول كيفية إغلاق القنوات بأمان ورشاقة.

إجراء عمليات atomic 64-بت على قيم غير مضمون أن تكون بمحاذاة 8 بايت

يُشترط أن يكون عنوان القيمة المتضمنة في عملية atomic 64-بت غير منهجية (non-method) بمحاذاة 8 بايت. وقد يؤدي الإخفاق في ذلك إلى حدوث panic في الـ goroutine الحالية. وبالنسبة إلى مصرِّف Go القياسي، لا يمكن أن يحدث هذا الإخفاق إلا على معماريات 32-بت. ومنذ Go 1.19، يمكننا استخدام عمليات atomic منهجية 64-بت لتجنّب هذا العيب. يُرجى قراءة تخطيطات الذاكرة لمعرفة كيفية ضمان محاذاة عناوين الكلمات 64-بت على 8 بايت في أنظمة التشغيل 32-بت.

عدم الانتباه إلى استهلاك استدعاءات الدالة time.After لموارد كثيرة جدًا

تُرجع الدالة After في الحزمة القياسية time قناة لإشعار تأخير. والدالة مريحة، لكن كل استدعاء لها سيُنشئ قيمة جديدة من النوع time.Timer. وقبل Go 1.23، تظل قيمة Timer المنشأة حديثًا حيّة طوال المدة المحدّدة بالمعطى الممرَّر إلى الدالة After. وإذا استُدعيت الدالة مرات كثيرة في فترة معيّنة، فستتراكم قيم Timer حيّة كثيرة بحيث يُستهلك الكثير من الذاكرة والحساب.

على سبيل المثال، (قبل Go 1.23)، إذا استُدعيت الدالة longRunning التالية وكانت هناك ملايين الرسائل الواردة في دقيقة واحدة، فستكون هناك ملايين قيم Timer حيّة في فترة قصيرة معيّنة (بضع ثوانٍ)، حتى لو أصبح معظم قيم Timer هذه بلا فائدة بالفعل.


import (
	"fmt"
	"time"
)

// ستُرجع الدالة إذا كانت الفترة الفاصلة
// بين وصول الرسائل أكبر من دقيقة واحدة.
func longRunning(messages <-chan string) {
	for {
		select {
		case <-time.After(time.Minute):
			return
		case msg := <-messages:
			fmt.Println(msg)
		}
	}
}

ملاحظة: منذ Go 1.23، اختفت هذه المشكلة. لأن قيمة Timer منذ Go 1.23 يمكن جمعها دون انتهاء صلاحيتها أو إيقافها.

قبل Go 1.23، لتجنّب إنشاء قيم Timer كثيرة جدًا في الشيفرة أعلاه، كان ينبغي استخدام قيمة Timer واحدة (وإعادة استخدامها) للقيام بالمهمة نفسها.


func longRunning(messages <-chan string) {
	timer := time.NewTimer(time.Minute)
	defer timer.Stop()

	for {
		select {
		case <-timer.C: // تنتهي صلاحيتها (مهلة)
			return
		case msg := <-messages:
			fmt.Println(msg)

			// كتلة "if" هذه مهمة.
			if !timer.Stop() {
				<-timer.C
			}
		}

		// إعادة الضبط لإعادة الاستخدام.
		timer.Reset(time.Minute)
	}
}

لاحظ أن كتلة الشيفرة if تُستخدم لتجاهل/تفريغ إشعار المؤقّت المحتمل الذي يُرسل في الفترة القصيرة عند تنفيذ كتلة شيفرة الفرع الثاني (منذ Go 1.23، أصبح هذا غير ضروري).

ملاحظة: منذ Go 1.23، ستتجاهل طريقة Timer.Reset تلقائيًا / ستفرّغ إشعار المؤقّت القديم المحتمل. لذا يمكن تبسيط الشيفرة أعلاه إلى


func longRunning(messages <-chan string) {
	timer := time.NewTimer(time.Minute)
	// defer timer.Stop() // أصبح غير ضروري منذ Go 1.23

	for {
		select {
		case <-timer.C:
			return
		case msg := <-messages:
			fmt.Println(msg)
		}

		timer.Reset(time.Minute)
	}
}

استخدام قيم time.Timer بشكل خاطئ

(ملاحظة: المشكلات الموصوفة في هذا القسم لم تكن موجودة إلا قبل Go 1.23. ومنذ Go 1.23، اختفت.)

عُرض مثال على الاستخدام الاصطلاحي لقيم time.Timer في القسم الأخير. وفيما يلي بعض الشروح:

  • تُرجع طريقة Stop لقيمة *Timer القيمة false إذا كانت قيمة Timer المقابلة قد انتهت صلاحيتها أو أُوقفت بالفعل. وإذا علمنا أن قيمة Timer لم تُوقف بعد، وأرجعت طريقة Stop القيمة false، فلا بد أن قيمة Timer قد انتهت صلاحيتها بالفعل.
  • قبل Go 1.23، بعد إيقاف قيمة Timer، لا يمكن أن يحتوي حقل القناة C الخاص بها على أكثر من إشعار مهلة واحد.
  • قبل Go 1.23، كان ينبغي إخراج إشعار المهلة، إن لم يكن قد أُخرج، من قيمة Timer منتهية الصلاحية بعد إيقاف قيمة Timer وقبل إعادة ضبط قيمة Timer وإعادة استخدامها. وهذا هو معنى كتلة الشيفرة if في مثال القسم الأخير.

يجب استدعاء طريقة Reset لقيمة *Timer عندما تكون قيمة Timer المقابلة قد انتهت صلاحيتها أو أُوقفت بالفعل، وإلا فقد يحدث تسابق بيانات بين استدعاء Reset وإرسال إشعار محتمل إلى حقل القناة C لقيمة Timer (قبل Go 1.23).

إذا اختير فرع case الأول في كتلة select، فهذا يعني أن قيمة Timer قد انتهت صلاحيتها بالفعل، لذا لا نحتاج إلى إيقافها، لأن الإشعار المُرسل قد أُخرج بالفعل. لكن يجب علينا إيقاف المؤقّت في الفرع الثاني للتحقق مما إذا كان إشعار مهلة موجودًا. وإذا كان موجودًا، ينبغي تفريغه قبل إعادة استخدام المؤقّت، وإلا فسيُطلق الإشعار فورًا في خطوة الحلقة التالية.

على سبيل المثال، من المحتمل جدًا أن ينتهي البرنامج التالي في نحو ثانية واحدة بدلًا من عشر ثوانٍ. والأهم من ذلك، أن البرنامج ليس خاليًا من تسابقات البيانات.


package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now()
	timer := time.NewTimer(time.Second/2)
	select {
	case <-timer.C:
	default:
		// على الأرجح يذهب إلى هنا.
		time.Sleep(time.Second)
	}
	// تسابق بيانات محتمل في السطر التالي.
	timer.Reset(time.Second * 10)
	<-timer.C
	fmt.Println(time.Since(start)) // نحو 1 ثانية
}

يمكن ترك قيمة time.Timer في حالة عدم إيقاف عندما لا تُستخدم بعد الآن، لكن يُوصى بإيقافها في النهاية.

استخدام قيمة time.Timer بشكل متزامن بين عدة goroutines عرضة للأخطاء وغير موصى به.

ينبغي ألا نعتمد على القيمة المُرجعة من استدعاء طريقة Reset. فنتيجة الإرجاع لطريقة Reset موجودة لغرض التوافق فقط.