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

تخطيط الذاكرة

ستقدّم هذه المقالة ضمانات محاذاة الأنواع وأحجامها في Go. ومن الضروري معرفة هذه الضمانات لتقدير أحجام أنواع البنية والاستخدام الصحيح لدوال 64 بت في الحزمة القياسية sync/atomic.

Go لغة من عائلة C، لذا فإن كثيرًا من المفاهيم التي تتناولها هذه المقالة مشتركة مع لغة C.

ضمانات محاذاة الأنواع في Go

للانتفاع الكامل بتعليمات CPU والحصول على أفضل أداء، يجب أن تكون العناوين (البدائية) لكتل الذاكرة المخصّصة لقيم نوع محدَّد محاذاةً عند مضاعفات عدد صحيح N، وعندئذٍ تُسمّى N ضمان محاذاة عنوان القيمة لذلك النوع، أو ببساطة ضمان المحاذاة للنوع. ويمكننا أيضًا القول إن عناوين القيم القابلة للعنونة من ذلك النوع مضمونة بأن تكون محاذاة على N بايت.

في الواقع، لكل نوع ضمانا محاذاة: أحدهما عندما يُستخدم كنوع حقل في أنواع (بنى) أخرى، والآخر في الحالات الأخرى (عند استخدامه في تعريف متغيّر، أو كنوع عنصر مصفوفة، وما إلى ذلك). ونسمّي الأول ضمان محاذاة الحقل لذلك النوع، ونسمّي الثاني ضمان المحاذاة العام لذلك النوع.

بالنسبة إلى نوع T، يمكننا استدعاء unsafe.Alignof(t) للحصول على ضمان محاذاته العام، حيث t قيمة غير حقلية من النوع T، واستدعاء unsafe.Alignof(x.t) للحصول على ضمان محاذاة الحقل، حيث x قيمة بنية وt قيمة حقل من النوع T.

تُقيَّم الاستدعاءات إلى الدوال في الحزمة القياسية unsafe دائمًا في وقت التصريف.

في وقت التشغيل، بالنسبة إلى قيمة t من النوع T، يمكننا استدعاء reflect.TypeOf(t).Align() للحصول على ضمان المحاذاة العام للنوع T، واستدعاء reflect.TypeOf(t).FieldAlign() للحصول على ضمان محاذاة الحقل للنوع T.

بالنسبة إلى مصرِّف Go القياسي الحالي (v1.25.n)، فإن ضمان محاذاة الحقل وضمان المحاذاة العام لنوع ما متساويان دائمًا. أما بالنسبة إلى مصرِّف gccgo فهذه العبارة غير صحيحة.

لا تذكر مواصفة Go سوى القليل عن ضمانات محاذاة الأنواع:

خصائص المحاذاة الدنيا التالية مضمونة:

  1. بالنسبة إلى متغيّر x من أي نوع: قيمة unsafe.Alignof(x) لا تقل عن 1.
  2. بالنسبة إلى متغيّر x من نوع بنية: قيمة unsafe.Alignof(x) هي الأكبر بين جميع قيم unsafe.Alignof(x.f) لكل حقل f من x، لكن لا تقل عن 1.
  3. بالنسبة إلى متغيّر x من نوع مصفوفة: قيمة unsafe.Alignof(x) هي نفس محاذاة متغيّر من نوع عنصر المصفوفة.

إذن، لا تحدد مواصفة Go ضمانات المحاذاة الدقيقة لأي نوع، بل تحدد فقط بعض المتطلبات الدنيا.

بالنسبة إلى المصرِّف نفسه، قد تختلف ضمانات محاذاة الأنواع الدقيقة بين معمارية وأخرى وبين إصدار وآخر من المصرِّف. وبالنسبة إلى الإصدار الحالي (v1.25.n) من مصرِّف Go القياسي، فإن ضمانات المحاذاة مدرجة هنا.


النوع                     ضمان المحاذاة
------                    ------
bool, uint8, int8         1
uint16, int16             2
uint32, int32             4
float32, complex64        4
المصفوفات                 تعتمد على أنواع العناصر
البنى                     تعتمد على أنواع الحقول
أنواع أخرى                حجم كلمة أصلية

