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

العمليات الذرية الموفَّرة في الحزمة القياسية sync/atomic

العمليات الذرية (atomic operations) أكثر بدائيةً من تقنيات التزامن الأخرى. فهي بلا أقفال، وتُنفَّذ عادةً مباشرةً على مستوى العتاد. بل إنها تُستخدم غالباً في تنفيذ تقنيات التزامن الأخرى.

لاحظ أن كثيراً من الأمثلة أدناه ليست برامج متزامنة. إنها لأغراض العرض والشرح فقط، لبيان كيفية استخدام الدوال الذرية الموفَّرة في الحزمة القياسية sync/atomic.

نظرة عامة على العمليات الذرية الموفَّرة قبل Go 1.19-

توفّر الحزمة القياسية sync/atomic الدوال الذرية الخمس التالية لنوع صحيح T، حيث يجب أن يكون T أحد الأنواع int32 وint64 وuint32 وuint64 وuintptr.


func AddT(addr *T, delta T)(new T)
func LoadT(addr *T) (val T)
func StoreT(addr *T, val T)
func SwapT(addr *T, new T) (old T)
func CompareAndSwapT(addr *T, old, new T) (swapped bool)

على سبيل المثال، تُوفَّر الدوال الخمس التالية للنوع int32.


func AddInt32(addr *int32, delta int32)(new int32)
func LoadInt32(addr *int32) (val int32)
func StoreInt32(addr *int32, val int32)
func SwapInt32(addr *int32, new int32) (old int32)
func CompareAndSwapInt32(addr *int32,
				old, new int32) (swapped bool)

تُوفَّر الدوال الذرية الأربع التالية لأنواع المؤشرات (الآمنة). وعندما أُدخلت هذه الدوال إلى المكتبة القياسية، لم تكن Go تدعم الأجناس العامة المخصّصة، لذا نُفِّذت هذه الدوال عبر نوع المؤشر غير الآمن unsafe.Pointer (نظير void* في C).


func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer,
				) (old unsafe.Pointer)
func CompareAndSwapPointer(addr *unsafe.Pointer,
				old, new unsafe.Pointer) (swapped bool)

لا توجد دالة AddPointer للمؤشرات، لأن مؤشرات Go (الآمنة) لا تدعم العمليات الحسابية.

توفّر الحزمة القياسية sync/atomic أيضاً نوعاً هو Value، ولنوع المؤشر المقابل له *Value أربع طرق (مدرجة أدناه، وقد أُدخلت الأخيرتان في Go 1.17). يمكننا استخدام هذه الطرق لإجراء عمليات ذرية على قيم من أي نوع.


func (*Value) Load() (x interface{})
func (*Value) Store(x interface{})
func (*Value) Swap(new interface{}) (old interface{})
func (*Value) CompareAndSwap(old, new interface{}) (swapped bool)

نظرة عامة على العمليات الذرية الجديدة الموفَّرة منذ Go 1.19

أدخلت Go 1.19 عدة أنواع، يملك كل منها مجموعة من طرق العمليات الذرية، لتحقيق الآثار نفسها التي تحققها الدوال على مستوى الحزمة المذكورة في القسم السابق.

من بين هذه الأنواع، خُصِّصت Int32 وInt64 وUint32 وUint64 وUintptr للعمليات الذرية على الأعداد الصحيحة. وطرق النوع atomic.Int32 مدرجة أدناه. أما طرق الأنواع الأربعة الأخرى فتأتي على النحو المشابه.


func (*Int32) Add(delta int32) (new int32)
func (*Int32) Load() int32
func (*Int32) Store(val int32)
func (*Int32) Swap(new int32) (old int32)
func (*Int32) CompareAndSwap(old, new int32) (swapped bool)

منذ Go 1.18، أصبحت Go تدعم الأجناس العامة المخصّصة. وقد بدأت بعض الحزم القياسية تتبنى الأجناس العامة المخصّصة منذ Go 1.19. وحزمة sync/atomic إحدى هذه الحزم. والنوع Pointer[T any] الذي أدخلته Go 1.19 في هذه الحزمة هو نوع عام. وطرقه مدرجة أدناه.


