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

ضمانات ترتيب الذاكرة في Go

عن ترتيب الذاكرة

كثيراً ما يُجري العديد من المصرِّفات (في وقت التصريف) ومعالجات CPU (في وقت التشغيل) بعض التحسينات عبر تعديل ترتيبات التعليمات، بحيث قد تختلف ترتيبات تنفيذ التعليمات عن الترتيبات المعروضة في الشيفرة. ويُسمى ترتيب التعليمات أيضاً غالباً ترتيب الذاكرة.

بطبيعة الحال، لا يمكن أن تكون إعادة ترتيب التعليمات عشوائية. فالشرط الأساسي لإعادة الترتيب داخل goroutine معينة هو ألا تكون إعادة الترتيب قابلة للكشف من قِبل الـ goroutine نفسها إذا لم تشارك هذه الـ goroutine بيانات مع goroutines أخرى. بعبارة أخرى، من منظور هذه الـ goroutine، يمكنها أن تعتبر ترتيب تنفيذ تعليماتها دائماً مطابقاً للترتيب المحدد في الشيفرة، حتى لو حدثت إعادة ترتيب التعليمات فعلاً في داخلها.

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

إليك برنامج Go غير احترافي لا يراعي إعادة ترتيب التعليمات. وقد وُسِّع البرنامج من مثال في الوثائق الرسمية نموذج ذاكرة Go 1.


package main

import "log"
import "runtime"

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
	if done {
		log.Println(len(a)) // يطبع دائماً 12 عند طباعته
	}
}

func main() {
	go setup()

	for !done {
		runtime.Gosched()
	}
	log.Println(a) // المتوقع طباعته: hello, world
}

من المحتمل جداً أن يكون سلوك هذا البرنامج كما نتوقع، أي سيُطبع النص hello, world. لكن سلوك هذا البرنامج يعتمد على المصرِّف وCPU. فإذا صُرِّف البرنامج بمصرِّف مختلف، أو بإصدار لاحق من المصرِّف، أو شُغِّل على معمارية مختلفة، فقد لا يُطبع النص hello, world، أو قد يُطبع نص مختلف عن hello, world. والسبب أن المصرِّفات ووحدات CPU قد تتبادل ترتيبات تنفيذ أول سطرين في الدالة setup، بحيث قد تصبح النتيجة النهائية للدالة setup هي


func setup() {
	done = true
	a = "hello, world"
	if done {
		log.Println(len(a))
	}
}

لا تستطيع الـ goroutine الخاصة بالدالة setup في البرنامج أعلاه ملاحظة إعادة الترتيب، لذا سيطبع السطر log.Println(len(a)) دائماً 12 (إذا نُفِّذ هذا السطر قبل خروج البرنامج). لكن الـ goroutine الرئيسية قد تلاحظ إعادة الترتيب، ولهذا قد لا يكون النص المطبوع hello, world.

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

يمكننا استخدام الأمر go build -race الموفَّر في سلسلة أدوات Go (Go Toolchain) لبناء برنامج، ثم تشغيل الملف التنفيذي الناتج للتحقق مما إذا كانت هناك تسابقات بيانات في البرنامج أم لا.

نموذج ذاكرة Go

أحياناً نحتاج إلى ضمان أن تنفيذ بعض أسطر الشيفرة في goroutine ما يجب أن يحدث قبل (أو بعد) تنفيذ بعض أسطر الشيفرة في goroutine أخرى (من منظور أيٍّ من الـ goroutineين)، للحفاظ على صحة البرنامج. وقد تسبب إعادة ترتيب التعليمات بعض المتاعب في مثل هذه الحالات. فكيف ينبغي أن نتصرف لمنع إعادة ترتيب تعليمات معينة محتملة؟