هنا، حجم الكلمة الأصلية (أو كلمة الآلة) هو 4 بايت على معماريات 32 بت و8 بايت على معماريات 64 بت.

هذا يعني أنه بالنسبة إلى الإصدار الحالي من مصرِّف Go القياسي، قد يكون ضمان المحاذاة للأنواع الأخرى إما 4 أو 8، حسب معماريات هدف البناء. وهذا صحيح أيضًا بالنسبة إلى gccgo.

عمومًا، لا نحتاج إلى الاهتمام بمحاذاة عناوين القيم في برمجة Go، إلا إذا أردنا تحسين استهلاك الذاكرة، أو كتابة برامج محمولة تستخدم دوال 64 بت من حزمة sync/atomic. يُرجى قراءة القسمين التاليين للتفاصيل.

أحجام الأنواع وحشو البنية

لا تقدّم مواصفة Go سوى ضمانات حجم النوع التالية:


النوع                   الحجم بالبايتات
------                  ------
uint8, int8             1
uint16, int16           2
uint32, int32, float32  4
uint64, int64           8
float64, complex64      8
complex128              16
uint, int               خاص بالتنفيذ،
                        وعادةً 4 على معماريات
                        32 بت، و8 على
                        معماريات 64 بت.
uintptr                 خاص بالتنفيذ،
                        وكبير بما يكفي لتخزين
                        البتات غير المفسَّرة
                        لقيمة مؤشر.

لا تقدّم مواصفة Go ضمانات لحجم القيم بالنسبة إلى أنواع أخرى. والقائمة الكاملة لأحجام الأنواع المختلفة التي يحددها مصرِّف Go القياسي مدرجة في تكاليف نسخ القيم.

يضمن مصرِّف Go القياسي (وgccgo) أن يكون حجم قيم نوع ما مضاعفًا لضمان محاذاة ذلك النوع.

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

فيما يلي مثال يوضّح كيف تُحشى البايتات بين حقول البنية. لقد تعلمنا بالفعل أن

  • ضمان المحاذاة وحجم النوع المدمج int8 كلاهما بايت واحد.
  • ضمان المحاذاة وحجم النوع المدمج int16 كلاهما بايتان.
  • حجم النوع المدمج int64 هو 8 بايت، وضمان محاذاة النوع int64 هو 4 بايت على معماريات 32 بت و8 بايت على معماريات 64 بت.
  • ضمانا المحاذاة للنوعين T1 وT2 هما أكبر ضماني محاذاة لحقول كل منهما، أي ضمان محاذاة الحقل int64. لذا فإن ضماني محاذاتهما كلاهما 4 بايت على معماريات 32 بت و8 بايت على معماريات 64 بت.
  • يجب أن يكون حجم النوعين T1 وT2 مضاعفًا لضماني محاذاتهما، أي 4N بايت على معماريات 32 بت و8N بايت على معماريات 64 بت.

type T1 struct {
	a int8

	// على معماريات 64 بت، وبهدف جعل الحقل b
	// محاذى على 8 بايت، يلزم حشو 7 بايت
	// هنا. وعلى معماريات 32 بت، وبهدف جعل
	// الحقل b محاذى على 4 بايت، يلزم حشو
	// 3 بايت هنا.

	b int64
	c int16

	// لجعل حجم النوع T1 مضاعفًا لضمان
	// محاذاة T1، على معماريات 64 بت
	// يلزم حشو 6 بايت هنا، وعلى معماريات
	// 32 بت يلزم حشو 2 بايت هنا.
}
// حجم T1 هو 24 (= 1 + 7 + 8 + 2 + 6)
// بايت على معماريات 64 بت وهو 16
// (= 1 + 3 + 8 + 2 + 2) على معماريات 32 بت.

type T2 struct {
	a int8

	// لجعل الحقل c محاذى على 2 بايت، يلزم
	// حشو بايت واحد هنا على كلٍّ من معماريات
	// 64 بت و32 بت.

	c int16

	// على معماريات 64 بت، وبهدف جعل الحقل b
	// محاذى على 8 بايت، يلزم حشو 4 بايت
	// هنا. وعلى معماريات 32 بت، فإن الحقل b
	// محاذى على 4 بايت بالفعل، لذا لا يلزم
	// حشو أي بايتات هنا.

	b int64
}
// حجم T2 هو 16 (= 1 + 1 + 2 + 4 + 8)
// بايت على معماريات 64 بت، وهو 12
// (= 1 + 1 + 2 + 8) على معماريات 32 بت.

