كتل الذاكرة
تُسمّى وحدات تخصيص الذاكرة الأساسية كتل الذاكرة. وكتلة الذاكرة مقطع ذاكرة متصل. وكما ذُكر سابقاً، يُستضاف جزء القيمة في وقت التشغيل على كتلة ذاكرة واحدة.
وقد تستضيف كتلة ذاكرة واحدة عدة أجزاء قيمة. ويجب ألا يكون حجم كتلة الذاكرة أصغر من حجم أي جزء قيمة تستضيفه.
وعندما تستضيف كتلة ذاكرة جزء قيمة، يمكننا القول إن جزء القيمة يشير إلى كتلة الذاكرة.
تستهلك عمليات تخصيص الذاكرة بعض موارد CPU للعثور على كتل الذاكرة المناسبة. لذا كلما أُنشئت كتل ذاكرة أكثر (من أجل تخصيصات الذاكرة)، استُهلكت موارد CPU أكثر. وفي البرمجة، ينبغي أن نحاول تجنّب تخصيصات الذاكرة غير الضرورية للحصول على أداء أفضل لتنفيذ الشيفرة.
أماكن تخصيص الذاكرة
قد يعثر وقت تشغيل Go على كتلة ذاكرة على المكدس (وهو أحد نوعي مناطق الذاكرة) الخاص بـ goroutine أو على الكومة (وهي النوع الآخر من مناطق الذاكرة) الخاصة بالبرنامج بأكمله لاستضافة بعض أجزاء القيمة. وتُسمى عملية العثور هذه تخصيصاً (للذاكرة).
تختلف أساليب إدارة الذاكرة في المكدس والكومة اختلافاً كبيراً. وفي معظم الحالات، يكون العثور على كتلة ذاكرة على المكدس أرخص بكثير من العثور عليها على الكومة.
كما أن جمع كتل ذاكرة المكدس أرخص بكثير من جمع كتل ذاكرة الكومة. في الواقع، لا تحتاج كتل ذاكرة المكدس إلى الجمع. ويمكن اعتبار مكدس goroutine في الواقع كتلة ذاكرة واحدة، وسيُجمع كاملاً عند خروج goroutine.
ومن ناحية أخرى، عندما لا تبقى جميع أجزاء القيمة المستضافة على كتلة ذاكرة على الكومة (أو بواسطتها) قيد الاستخدام (بعبارة أخرى، لم يعد أي جزء قيمة حيّ يشير إلى كتلة الذاكرة)، ستُعتبر كتلة الذاكرة نفايات وستُجمع تلقائياً في النهاية، خلال دورات جمع النفايات في وقت التشغيل، وهو ما قد يستهلك موارد CPU معينة (سيُشرح جمع النفايات بالتفصيل في فصل لاحق). وعموماً، كلما زادت كتل الذاكرة المخصَّصة على الكومة، زاد الضغط على جمع النفايات.
ولأن تخصيصات الكومة أغلى بكثير، فإن تخصيصات ذاكرة الكومة وحدها هي التي تدخل في مقاييس التخصيص في نتائج قياس أداء شيفرة Go. ولكن لاحظ أن التخصيص على المكدس لا يزال له كلفة، وإن كانت غالباً أصغر بكثير نسبياً.
يستطيع محلّل الهروب (escape analysis) في مصرِّف Go اكتشاف أن بعض أجزاء القيمة لن يستخدمها إلا goroutine واحد، ويحاول أن يجعل وقت التشغيل يخصّص أجزاء القيمة تلك على المكدس إذا تحققت شروط إضافية معينة. وستُشرح تخصيصات ذاكرة المكدس وتحليل الهروب بمزيد من التفصيل في الفصل التالي.
حالات تخصيص الذاكرة
عموماً، ستُجري كل عملية من العمليات التالية تخصيصاً واحداً على الأقل.
- تعريف المتغيرات
- استدعاء الدالة المدمجة
new. - استدعاء الدالة المدمجة
make. - تعديل الشرائح والخرائط بالقيم الحرفية المركّبة.
- تحويل الأعداد الصحيحة إلى نصوص.
- دمج النصوص باستخدام
+. - التحويل بين النصوص وشرائح byte، والعكس.
- تحويل النصوص إلى شرائح rune.
- تغليف القيم في واجهات (تحويل القيم غير الواجهية إلى واجهات).
- إلحاق عناصر بشريحة ولا تكون سعة الشريحة كبيرة بما يكفي.
- إضافة مدخلات جديدة إلى الخرائط ولا تكون المصفوفة الكامنة (الخاصة بتخزين المدخلات) في الخريطة كبيرة بما يكفي لتخزين المدخلات الجديدة.
ومع ذلك، يُجري المصرِّف القياسي الرسمي لـ Go بعض تحسينات الشيفرة الخاصة بحيث لا تُجري بعض العمليات المذكورة أعلاه تخصيصات في حالات معينة. وستُقدَّم هذه التحسينات لاحقاً في هذا الكتاب.
إهدار الذاكرة بسبب تخصيص كتل ذاكرة أكبر من اللازم
قد تختلف أحجام كتل الذاكرة المختلفة. لكنها ليست عشوائية. وفي تطبيق وقت التشغيل القياسي الرسمي لـ Go، بالنسبة إلى كتل الذاكرة المخصَّصة على الكومة،
- تُعرَّف مسبقاً بعض فئات أحجام كتل الذاكرة (التي لا يتجاوز حجمها 32768 بايت). واعتباراً من الإصدار 1.24.x من المصرِّف القياسي الرسمي لـ Go، أصغر فئات الأحجام هي 8 و16 و24 و32 و48 و64 و80 و96 بايت.
- أما كتل الذاكرة الأكبر من 32768 بايت، فتتكوّن كل واحدة منها دائماً من عدة صفحات ذاكرة. وحجم صفحة الذاكرة المستخدم في وقت التشغيل القياسي الرسمي لـ Go (إصدارات 1.24) هو 8192 بايت.
لذا،
- لتخصيص كتلة ذاكرة (على الكومة) لقيمة يقع حجمها في المدى [33, 48]، يكون حجم كتلة الذاكرة عموماً (ويجب أن يكون على الأقل) 48. بعبارة أخرى، قد يُهدر ما يصل إلى 15 بايت (إذا كان حجم القيمة 33).
- ولإنشاء شريحة byte من 32769 عنصراً على الكومة، يكون حجم كتلة الذاكرة التي تستضيف عناصر الشريحة 40960 (32768 + 8192، أي 5 صفحات ذاكرة). بعبارة أخرى، يُهدر 8191 بايت.
بعبارة أخرى، غالباً ما تكون كتل الذاكرة أكبر من اللازم. وقد وُضعت هذه الاستراتيجيات لإدارة الذاكرة بسهولة وكفاءة، لكنها قد تسبّب إهداراً قليلاً للذاكرة أحياناً (نعم، إنها مقايضة).
ويمكن إثبات ذلك بالبرنامج التالي:
package main
import "testing"
import "unsafe"
var t *[5]int64
var s []byte
func f(b *testing.B) {
for i := 0; i < b.N; i++ {
t = &[5]int64{}
}
}
func g(b *testing.B) {
for i := 0; i < b.N; i++ {
s = make([]byte, 32769)
}
}
func main() {
println(unsafe.Sizeof(*t)) // 40
rf := testing.Benchmark(f)
println(rf.AllocedBytesPerOp()) // 48
rg := testing.Benchmark(g)
println(rg.AllocedBytesPerOp()) // 40960
}
مثال آخر:
package main
import "testing"
var s = []byte{32: 'b'} // len(s) == 33
var r string
func Concat(b *testing.B) {
for i := 0; i < b.N; i++ {
r = string(s) + string(s)
}
}
func main() {
br := testing.Benchmark(Concat)
println(br.AllocsPerOp()) // 3
println(br.AllocedBytesPerOp()) // 176
}
تُجري الدالة Concat ثلاثة تخصيصات. اثنان منها ناتجان عن تحويلي شريحة byte إلى نص string(s)، وحجم كتلتَي الذاكرة اللتين تستضيفان البايتات الكامنة للنصين الناتجين هو 48 لكل منهما (وهي أصغر فئة أحجام لا تقل عن 33). أما التخصيص الثالث فنتج عن دمج النصوص، وحجم كتلة الذاكرة الناتجة هو 80 (أصغر فئة أحجام لا تقل عن 66). وتخصّص التخصيصات الثلاثة 176 (48+48+80) بايت إجمالاً. وفي النهاية، يُهدر 14 بايت. كما يُهدر 44 (15 + 15 + 14) بايت أثناء تنفيذ الدالة Concat.
في المثال أعلاه، تُستخدم نتائج تحويلات string(s) مؤقتاً في عملية دمج النصوص. ووفقاً لتطبيق المصرِّف ووقت التشغيل القياسيين الرسميين الحاليين لـ Go (إصدارات 1.24)، تُخصَّص بايتات النص على الكومة (انظر الأقسام أدناه للتفاصيل). وبعد اكتمال الدمج، تتحوّل كتلتا الذاكرة اللتان تستضيفان بايتات النص إلى نفايات ذاكرة وستُجمعان في النهاية لاحقاً.
تقليل تخصيصات الذاكرة وتوفير الذاكرة
كلما قلّت تخصيصات (كتل) الذاكرة، قلّ استهلاك موارد CPU، وقلّ الضغط على جمع النفايات.
الذاكرة رخيصة هذه الأيام، لكن هذا لا ينطبق على الذاكرة التي تبيعها مزوّدات الحوسبة السحابية. لذا إذا شغّلنا برامج على خوادم سحابية، فكلما وفّرت برامج Go ذاكرة أكثر، وفّرنا مالاً أكثر.
فيما يلي بعض الاقتراحات لتقليل تخصيصات الذاكرة وتوفير الذاكرة أثناء البرمجة.
تجنّب التخصيصات غير الضرورية بالتخصيص المسبق الكافي
كثيراً ما نستخدم الدالة المدمجة append لإضافة بعض عناصر الشريحة. وفي الجملة r = append(s, elements...)، إذا لم تكن السعة الحرة لـ s كبيرة بما يكفي لاستيعاب جميع العناصر الملحقة، فسيخصّص وقت تشغيل Go كتلة ذاكرة جديدة لاستيعاب جميع عناصر الشريحة الناتجة r.
وإذا احتجنا إلى استدعاء الدالة append عدة مرات لإضافة بعض العناصر، فمن الأفضل ضمان أن تكون للشريحة الأساسية سعة كبيرة بما يكفي، لتجنّب عدة تخصيصات غير ضرورية في عملية الإضافة بأكملها.
مثلاً، لدمج عدة شرائح في شريحة واحدة، يكون التطبيق MergeWithTwoLoops الموضح أدناه أكثر كفاءة من التطبيق MergeWithOneLoop، لأن الأول يُجري تخصيصات أقل وينسخ قيماً أقل.
package allocations
import "testing"
func getData() [][]int {
return [][]int{
{1, 2},
{9, 10, 11},
{6, 2, 3, 7},
{11, 5, 7, 12, 16},
{8, 5, 6},
}
}
func MergeWithOneLoop(data ...[]int) []int {
var r []int
for _, s := range data {
r = append(r, s...)
}
return r
}
func MergeWithTwoLoops(data ...[]int) []int {
n := 0
for _, s := range data {
n += len(s)
}
r := make([]int, 0, n)
for _, s := range data {
r = append(r, s...)
}
return r
}
func Benchmark_MergeWithOneLoop(b *testing.B) {
data := getData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = MergeWithOneLoop(data...)
}
}
func Benchmark_MergeWithTwoLoops(b *testing.B) {
data := getData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = MergeWithTwoLoops(data...)
}
}
نتائج قياس الأداء:
Benchmark_MergeWithOneLoop-4 636.6 ns/op 352 B/op 4 allocs/op
Benchmark_MergeWithTwoLoops-4 268.4 ns/op 144 B/op 1 allocs/op
تُظهر نتائج قياس الأداء أن التخصيصات تؤثر كثيراً في أداء تنفيذ الشيفرة.
لنطبع بعض السجلات لنرى متى يحدث كل تخصيص من التخصيصات الأربعة في نداء الدالة MergeWithOneLoop.
package main
import "fmt"
func getData() [][]int {
return [][]int{
{1, 2},
{9, 10, 11},
{6, 2, 3, 7},
{11, 5, 7, 12, 16},
{8, 5, 6},
}
}
const format = "Allocate from %v to %v (when append slice#%v).\n"
func MergeWithOneLoop(data [][]int) []int {
var oldCap int
var r []int
for i, s := range data {
r = append(r, s...)
if oldCap == cap(r) {
continue
}
fmt.Printf(format, oldCap, cap(r), i)
oldCap = cap(r)
}
return r
}
func main() {
MergeWithOneLoop(getData())
}
المخرجات (للمصرِّف القياسي الرسمي لـ Go v1.24.n):
Allocate from 0 to 2 (when append slice#0).
Allocate from 2 to 6 (when append slice#1).
Allocate from 6 to 12 (when append slice#2).
Allocate from 12 to 24 (when append slice#3).
من المخرجات، يمكننا أن نستنتج أن نداء append الأخير فقط لا يُجري تخصيصاً.
في الواقع، يمكن أن تكون الدالة Merge_TwoLoops أسرع من ذلك نظرياً. واعتباراً من الإصدار 1.24 من المصرِّف القياسي الرسمي لـ Go، سيصفّر نداء make في الدالة Merge_TwoLoop جميع العناصر المُنشأة للتو، وهو أمر غير ضروري في الواقع. وقد تتجنّب تحسينات المصرِّف في الإصدارات المستقبلية عملية التصفير هذه.
بالمناسبة، في التطبيق أعلاه للدالة Merge_TwoLoops نقص. فهو لا يتعامل مع حالة طفح الأعداد الصحيحة. وفيما يلي تطبيق أفضل.
func Merge_TwoLoops(data ...[][]byte) []byte {
n := 0
for _, s := range data {
if k := n + len(s); k < n {
panic("slice length overflows")
} else {
n = k
}
}
r := make([]int, 0, n)
...
}
تجنّب التخصيصات إن أمكن
التخصيص الأقل أفضل، لكن عدم التخصيص إطلاقاً هو الأفضل.
فيما يلي مثال آخر يوضّح فروق الأداء بين تطبيقين. أحدهما لا يُجري أي تخصيص، والآخر يُجري تخصيصاً واحداً.
package allocations
import "testing"
func buildOriginalData() []int {
s := make([]int, 1024)
for i := range s {
s[i] = i
}
return s
}
func check(v int) bool {
return v%2 == 0
}
func FilterOneAllocation(data []int) []int {
var r = make([]int, 0, len(data))
for _, v := range data {
if check(v) {
r = append(r, v)
}
}
return r
}
func FilterNoAllocations(data []int) []int {
var k = 0
for i, v := range data {
if check(v) {
data[i] = data[k]
data[k] = v
k++
}
}
return data[:k]
}
func Benchmark_FilterOneAllocation(b *testing.B) {
data := buildOriginalData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = FilterOneAllocation(data)
}
}
func Benchmark_FilterNoAllocations(b *testing.B) {
data := buildOriginalData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = FilterNoAllocations(data)
}
}
نتائج قياس الأداء:
Benchmark_FilterOneAllocation-4 3711 ns/op 8192 B/op 1 allocs/op
Benchmark_FilterNoAllocations-4 903.3 ns/op 0 B/op 0 allocs/op
من نتائج قياس الأداء، يمكننا أن نستنتج أن التطبيق FilterNoAllocations أعلى أداءً. (وبالطبع، إذا لم يُسمح بتعديل البيانات المُدخلة، فعلينا اختيار تطبيق يُجري تخصيصات.)
توفير الذاكرة وتقليل التخصيصات بدمج كتل الذاكرة
أحياناً، يمكننا تخصيص كتلة ذاكرة كبيرة واحدة لاستضافة أجزاء قيمة كثيرة بدلاً من تخصيص كتلة ذاكرة صغيرة لكل جزء قيمة. وسيؤدي فعل ذلك إلى تقليل تخصيصات الذاكرة كثيراً، فيُستهلك قدر أقل من موارد CPU ويُخفَّف ضغط GC إلى حد ما. وأحياناً، قد يقلّل فعل ذلك من إهدار الذاكرة، لكن هذا ليس صحيحاً دائماً.
لنطّلع على مثال:
package allocations
import "testing"
const N = 100
type Book struct {
Title string
Author string
Pages int
}
//go:noinline
func CreateBooksOnOneLargeBlock(n int) []*Book {
books := make([]Book, n)
pbooks := make([]*Book, n)
for i := range pbooks {
pbooks[i] = &books[i]
}
return pbooks
}
//go:noinline
func CreateBooksOnManySmallBlocks(n int) []*Book {
books := make([]*Book, n)
for i := range books {
books[i] = new(Book)
}
return books
}
func Benchmark_CreateBooksOnOneLargeBlock(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = CreateBooksOnOneLargeBlock(N)
}
}
func Benchmark_CreateBooksOnManySmallBlocks(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = CreateBooksOnManySmallBlocks(N)
}
}
نشغّل قياسات الأداء، فنحصل على:
Benchmark_CreateOnOneLargeBlock-4 4372 ns/op 4992 B/op 2 allocs/op
Benchmark_CreateOnManySmallBlocks-4 18017 ns/op 5696 B/op 101 allocs/op
من النتائج، يمكننا أن نستنتج أن تخصيص أجزاء قيمة صغيرة كثيرة على كتلة ذاكرة كبيرة واحدة
- يستهلك وقت CPU أقل بكثير.
- يستهلك ذاكرة أقل قليلاً.
الاستنتاج الأول سهل الفهم. فعملية تخصيص اثنتين تستهلك وقتاً أقل بكثير من 101 عملية تخصيص.
أما الاستنتاج الثاني فقد شُرح فعلاً سابقاً. وكما ذُكر أعلاه، عندما لا يطابق حجم قيمة (جزء قيمة) صغيرة تماماً أي فئة من فئات كتل الذاكرة التي يدعمها وقت التشغيل القياسي الرسمي لـ Go، فستُخصَّص للقيمة (جزء القيمة) الصغيرة كتلة ذاكرة أكبر قليلاً من اللازم إذا أُنشئت القيمة (جزء القيمة) الصغيرة منفردة. وحجم النوع Book هو 40 بايت (على المعماريات 64-بت)، بينما حجم أصغر فئة أحجام لكتل الذاكرة أكبر من 40 هو 48. لذا سيؤدي تخصيص قيمة Book منفردة إلى إهدار 8 بايت.
في الواقع، لا يصح الاستنتاج الثاني إلا في ظروف معينة. وتحديداً، لا يصح الاستنتاج عندما تكون قيمة N في المدى من 820 إلى 852. وعلى وجه الخصوص، عندما N == 820، تُظهر نتائج قياس الأداء أن تخصيص أجزاء قيمة صغيرة كثيرة على كتلة ذاكرة كبيرة واحدة يستهلك ذاكرة أكثر بنسبة 3.5%.
Benchmark_CreateOnOneLargeBlock-4 30491 ns/op 47744 B/op 2 allocs/op
Benchmark_CreateOnManySmallBlocks-4 145204 ns/op 46144 B/op 821 allocs/op
لماذا تستهلك الدالة CreateBooksOnOneLargeBlock ذاكرة أكثر عندما N == 820؟ لأنها تحتاج إلى تخصيص كتلة ذاكرة لا يقل حجمها الأدنى عن 32800 (820 * 40)، وهو أكبر من أكبر فئة صغيرة لكتل الذاكرة 32768. لذا تحتاج كتلة الذاكرة إلى 5 صفحات ذاكرة، وحجمها الإجمالي 40960 (8192 * 5). بعبارة أخرى، يُهدر 8160 (40960 - 32800) بايت.
ورغم أنه يهدر ذاكرة أكثر أحياناً، فإن تخصيص أجزاء قيمة صغيرة كثيرة على كتلة ذاكرة كبيرة واحدة أفضل نسبياً، بوجه عام، من تخصيص كل واحد منها على كتلة ذاكرة منفصلة. ويصح هذا خصوصاً عندما تكون أعمار أجزاء القيمة الصغيرة متقاربة، إذ يمكن في هذه الحالة أن يتجنّب تخصيص أجزاء قيمة صغيرة كثيرة على كتلة ذاكرة كبيرة واحدة تفتّت الذاكرة بفعالية في كثير من الأحيان.
استخدام حوض ذاكرة مؤقتة للقيم لتجنّب بعض التخصيصات
أحياناً، نحتاج إلى تخصيص قيم من نوع محدد والتخلّص منها بشكل متكرر من وقت لآخر. ومن الفكرة الجيدة إعادة استخدام القيم المخصَّصة لتجنّب عدد كبير من التخصيصات.
مثلاً، توجد شخصيات غير لاعبين (NPC) كثيرة في ألعاب RTS. وسيُنشأ عدد كبير من شخصيات NPC وتُدمَّر من وقت لآخر خلال جلسة اللعب. والشيفرة المتعلقة بذلك مثل
type NPC struct {
name [64]byte
nameLen uint16
blood uint16
properties uint32
x, y float64
}
func SpawnNPC(name string, x, y float64) *NPC {
var npc = newNPC()
npc.nameLen = uint16(copy(npc.name[:], name))
npc.x = x
npc.y = y
return npc
}
func newNPC() *NPC {
return &NPC{}
}
func releaseNPC(npc *NPC) {
}
ولأن Go يدعم جمع النفايات التلقائي، قد لا تفعل الدالة releaseNPC شيئاً. غير أن مثل هذا التطبيق سيؤدي إلى عدد كبير من التخصيصات أثناء اللعب ويسبّب ضغطاً كبيراً على جمع النفايات، بحيث يصعب ضمان معدل FPS جيد للعبة (إطاراً في الثانية).
ويمكننا بدلاً من ذلك استخدام حوض ذاكرة مؤقتة لتقليل التخصيصات، كما في الشيفرة الموضحة أدناه.
import "container/list"
var npcPool = struct {
sync.Mutex
*list.List
}{
List: list.New(),
}
func newNPC() *NPC {
npcPool.Lock()
defer npcPool.Unlock()
if npcPool.Len() == 0 {
return &NPC{}
}
return npcPool.Remove(npcPool.Front()).(*NPC)
}
func releaseNPC(npc *NPC) {
npcPool.Lock()
defer npcPool.Unlock()
*npc = NPC{} // تصفير NPC المحرَّرة
npcPool.PushBack(npc)
}
وباستخدام الحوض (ويسمى أيضاً القائمة الحرة)، ستقلّ تخصيصات قيم NPC كثيراً، وهو أمر مفيد جداً للحفاظ على معدل FPS سلس (إطاراً في الثانية) أثناء اللعب.
وإذا كان حوض الذاكرة المؤقتة يُستخدم في goroutine واحد فقط، فلا حاجة إلى مزامنات التزامن في التطبيق.
ويمكننا أيضاً تحديد حجم أقصى للحوض لتجنّب أن يشغل الحوض ذاكرة كبيرة جداً.
توفّر الحزمة القياسية sync نوع Pool لتقديم وظائف مشابهة، لكن مع عدة فروق في التصميم:
- سيُجمع أي كائن حر في
sync.Poolتلقائياً كنفايات إذا وُجد أنه غير مستخدم في دورتَي جمع نفايات متتاليتين. وهذا يعني أن الحجم الأقصى لـsync.Poolديناميكي ويعتمد على الطلب في وقت التشغيل. - يمكن أن تختلف أنواع الكائنات وأحجامها في
sync.Poolنفسه. لكن أفضل ممارسة هي ضمان أن الكائنات الموضوعة فيsync.Poolنفسه من النوع نفسه ولها الحجم نفسه.
شخصياً، أجد أن تصميم sync.Pool قلّما يلبّي الاحتياجات عملياً. لذا كثيراً ما أستخدم تطبيقات مخصّصة لحوض ذاكرة مؤقتة للقيم في مشاريع Go الخاصة بي.