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

تكاليف نسخ القيم في Go

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

أحجام القيم

يعني حجم القيمة عدد البايتات التي سيشغلها الجزء المباشر من القيمة في الذاكرة. ولا تساهم الأجزاء الكامنة غير المباشرة للقيمة في حجم القيمة.

في Go، إذا كان نوعا قيمتين ينتميان إلى الصنف نفسه، ولم يكن صنف النوع هو صنف النص ولا صنف الواجهة ولا صنف المصفوفة ولا صنف البنية، فإن حجمي القيمتين متساويان دائماً.

في الواقع، وبالنسبة للمصرِّف/وقت التشغيل القياسي لـ Go، فإن حجمي أي قيمتين نصيتين متساويان دائماً أيضاً. وتنطبق العلاقة نفسها على حجمي أي قيمتين من نوع الواجهة.

حتى الآن (Go Toolchain 1.25.n)، بالنسبة للمصرِّف القياسي لـ Go (وgccgo)، فإن قيم نوع محدَّد لها دائماً حجم القيمة نفسه. لذا غالباً ما نسمّي حجم القيمة حجم نوع القيمة.

يعتمد حجم نوع المصفوفة على حجم نوع العنصر وطول نوع المصفوفة. وحجم نوع المصفوفة هو حاصل ضرب حجم نوع عنصر المصفوفة في طول المصفوفة.

يعتمد حجم نوع البنية على جميع أحجام حقوله وترتيبها. ولأنه قد تُدرَج بعض بايتات الحشو بين حقلين متجاورين من حقول البنية لضمان متطلبات محاذاة معيّنة لعناوين ذاكرة هذه الحقول، وجب ألا يكون حجم نوع البنية أصغر من مجموع أحجام الأنواع المقابلة لحقوله (وغالباً ما يكون أكبر منه).

يسرد الجدول التالي أحجام قيم جميع أصناف الأنواع (بالنسبة للمصرِّف القياسي لـ Go الإصدار 1.25.n). وفي الجدول، تعني الكلمة الواحدة كلمة أصلية واحدة، وهي 4 بايتات على معماريات 32 بت و8 بايتات على معماريات 64 بت.

أصناف الأنواع حجم القيمة ما تتطلبه مواصفة Go
bool بايت واحد غير محدَّد
int8, uint8 (byte) بايت واحد بايت واحد
int16, uint16 بايتان بايتان
int32 (rune), uint32, float32 4 بايتات 4 بايتات
int64, uint64, float64, complex64 8 بايتات 8 بايتات
complex128 16 بايتاً 16 بايتاً
int, uint كلمة واحدة يعتمد على المعمارية، 4 بايتات على معماريات 32 بت و8 بايتات على معماريات 64 بت
uintptr كلمة واحدة كبيرة بما يكفي لتخزين البتات غير المفسَّرة لقيمة مؤشر
string كلمتان غير محدَّد
pointer (safe or unsafe) كلمة واحدة غير محدَّد
slice 3 كلمات غير محدَّد
map كلمة واحدة غير محدَّد
channel كلمة واحدة غير محدَّد
function كلمة واحدة غير محدَّد
interface كلمتان غير محدَّد
struct (مجموع أحجام جميع الحقول) + (عدد بايتات الحشو) حجم نوع البنية صفر إذا لم يحتوِ على أي حقول حجمها أكبر من صفر
array (حجم قيمة العنصر) * (طول المصفوفة) حجم نوع المصفوفة صفر إذا كان نوع عنصره صفر الحجم

تكاليف نسخ القيم

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

عملياً، يمكننا اعتبار قيم البنى التي لها أقل من 5 حقول وأحجام لا تتجاوز أربع كلمات أصلية قيماً صغيرة الحجم. وتكاليف نسخ القيم صغيرة الحجم ضئيلة.

وبالنسبة للمصرِّف القياسي لـ Go، فباستثناء قيم أنواع البنى والمصفوفات كبيرة الحجم، فإن الأنواع الأخرى في Go كلها أنواع صغيرة الحجم.

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

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

عموماً، نادراً ما نستخدم عملياً أنواع المؤشرات التي تكون أنواعها الأساسية أنواع شرائح أو أنواع خرائط أو أنواع قنوات أو أنواع دوال أو أنواع نصوص أو أنواع واجهات. فتكاليف نسخ قيم هذه الأنواع الأساسية المفترضة ضئيلة جداً.

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

في ما يلي مثال يقيس أداء طرق مختلفة لتكرار عناصر شريحة.


package main

import "testing"

type S [12]int64
var sX = make([]S, 1000)
var sY = make([]S, 1000)
var sZ = make([]S, 1000)
var sumX, sumY, sumZ int64

func Benchmark_Loop(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumX = 0
		for j := 0; j < len(sX); j++ {
			sumX += sX[j][0]
		}
	}
}

func Benchmark_Range_OneIterVar(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumY = 0
		for j := range sY {
			sumY += sY[j][0]
		}
	}
}

func Benchmark_Range_TwoIterVar(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumZ = 0
		for _, v := range sZ {
			sumZ += v[0]
		}
	}
}

عند تشغيل قياسات الأداء في دليل ملف الاختبار، سنحصل على نتيجة مشابهة لما يلي:


Benchmark_Loop-4             424342 2708 ns/op
Benchmark_Range_OneIterVar-4 407905 2808 ns/op
Benchmark_Range_TwoIterVar-4 214860 3915 ns/op

يمكننا أن نجد أن كفاءة الصيغة ذات متغيري التكرار أقل بكثير من الصيغتين الأخريين. لكن لاحظ أن بعض المصرِّفات قد تُجري تحسينات خاصة لإزالة فروق الأداء بين هذه الصيغ. وتستند نتيجة قياس الأداء أعلاه إلى المصرِّف القياسي لـ Go الإصدار 1.25.n.