توفِّر معماريات CPU المختلفة تعليمات سياج (fence instructions) مختلفة لمنع أنواع مختلفة من إعادة ترتيب التعليمات. وتوفِّر بعض لغات البرمجة دوال مقابلة لإدراج تعليمات السياج هذه في الشيفرة. لكن فهم تعليمات السياج واستخدامها استخداماً صحيحاً يرفع سقف البرمجة المتزامنة.

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

سيسرد ما تبقى من هذا المقال بعض ترتيبات تنفيذ الشيفرة المضمونة (وغير المضمونة) في Go، والتي ذُكرت أو لم تُذكر في نموذج ذاكرة Go 1 ووثائق Go الرسمية الأخرى.

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

إنشاء الـ goroutine يحدث قبل تنفيذ الـ goroutine

في الدالة التالية، سيُنفَّذ الإسناد x, y = 123, 789 قبل النداء fmt.Println(x)، وسيُنفَّذ النداء fmt.Println(x) قبل النداء fmt.Println(y).


var x, y int
func f1() {
	x, y = 123, 789
	go func() {
		fmt.Println(x)
		go func() {
			fmt.Println(y)
		}()
	}()
}

لكن ترتيبات تنفيذ الجمل الثلاث في الدالة التالية غير حتمية. وتوجد تسابقات بيانات في هذه الدالة.


var x, y int
func f2() {
	go func() {
		// قد يطبع 0 أو 123 أو غير ذلك.
		fmt.Println(x)
	}()
	go func() {
		// قد يطبع 0 أو 789 أو غير ذلك.
		fmt.Println(y)
	}()
	x, y = 123, 789
}

ضمانات الترتيب المتعلقة بعمليات القنوات

يسرد نموذج ذاكرة Go 1 ضمانات الترتيب الثلاث التالية المتعلقة بالقنوات.

  1. الإرسال الناجح رقم n إلى قناة يحدث قبل اكتمال الاستقبال الناجح رقم n من تلك القناة، سواء كانت القناة مخزّنة أم غير مخزّنة.
  2. الاستقبال الناجح رقم n من قناة سعتها m يحدث قبل اكتمال الإرسال الناجح رقم (n+m) إلى تلك القناة. وعلى وجه الخصوص، إذا كانت تلك القناة غير مخزّنة (m == 0)، فإن الاستقبال الناجح رقم n من تلك القناة يحدث قبل اكتمال الإرسال الناجح رقم n إلى تلك القناة.
  3. إغلاق قناة يحدث قبل اكتمال استقبال إذا أرجع هذا الاستقبال القيمة الصفرية لأن القناة مغلقة.

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

إليك مثالاً يُظهر بعض ترتيبات تنفيذ الشيفرة المضمونة عند استخدام قناة غير مخزّنة.


func f3() {
	var a, b int
	var c = make(chan bool)

	go func() {
		a = 1
		c <- true
		if b != 1 { // مستحيل
			panic("b != 1") // لن يحدث أبداً
		}
	}()

	go func() {
		b = 1
		<-c
		if a != 1  { // مستحيل
			panic("a != 1") // لن يحدث أبداً
		}
	}()
}

هنا، بالنسبة إلى الـ goroutineين الجديدتين المُنشأتين، تُضمَن الترتيبات التالية:

  • ينتهي تنفيذ الإسناد b = 1 قطعاً قبل تقييم الشرط b != 1.
  • ينتهي تنفيذ الإسناد a = 1 قطعاً قبل تقييم الشرط a != 1.

لذا لن يُنفَّذ أيٌّ من نداءَي panic في المثال أعلاه أبداً. لكن نداءات panic في المثال التالي قد تُنفَّذ.