على الرغم من أن T1 وT2 لهما المجموعة نفسها من الحقول، فإن حجميهما مختلفان.

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

شرط المحاذاة للعمليات الذرية على الكلمات 64 بت

الكلمات 64 بت تعني قيم الأنواع التي يكون نوعها الأساسي int64 أو uint64.

تذكر مقالة العمليات الذرية حقيقة مفادها أن العمليات الذرية (atomic operations) ذات 64 بت على كلمة 64 بت تتطلب أن يكون عنوان الكلمة 64 بت محاذى على 8 بايت. وهذه ليست مشكلة بالنسبة إلى معماريات 64 بت الحالية التي يدعمها مصرِّف Go القياسي، لأن الكلمات 64 بت على معماريات 64 بت هذه محاذاة دائمًا على 8 بايت.

ومع ذلك، على معماريات 32 بت، فإن ضمان المحاذاة الذي يقدّمه مصرِّف Go القياسي للكلمات 64 بت هو 4 بايت فقط. والعمليات الذرية ذات 64 بت على كلمة 64 بت غير محاذاة على 8 بايت ستؤدي إلى panic في وقت التشغيل. والأسوأ من ذلك أن دوال 64 بت الذرية غير مدعومة على معماريات CPU القديمة جدًا.

في نهاية وثائق sync/atomic، يُذكر:

على x86-32، تستخدم دوال 64 بت تعليمات غير متوفرة قبل Pentium MMX. على ARM غير Linux، تستخدم دوال 64 بت تعليمات غير متوفرة قبل نواة ARMv6k. على كلٍّ من ARM وx86-32، تقع على عاتق المستدعي مسؤولية ترتيب محاذاة 64 بت للكلمات 64 بت التي يُوصل إليها ذريًّا. ويمكن الاعتماد على أن الكلمة الأولى في متغيّر أو في بنية أو مصفوفة أو شريحة مخصّصة محاذاة على 64 بت.

إذن، الأمور ليست سيئة للغاية لسببين.

  1. معماريات CPU القديمة جدًا ليست معماريات سائدة في الوقت الحاضر. وإذا احتاج برنامج إلى إجراء مزامنة للكلمات 64 بت على هذه المعماريات، فهناك تقنيات مزامنة أخرى للإنقاذ.
  2. على معماريات 32 بت الأخرى غير القديمة جدًا، هناك بعض الطرق لضمان إمكانية الاعتماد على أن بعض الكلمات 64 بت محاذاة على 64 بت.

تُوصَف هذه الطرق بأنه يمكن الاعتماد على أن الكلمة الأولى (64 بت) في متغيّر أو في بنية أو مصفوفة أو شريحة مخصّصة محاذاة على 64 بت. فماذا تعني كلمة «مخصّص» (allocated)؟ يمكننا اعتبار القيمة المخصّصة متغيّرًا معرَّفًا، أو قيمة تُرجعها الدالة المدمجة make، أو القيمة التي يشير إليها مؤشر تُرجعه الدالة المدمجة new. وإذا اشتُقّت قيمة شريحة من مصفوفة مخصّصة وكان العنصر الأول في الشريحة هو العنصر الأول في المصفوفة، فيمكن عندئذٍ اعتبار قيمة الشريحة أيضًا قيمة مخصّصة.

الوصف المتعلق بالكلمات 64 بت التي يمكن الاعتماد على أنها محاذاة على 64 بت على معماريات 32 بت متحفّظ بعض الشيء. فهناك المزيد من الكلمات 64 بت التي يمكن الاعتماد على أنها محاذاة على 8 بايت. في الواقع، إذا أمكن الاعتماد على أن العنصر الأول في مصفوفة أو شريحة نوع عنصرها نوع كلمة 64 بت محاذى على 64 بت، فإن جميع العناصر في المصفوفة/الشريحة يمكن أيضًا الوصول إليها ذريًّا. لكن صياغة وصف بسيط وواضح يشمل جميع الكلمات 64 بت التي يمكن الاعتماد على أنها محاذاة على 64 بت على معماريات 32 بت أمر دقيق ومطوَّل بعض الشيء، لذا تكتفي الوثائق الرسمية بوصف متحفّظ.