(*Pointer[T]) Load() *T
(*Pointer[T]) Store(val *T)
(*Pointer[T]) Swap(new *T) (old *T)
(*Pointer[T]) CompareAndSwap(old, new *T) (swapped bool)

أدخلت Go 1.19 أيضاً نوع Bool لإجراء عمليات ذرية منطقية.

العمليات الذرية على الأعداد الصحيحة

يعرض ما تبقى من هذا المقال بعض الأمثلة على كيفية استخدام العمليات الذرية الموفَّرة في Go.

يعرض المثال التالي كيفية إجراء العملية الذرية Add على قيمة من النوع int32 باستخدام الدالة AddInt32. في هذا المثال، تُنشئ goroutine الرئيسية 1000 goroutine متزامنة جديدة. وتزيد كل goroutine جديدة منشأة العدد الصحيح n بمقدار واحد. وتضمن العمليات الذرية عدم وجود تسابقات على البيانات بين هذه الـ goroutines. وفي النهاية، يُضمن طباعة 1000.


package main

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

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

	fmt.Println(atomic.LoadInt32(&n)) // 1000
}

إذا استُبدلت الجملة atomic.AddInt32(&n, 1) بـ n++، فقد لا يكون الخرج 1000.

تعيد الشيفرة التالية تنفيذ البرنامج أعلاه باستخدام النوع atomic.Int32 وطرقه (منذ Go 1.19). وتبدو هذه الشيفرة أكثر ترتيباً بقليل.


package main

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

func main() {
	var n atomic.Int32
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			n.Add(1)
			wg.Done()
		}()
	}
	wg.Wait()

	fmt.Println(n.Load()) // 1000
}

غالباً ما تُستخدم الدوال/الطرق الذرية StoreT وLoadT لتنفيذ طرق الضبط (setter) والجلب (getter) للنوع (أو لنوع المؤشر المقابل له) إذا احتُاج إلى استخدام قيم هذا النوع بشكل متزامن. مثلاً، نسخة الدوال:


type Page struct {
	views uint32
}

func (page *Page) SetViews(n uint32) {
	atomic.StoreUint32(&page.views, n)
}

func (page *Page) Views() uint32 {
	return atomic.LoadUint32(&page.views)
}

ونسخة النوع+الطرق (منذ Go 1.19):


type Page struct {
	views atomic.Uint32
}

func (page *Page) SetViews(n uint32) {
	page.views.Store(n)
}

func (page *Page) Views() uint32 {
	return page.views.Load()
}

بالنسبة إلى نوع صحيح مُوقَّع T (int32 أو int64)، يمكن أن يكون المعطى الثاني للنداء إلى الدالة AddT قيمة سالبة، لإجراء عملية إنقاص ذرية. لكن كيف نُجري عمليات إنقاص ذرية لقيم نوع غير مُوقَّع T، مثل uint32 وuint64 وuintptr؟ هناك حالتان للمعطيات الثانية غير المُوقَّعة.

  1. بالنسبة إلى متغير غير مُوقَّع v من النوع T، يُعد -v قانونياً في Go. لذا يمكننا ببساطة تمرير -v كمعطى ثانٍ لنداء AddT.
  2. بالنسبة إلى عدد صحيح ثابت موجب c، يُعد استخدام -c غير قانوني كمعطى ثانٍ لنداء AddT (حيث يشير T إلى نوع صحيح غير مُوقَّع). ويمكننا استخدام ^T(c-1) كمعطى ثانٍ بدلاً من ذلك.

تعمل حيلة ^T(v-1) هذه أيضاً مع متغير غير مُوقَّع v، لكن ^T(v-1) أقل كفاءة من T(-v).

في الحيلة ^T(c-1)، إذا كانت c قيمة ذات نوع وكان نوعها هو T تحديداً، فيمكن اختصار الصيغة إلى ^(c-1).

مثال:


package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var (
		n uint64 = 97
		m uint64 = 1
		k int    = 2
	)
	const (
		a        = 3
		b uint64 = 4
		c uint32 = 5
		d int    = 6
	)

	show := fmt.Println
	atomic.AddUint64(&n, -m)
	show(n) // 96 (97 - 1)
	atomic.AddUint64(&n, -uint64(k))
	show(n) // 94 (96 - 2)
	atomic.AddUint64(&n, ^uint64(a - 1))
	show(n) // 91 (94 - 3)
	atomic.AddUint64(&n, ^(b - 1))
	show(n) // 87 (91 - 4)
	atomic.AddUint64(&n, ^uint64(c - 1))
	show(n) // 82 (87 - 5)
	atomic.AddUint64(&n, ^uint64(d - 1))
	show(n) // 76 (82 - 6)
	x := b; atomic.AddUint64(&n, -x)
	show(n) // 72 (76 - 4)
	atomic.AddUint64(&n, ^(m - 1))
	show(n) // 71 (72 - 1)
	atomic.AddUint64(&n, ^uint64(k - 1))
	show(n) // 69 (71 - 2)
}

يشبه نداء الدالة SwapT نداء الدالة StoreT، لكنه يعيد القيمة القديمة.

لا يطبّق نداء الدالة CompareAndSwapT عملية التخزين إلا عندما تطابق القيمة الحالية القيمة القديمة الممرَّرة. وتشير نتيجة الإرجاع المنطقية (bool) لنداء الدالة CompareAndSwapT إلى ما إذا كانت عملية التخزين قد طُبِّقت أم لا.

مثال:


package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var n int64 = 123
	var old = atomic.SwapInt64(&n, 789)
	fmt.Println(n, old) // 789 123
	swapped := atomic.CompareAndSwapInt64(&n, 123, 456)
	fmt.Println(swapped) // false
	fmt.Println(n)       // 789
	swapped = atomic.CompareAndSwapInt64(&n, 789, 456)
	fmt.Println(swapped) // true
	fmt.Println(n)       // 456
}

فيما يلي نسخة النوع+الطرق المقابلة (منذ Go 1.19):


package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var n atomic.Int64
	n.Store(123)
	var old = n.Swap(789)
	fmt.Println(n.Load(), old) // 789 123
	swapped := n.CompareAndSwap(123, 456)
	fmt.Println(swapped)  // false
	fmt.Println(n.Load()) // 789
	swapped = n.CompareAndSwap(789, 456)
	fmt.Println(swapped)  // true
	fmt.Println(n.Load()) // 456
}

لاحظ أنه حتى الآن (Go 1.25)، تتطلب العمليات الذرية على الكلمات ذات 64 بت، أي قيم int64 وuint64، أن تكون الكلمات ذات 64 بت محاذاة على 8 بايت في الذاكرة. وبالنسبة إلى عمليات الطرق الذرية التي أدخلتها Go 1.19، فإن هذا الشرط متحقق دائماً، سواء على المعماريات 32 بت أو 64 بت، لكن هذا ليس صحيحاً بالنسبة إلى عمليات الدوال الذرية على المعماريات 32 بت. يُرجى قراءة تخطيط الذاكرة للتفاصيل.

العمليات الذرية على المؤشرات

ذُكر أعلاه أن الحزمة القياسية sync/atomic توفّر أربع دوال لإجراء عمليات ذرية على المؤشرات، بمساعدة المؤشرات غير الآمنة.

من مقالة المؤشرات غير الآمنة نوعياً، نتعلم أنه في Go يمكن تحويل قيم أي نوع مؤشر صراحةً إلى unsafe.Pointer والعكس بالعكس. لذا يمكن أيضاً تحويل قيم النوع *unsafe.Pointer صراحةً إلى unsafe.Pointer والعكس بالعكس.

المثال التالي ليس برنامجاً متزامناً. إنه يوضح فقط كيفية إجراء عمليات ذرية على المؤشرات. في هذا المثال، يمكن أن يكون T أي نوع.


package main

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

type T struct {x int}