func f4() {
	var a, b, x, y int
	c := make(chan bool)

	go func() {
		a = 1
		c <- true
		x = 1
	}()

	go func() {
		b = 1
		<-c
		y = 1
	}()

	// توجد تسابقات بيانات كثيرة في هذه الـ goroutine.
	// لا تكتب شيفرة كهذه.
	go func() {
		if x == 1 {
			if a != 1 { // محتمل
				panic("a != 1") // قد يحدث
			}
			if b != 1 { // محتمل
				panic("b != 1") // قد يحدث
			}
		}

		if y == 1 {
			if a != 1 { // محتمل
				panic("a != 1") // قد يحدث
			}
			if b != 1 { // محتمل
				panic("b != 1") // قد يحدث
			}
		}
	}()
}

هنا، بالنسبة إلى الـ goroutine الثالثة، وهي غير معنية بالعمليات على القناة c، لن يُضمَن لها ملاحظة الترتيبات التي لاحظتها الـ goroutineان الجديدتان الأولى والثانية. لذا قد يُنفَّذ أيٌّ من نداءات panic الأربعة.

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

إليك مثالاً يستخدم قناة مخزّنة.


func f5() {
	var k, l, m, n, x, y int
	c := make(chan bool, 2)

	go func() {
		k = 1
		c <- true
		l = 1
		c <- true
		m = 1
		c <- true
		n = 1
	}()

	go func() {
		x = 1
		<-c
		y = 1
	}()
}

تُضمَن الترتيبات التالية:

  • ينتهي تنفيذ k = 1 قبل تنفيذ y = 1.
  • ينتهي تنفيذ x = 1 قبل تنفيذ n = 1.

لكن تنفيذ x = 1 ليس مضموناً حدوثه قبل تنفيذ l = 1 وm = 1، كما أن تنفيذ l = 1 وm = 1 ليس مضموناً حدوثه قبل تنفيذ y = 1.

فيما يلي مثال على إغلاق قناة. في هذا المثال، يُضمَن انتهاء تنفيذ k = 1 قبل تنفيذ y = 1، لكن ليس مضموناً انتهاؤه قبل تنفيذ x = 1،


func f6() {
	var k, x, y int
	c := make(chan bool, 1)

	go func() {
		c <- true
		k = 1
		close(c)
	}()

	go func() {
		<-c
		x = 1
		<-c
		y = 1
	}()
}

ضمانات الترتيب المتعلقة بـ mutex

فيما يلي ضمانات الترتيب المتعلقة بـ mutex في Go.

  1. بالنسبة إلى قيمة قابلة للعنونة m من النوع Mutex أو RWMutex في الحزمة القياسية sync، فإن نداء الطريقة m.Unlock() الناجح رقم n يحدث قبل إرجاع نداء الطريقة m.Lock() رقم (n+1).
  2. بالنسبة إلى قيمة قابلة للعنونة rw من النوع RWMutex، إذا كان نداء الطريقة rw.Lock() رقم n قد أُرجع، فإن نداء الطريقة rw.Unlock() الناجح رقم n يحدث قبل إرجاع أي نداء للطريقة rw.RLock() مضمون الحدوث بعد إرجاع نداء الطريقة rw.Lock() رقم n.
  3. بالنسبة إلى قيمة قابلة للعنونة rw من النوع RWMutex، إذا كان نداء الطريقة rw.RLock() رقم n قد أُرجع، فإن نداء الطريقة rw.RUnlock() الناجح رقم m، حيث m <= n، يحدث قبل إرجاع أي نداء للطريقة rw.Lock() مضمون الحدوث بعد إرجاع نداء الطريقة rw.RLock() رقم n.

في المثال التالي، تُضمَن الترتيبات التالية:

  • ينتهي تنفيذ a = 1 قبل تنفيذ b = 1. 1. ينتهي تنفيذ m = 1 قبل تنفيذ n = 1.
  1. ينتهي تنفيذ x = 1 قبل تنفيذ y = 1.

func fab() {
	var a, b int
	var l sync.Mutex // أو sync.RWMutex

	l.Lock()
	go func() {
		l.Lock()
		b = 1
		l.Unlock()
	}()
	go func() {
		a = 1
		l.Unlock()
	}()
}