فيما يلي مثال يسرد بعض الكلمات 64 بت التي يكون الوصول إليها ذريًّا آمنًا أو غير آمن على كلٍّ من معماريات 64 بت و32 بت.


type (
	T1 struct {
		v uint64
	}

	T2 struct {
		_ int16
		x T1
		y *T1
	}

	T3 struct {
		_ int16
		x [6]int64
		y *[6]int64
	}
)

var a int64    // a آمن
var b T1       // b.v آمن
var c [6]int64 // c[0] آمن

var d T2 // d.x.v غير آمن
var e T3 // e.x[0] غير آمن

func f() {
	var f int64           // f آمن
	var g = []int64{5: 0} // g[0] آمن

	var h = e.x[:] // h[0] غير آمن

	// هنا، كلٌّ من d.y.v وe.y[0] آمن،
	// لأن *d.y و*e.y مخصّصان.
	d.y = new(T1)
	e.y = &[6]int64{}

	_, _, _ = f, g, h
}

// في الواقع، جميع العناصر في c وg وe.y.v
// آمنة للوصول إليها ذريًّا، رغم أن وثائق Go
// الرسمية لا تقدّم هذه الضمانات أبدًا.

إذا كان سيُوصل إلى حقل كلمة 64 بت (عادةً الأول) في نوع بنية ذريًّا في الشيفرة، فينبغي أن نستخدم دائمًا قيمًا مخصّصة من نوع البنية لضمان إمكانية الاعتماد دائمًا على أن الحقول التي يُوصل إليها ذريًّا محاذاة على 8 بايت على معماريات 32 بت. وعندما يُستخدم نوع البنية هذا كنوع حقل في نوع بنية آخر، ينبغي ترتيب الحقل ليكون الحقل الأول في نوع البنية الآخر، واستخدام قيم مخصّصة من النوع الآخر دائمًا.

أحيانًا، إذا لم نستطع التأكد مما إذا كان يمكن الوصول إلى كلمة 64 بت ذريًّا أم لا، يمكننا استخدام قيمة من النوع [15]byte لتحديد عنوان الكلمة 64 بت في وقت التشغيل. على سبيل المثال،


package mylib

import (
	"unsafe"
	"sync/atomic"
)

type Counter struct {
	x [15]byte // بدلًا من "x uint64"
}

func (c *Counter) xAddr() *uint64 {
	// يجب أن تكون القيمة المُرجَعة محاذاة على 8 بايت.
	return (*uint64)(unsafe.Pointer(
		(uintptr(unsafe.Pointer(&c.x)) + 7)/8*8))
}

func (c *Counter) Add(delta uint64) {
	p := c.xAddr()
	atomic.AddUint64(p, delta)
}

func (c *Counter) Value() uint64 {
	return atomic.LoadUint64(c.xAddr())
}

باستخدام هذا الحل، يمكن تضمين النوع Counter في أنواع أخرى يحددها المستخدم بحرية وأمان، حتى على معماريات 32 بت. والعيب في هذا الحل هو إهدار سبعة بايتات لكل قيمة من النوع Counter واستخدامه مؤشرات unsafe.

قدّم Go 1.19 طريقة أكثر أناقة لضمان محاذاة 8 بايت لبعض القيم. فقد أضاف Go 1.19 عدة أنواع ذرية في الحزمة القياسية sync/atomic. وتشمل هذه الأنواع atomic.Int64 وatomic.Uint64، وهما نوعان مضمون أن قيمهما محاذاة على 8 بايت، حتى على معماريات 32 بت. ويمكننا الاستفادة من هذه الحقيقة لجعل بعض الكلمات 64 بت محاذاة دائمًا على 8 بايت على معماريات 32 بت. على سبيل المثال، في الشيفرة التالية، يكون الحقل x في أي قيمة من النوع T محاذى دائمًا على 8 بايت، في جميع الحالات، سواء على معماريات 64 بت أو 32 بت.


type T struct {
	_ [0]atomic.Int64
	x int64
}