func main() {
	var pT *T
	var unsafePPT = (*unsafe.Pointer)(unsafe.Pointer(&pT))
	var ta, tb = T{1}, T{2}
	// تخزين
	atomic.StorePointer(
		unsafePPT, unsafe.Pointer(&ta))
	fmt.Println(pT) // &{1}
	// تحميل
	pa1 := (*T)(atomic.LoadPointer(unsafePPT))
	fmt.Println(pa1 == &ta) // true
	// مبادلة
	pa2 := atomic.SwapPointer(
		unsafePPT, unsafe.Pointer(&tb))
	fmt.Println((*T)(pa2) == &ta) // true
	fmt.Println(pT) // &{2}
	// مقارنة ومبادلة
	b := atomic.CompareAndSwapPointer(
		unsafePPT, pa2, unsafe.Pointer(&tb))
	fmt.Println(b) // false
	b = atomic.CompareAndSwapPointer(
		unsafePPT, unsafe.Pointer(&tb), pa2)
	fmt.Println(b) // true
}

أجل، استخدام دوال المؤشرات الذرية مطوَّل جداً. بل إن هذه الاستخدامات ليست مطوَّلة فحسب، وإنما غير محمية أيضاً بـ إرشادات توافق Go 1، لأنها تتطلب استيراد الحزمة القياسية unsafe.

وعلى العكس، ستكون الشيفرة أبسط وأنظف بكثير إذا استخدمنا النوع العام Pointer الذي أدخلته Go 1.19 وطرقه لإجراء عمليات ذرية على المؤشرات، كما تُظهر الشيفرة التالية.


package main

import (
	"fmt"
	"sync/atomic"
)

type T struct {x int}

func main() {
	var pT atomic.Pointer[T]
	var ta, tb = T{1}, T{2}
	// تخزين
	pT.Store(&ta)
	fmt.Println(pT.Load()) // &{1}
	// تحميل
	pa1 := pT.Load()
	fmt.Println(pa1 == &ta) // true
	// مبادلة
	pa2 := pT.Swap(&tb)
	fmt.Println(pa2 == &ta) // true
	fmt.Println(pT.Load())  // &{2}
	// مقارنة ومبادلة
	b := pT.CompareAndSwap(&ta, &tb)
	fmt.Println(b) // false
	b = pT.CompareAndSwap(&tb, &ta)
	fmt.Println(b) // true
}

والأهم من ذلك، أن التنفيذ باستخدام النوع العام Pointer محمي بإرشادات توافق Go 1.

العمليات الذرية على قيم من أي نوع

يمكن استخدام النوع Value الموفَّر في الحزمة القياسية sync/atomic لتحميل وتخزين قيم من أي نوع بشكل ذري.

للنوع *Value عدة طرق: Load وStore وSwap وCompareAndSwap (والأخيرتان أُدخلتا في Go 1.17). أنواع الوسائط الداخلة لهذه الطرق كلها interface{}. لذا يمكن تمرير أي قيمة إلى النداءات على هذه الطرق. لكن بالنسبة إلى قيمة Value قابلة للعنونة v، بمجرد أن يُستدعى v.Store() (اختصار لـ (&v).Store()) مرة واحدة، فيجب أن تأخذ نداءات الطرق اللاحقة على القيمة v قيماً بنفس النوع الملموس الذي كان لمعطى أول نداء v.Store()، وإلا فسيحدث panic. كما أن معطى واجهة nil سيجعل نداء v.Store() يُحدث panic.

مثال:


package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	type T struct {a, b, c int}
	var ta = T{1, 2, 3}
	var v atomic.Value
	v.Store(ta)
	var tb = v.Load().(T)
	fmt.Println(tb)       // {1 2 3}
	fmt.Println(ta == tb) // true

	v.Store("hello") // سيؤدي إلى panic
}

مثال آخر (لـ Go 1.17+):


package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	type T struct {a, b, c int}
	var x = T{1, 2, 3}
	var y = T{4, 5, 6}
	var z = T{7, 8, 9}
	var v atomic.Value
	v.Store(x)
	fmt.Println(v) // {{1 2 3}}
	old := v.Swap(y)
	fmt.Println(v)       // {{4 5 6}}
	fmt.Println(old.(T)) // {1 2 3}
	swapped := v.CompareAndSwap(x, z)
	fmt.Println(swapped, v) // false {{4 5 6}}
	swapped = v.CompareAndSwap(y, z)
	fmt.Println(swapped, v) // true {{7 8 9}}
}

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

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

يُرجى قراءة نموذج الذاكرة في Go للتفاصيل.