func fmn() {
	var m, n int
	var l sync.RWMutex

	l.RLock()
	go func() {
		l.Lock()
		n = 1
		l.Unlock()
	}()
	go func() {
		m = 1
		l.RUnlock()
	}()
}

func fxy() {
	var x, y int
	var l sync.RWMutex

	l.Lock()
	go func() {
		l.RLock()
		y = 1
		l.RUnlock()
	}()
	go func() {
		x = 1
		l.Unlock()
	}()
}

لاحظ أنه في الشيفرة التالية، وفق وثائق Go الرسمية، لا يُضمَن انتهاء تنفيذ p = 1 قبل تنفيذ q = 1، مع أن معظم المصرِّفات تقدِّم هذه الضمانات.


var p, q int
func fpq() {
	var l sync.Mutex
	p = 1
	l.Lock()
	l.Unlock()
	q = 1
}

ضمانات الترتيب التي تقدِّمها قيم sync.WaitGroup

في لحظة معينة، افترض أن العداد الذي تحتفظ به قيمة قابلة للعنونة wg من النوع sync.WaitGroup ليس صفراً. إذا وُجدت مجموعة من نداءات الطريقة wg.Add(n) تُستدعى بعد تلك اللحظة، ويمكننا التأكد من أن آخر نداء مُرجَع فقط بين مجموعة النداءات هو الذي سيعدِّل العداد الذي تحتفظ به wg إلى الصفر، فإن كل نداء من مجموعة النداءات مضمون الحدوث قبل إرجاع نداء الطريقة wg.Wait المستدعى بعد تلك اللحظة.

لاحظ أن wg.Done() مكافئ لـ wg.Add(-1).

يرجى قراءة شروح النوع sync.WaitGroup لمعرفة كيفية استخدام قيم sync.WaitGroup.

ضمانات الترتيب التي تقدِّمها قيم sync.Once

يرجى قراءة شروح النوع sync.Once لمعرفة ضمانات الترتيب التي تقدِّمها قيم sync.Once وكيفية استخدام قيم sync.Once.

ضمانات الترتيب التي تقدِّمها قيم sync.Cond

من الصعب بعض الشيء تقديم وصف واضح لضمانات الترتيب التي تقدِّمها قيم sync.Cond. يرجى قراءة شروح النوع sync.Cond لمعرفة كيفية استخدام قيم sync.Cond.

ضمانات الترتيب المتعلقة بالعمليات الذرية

منذ Go 1.19، تنص وثائق نموذج ذاكرة Go 1 رسمياً على أن جميع العمليات الذرية المنفَّذة في برامج Go تتصرف كما لو نُفِّذت بترتيب متسق تسلسلياً ما. وإذا لاحظت عملية ذرية B أثر عملية ذرية A، فإن A تكون متزامنة قبل B.

وفقاً للأوصاف، في الشيفرة التالية، يُضمَن حدوث عملية الكتابة الذرية على المتغير b قبل عملية القراءة الذرية التي نتيجتها 1 على المتغير نفسه. وبالتالي، يُضمَن أيضاً حدوث عملية الكتابة على المتغير a قبل عملية القراءة على المتغير نفسه. لذا يُضمَن أن يطبع البرنامج التالي 2.


package main

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

func main() {
	var a, b int32 = 0, 0

	go func() {
		a = 2
		atomic.StoreInt32(&b, 1)
	}()

	for {
		if n := atomic.LoadInt32(&b); n == 1 {
			// يطبع السطر التالي دائماً 2.
			fmt.Println(a)
			break
		}
		runtime.Gosched()
	}
}

يرجى قراءة هذا المقال لمعرفة كيفية إجراء العمليات الذرية.

ضمانات الترتيب المتعلقة بالمنهيات (finalizers)

نداء runtime.SetFinalizer(x, f) يحدث قبل نداء الإنهاء f(x).