Go لغة تدعم الإدارة التلقائية للذاكرة، مثل التخصيص التلقائي للذاكرة وجمع النفايات التلقائي. لذا يستطيع مبرمجو Go البرمجة دون التعامل مع إدارة الذاكرة المطوَّلة الكامنة. وهذا لا يجلب راحة كبيرة ويوفّر على مبرمجي Go وقتاً كثيراً فحسب، بل يساعدهم أيضاً على تجنّب كثير من الأخطاء الناتجة عن الإهمال.
رغم أن معرفة تفاصيل تطبيق إدارة الذاكرة الكامنة ليست ضرورية لمبرمجي Go لكتابة شيفرة Go، فإن فهم بعض المفاهيم والوعي ببعض الحقائق في تطبيق إدارة الذاكرة في المصرِّف ووقت التشغيل القياسيين لـ Go مفيد جداً لمبرمجي Go لكتابة شيفرة Go عالية الجودة.
سيشرح هذا المقال بعض المفاهيم ويسرد بعض الحقائق حول تطبيق تخصيص كتل الذاكرة وجمع النفايات بواسطة المصرِّف ووقت التشغيل القياسيين لـ Go. أما الجوانب الأخرى، مثل طلب الذاكرة وتحرير الذاكرة في إدارة الذاكرة، فلن يتناولها هذا المقال.
كتل الذاكرة
كتلة الذاكرة مقطع ذاكرة متصل تُستضاف فيه أجزاء القيمة في وقت التشغيل. وقد تختلف أحجام كتل الذاكرة المختلفة لتستضيف أجزاء قيمة مختلفة. وقد تستضيف كتلة ذاكرة واحدة عدة أجزاء قيمة في الوقت نفسه، لكن كل جزء قيمة لا يمكن أن يُستضاف إلا داخل كتلة ذاكرة واحدة، مهما كان حجم ذلك الجزء كبيراً. بعبارة أخرى، لا يتجاوز أي جزء قيمة حدود كتل الذاكرة أبداً.
هناك أسباب كثيرة لاستضافة كتلة ذاكرة واحدة عدة أجزاء قيمة. ومنها:
- غالباً ما تحتوي قيمة البنية على عدة حقول. لذا عند تخصيص كتلة ذاكرة لقيمة بنية، ستستضيف كتلة الذاكرة أيضاً (الأجزاء المباشرة من) قيم هذه الحقول.
- غالباً ما تحتوي قيمة المصفوفة على العديد من العناصر. لذا عند تخصيص كتلة ذاكرة لقيمة مصفوفة، ستستضيف كتلة الذاكرة أيضاً (الأجزاء المباشرة من) قيم عناصر المصفوفة.
- قد تُستضاف متتاليتا العناصر الكامنتان لشريحتين على كتلة الذاكرة نفسها، بل يمكن أن تتداخل متتاليتا العناصر معاً.
القيمة تشير إلى كتل الذاكرة التي تستضيف أجزاء قيمتها
علمنا أن جزء قيمة يمكن أن يشير إلى جزء قيمة آخر. وهنا نوسّع تعريف الإشارة بأن نقول إن كتلة الذاكرة يُشار إليها من جميع أجزاء القيمة التي تستضيفها. لذا إذا أشار جزء قيمة آخر إلى جزء القيمة v، فإن ذلك الجزء الآخر سيشير أيضاً وبشكل غير مباشر إلى كتلة الذاكرة التي تستضيف v.
متى تُخصَّص كتل الذاكرة؟
في Go، قد تُخصَّص كتل الذاكرة في الحالات التالية، على سبيل المثال لا الحصر:
- استدعاء الدالتين المدمجتين
newوmakeصراحةً. يخصّص نداءnewدائماً كتلة ذاكرة واحدة بالضبط. أما نداءmakeفيخصّص أكثر من كتلة ذاكرة لاستضافة الجزء المباشر والأجزاء الكامنة للشريحة أو الخريطة أو القناة المُنشأة. - إنشاء الخرائط والشرائح والدوال المجهولة بقيمها الحرفية المقابلة. وقد تُخصَّص أكثر من كتلة ذاكرة في كل عملية من هذه العمليات.
- تعريف المتغيرات.
- إسناد قيم غير واجهية إلى قيم واجهية (عندما لا تكون القيمة غير الواجهية قيمة مؤشر).
- دمج النصوص غير الثابتة.
- تحويل النصوص إلى شرائح byte أو rune، والعكس، باستثناء بعض حالات تحسين المصرِّف الخاصة.
- تحويل الأعداد الصحيحة إلى نصوص.
- استدعاء الدالة المدمجة
append(عندما لا تكون سعة الشريحة الأساسية كبيرة بما يكفي). - إضافة زوج مفتاح-عنصر جديد إلى خريطة (عندما يحتاج جدول التجزئة الكامن إلى تغيير حجمه).
أين تُخصَّص كتل الذاكرة؟
لكل برنامج Go يصرِّفه المصرِّف القياسي الرسمي لـ Go، يحافظ كل goroutine في وقت التشغيل على مكدس، وهو مقطع ذاكرة. ويعمل هذا المكدس كحوض ذاكرة تُخصَّص منه أو عليه بعض كتل الذاكرة. قبل Go Toolchain 1.19، كان الحجم الابتدائي للمكدس دائماً 8KiB على منصة Windows و2KiB على المنصات الأخرى. ومنذ Go Toolchain 1.19، أصبح الحجم الابتدائي تكيّفياً. وسينمو مكدس goroutine ويتقلّص حسب الحاجة أثناء تشغيل goroutine. والحد الأدنى لحجم المكدس هو 8KiB على منصة Windows و2KiB على المنصات الأخرى.
(لاحظ أنه يوجد حدّ أقصى شامل لحجم المكدس يمكن أن يبلغه كل goroutine. وإذا تجاوز goroutine هذا الحد أثناء نمو مكدسه، ينهار البرنامج. واعتباراً من Go Toolchain 1.25.n، الحد الأقصى الافتراضي لحجم المكدس هو 1 GB على الأنظمة 64-بت، و250 MB على الأنظمة 32-بت. ويمكننا استدعاء الدالة SetMaxStack في الحزمة القياسية runtime/debug لتغيير الحجم. ولاحظ أيضاً أنه، حسب تطبيق المصرِّف القياسي الرسمي الحالي لـ Go، فإن الحد الأقصى الفعلي المسموح به لحجم المكدس هو أكبر قوة للعدد 2 لا تتجاوز إعداد MaxStack. لذا في الإعداد الافتراضي، يكون الحد الأقصى الفعلي المسموح به لحجم المكدس 512 MiB على الأنظمة 64-بت، و128 MiB على الأنظمة 32-بت.)
يمكن تخصيص كتل الذاكرة على المكدسات. وكتل الذاكرة المخصَّصة على مكدس goroutine لا يمكن استخدامها (الإشارة إليها) إلا داخل ذلك goroutine. فهي موارد محلية خاصة بـ goroutine. وليس من الآمن الإشارة إليها عبر goroutines. ويستطيع goroutine الوصول إلى أجزاء القيمة المستضافة على كتلة ذاكرة مخصَّصة على مكدسه أو تعديلها دون استخدام أي تقنيات لمزامنة البيانات.
(لمعرفة كتل الذاكرة التي ستُخصَّص على المكدس، اقرأ فصل «المكدس وتحليل الهروب» (Stack and Escape Analysis) في كتاب Go Optimizations 101.)
الكومة كيان وحيد في كل برنامج. وهي مفهوم افتراضي. وإذا لم تُخصَّص كتلة ذاكرة على أي مكدس goroutine، نقول إن كتلة الذاكرة مخصَّصة على الكومة. ويمكن لعدة goroutines استخدام أجزاء القيمة المستضافة على كتل ذاكرة مخصَّصة على الكومة. بعبارة أخرى، يمكن استخدامها تزامنياً. وينبغي مزامنة استخدامها عند الحاجة.
الكومة مكان متحفّظ لتخصيص كتل الذاكرة عليه. فإذا اكتشف المصرِّف أن كتلة ذاكرة سيُشار إليها عبر goroutines، أو لم يستطع بسهولة تأكيد ما إذا كانت كتلة الذاكرة آمنة لوضعها على مكدس goroutine، فستُخصَّص كتلة الذاكرة على الكومة في وقت التشغيل. وهذا يعني أن بعض القيم التي يمكن تخصيصها بأمان على المكدسات قد تُخصَّص أيضاً على الكومة.
في الواقع، المكدسات ليست جوهرية لبرامج Go. فبإمكان مصرِّف ووقت تشغيل Go تخصيص جميع كتل الذاكرة على الكومة. ودعم المكدسات ليس إلا لجعل برامج Go تعمل بكفاءة أكبر:
- تخصيص كتل الذاكرة على المكدسات أسرع بكثير من تخصيصها على الكومة.
- كتل الذاكرة المخصَّصة على المكدس لا تحتاج إلى جمع النفايات.
- كتل ذاكرة المكدس أكثر ملاءمة لذاكرة CPU المؤقتة من كتل ذاكرة الكومة.
إذا خُصِّصت كتلة ذاكرة في مكان ما، يمكننا أيضاً القول إن أجزاء القيمة المستضافة على كتلة الذاكرة مخصَّصة في المكان نفسه.
إذا خُصِّصت بعض أجزاء قيمة متغير محلي معرَّف في دالة على الكومة، يمكننا القول إن أجزاء القيمة (والمتغير) تهرب إلى الكومة. وباستخدام Go Toolchain، يمكننا تشغيل go build -gcflags -m للتحقق من القيم المحلية (أجزاء القيمة) التي ستهرب إلى الكومة في وقت التشغيل. وكما ذُكر أعلاه، لا يزال محلّل الهروب الحالي في المصرِّف القياسي لـ Go غير مثالي، فكثير من أجزاء القيمة المحلية التي يمكن تخصيصها على المكدسات بأمان ستهرب مع ذلك إلى الكومة.
يجب أن يُشار إلى أي جزء قيمة نشط مخصَّص على الكومة ولا يزال قيد الاستخدام من جزء قيمة واحد على الأقل مخصَّص على مكدس. وإذا كانت القيمة الهاربة إلى الكومة متغيراً محلياً معرَّفاً، ولنفترض أن نوعه T، فسينشئ وقت تشغيل Go (كتلة ذاكرة لـ) مؤشراً ضمنياً من النوع *T على مكدس goroutine الحالي. وتخزّن قيمة المؤشر عنوان كتلة الذاكرة المخصَّصة للمتغير على الكومة (أي عنوان المتغير المحلي من النوع T). وسيستبدل مصرِّف Go أيضاً جميع استخدامات المتغير بإلغاء مراجع قيمة المؤشر في وقت التصريف. وقد تُعلَّم قيمة المؤشر *T على المكدس بأنها ميتة منذ وقت لاحق، فتختفي علاقة الإشارة منها إلى قيمة T على الكومة. وتؤدي علاقة الإشارة من قيمة *T على المكدس إلى قيمة T على الكومة دوراً مهماً في عملية جمع النفايات التي ستُوصف أدناه.
وبالمثل، يمكننا اعتبار أن كل متغير على مستوى الحزمة مخصَّص على الكومة، وأن المتغير يُشار إليه بمؤشر ضمني مخصَّص في منطقة ذاكرة عامة. في الواقع، يشير المؤشر الضمني إلى الجزء المباشر من المتغير على مستوى الحزمة، ويشير الجزء المباشر من المتغير إلى بعض أجزاء القيمة الأخرى.
قد يُشار إلى كتلة ذاكرة مخصَّصة على الكومة من عدة أجزاء قيمة مخصَّصة على مكدسات مختلفة في الوقت نفسه.
بعض الحقائق:
- إذا هرب حقل من قيمة بنية إلى الكومة، فستهرب قيمة البنية كاملة أيضاً إلى الكومة.
- إذا هرب عنصر من قيمة مصفوفة إلى الكومة، فستهرب قيمة المصفوفة كاملة أيضاً إلى الكومة.
- إذا هرب عنصر من قيمة شريحة إلى الكومة، فستهرب جميع عناصر الشريحة أيضاً إلى الكومة.
- إذا أشارت قيمة (جزء قيمة) هاربة إلى الكومة إلى قيمة (جزء قيمة)
v، فستهرب القيمة (جزء القيمة)vأيضاً إلى الكومة.
قد تُخصَّص كتلة الذاكرة الناتجة عن استدعاء الدالة new على الكومة أو على المكدسات. وهذا يختلف عن C++.
عندما يتغير حجم مكدس goroutine (بسبب نمو المكدس أو تقلّصه)، سيُخصَّص مقطع ذاكرة جديد للمكدس. لذا من المرجح جداً أن تُنقل كتل الذاكرة المخصَّصة على المكدس، أو أن تتغير عناوينها. ونتيجة لذلك، يجب تعديل المؤشرات التي تشير إلى كتل الذاكرة هذه، والتي يجب أن تكون مخصَّصة على المكدس أيضاً، وفقاً لذلك. وفي ما يلي مثال على ذلك.
package main
// التوجيه التالي لمنع تضمين
// نداءات الدالة f.
//go:noinline
func f(i int) byte {
var a [1<<20]byte // اجعل المكدس ينمو
return a[i]
}
func main(){
var x int
println(&x)
f(100)
println(&x)
}
سنجد أن العنوانين المطبوعين مختلفان (اعتباراً من المصرِّف القياسي لـ Go v1.25.n).
متى يمكن جمع كتلة الذاكرة؟
كتل الذاكرة المخصَّصة للأجزاء المباشرة من المتغيرات على مستوى الحزمة لن تُجمع أبداً.
يُجمَع مكدس goroutine ككل عند خروج goroutine. لذا لا حاجة إلى جمع كتل الذاكرة المخصَّصة على المكدسات فرادى، واحدة تلو الأخرى. فالمكدسات لا يجمعها جامع النفايات.
بالنسبة إلى كتلة ذاكرة مخصَّصة على الكومة، لا يمكن جمعها بأمان إلا إذا لم تعد جميع أجزاء القيمة المخصَّصة على مكدسات goroutines ومنطقة الذاكرة العامة تشير إليها (سواء مباشرة أو غير مباشرة). ونسمّي كتل الذاكرة هذه كتل ذاكرة غير مستخدمة. وستجمع جامع النفايات كتل الذاكرة غير المستخدمة على الكومة.
وفي ما يلي مثال يوضّح متى يمكن جمع بعض كتل الذاكرة:
package main
var p *int
func main() {
done := make(chan bool)
// "done" سيُستخدم في main وفي
// goroutine الجديد التالي، لذا سيُخصَّص على الكومة.
go func() {
x, y, z := 123, 456, 789
_ = z // يمكن تخصيص z على المكدس بأمان.
p = &x // لأن x وy يُشار إليهما في مرحلة ما
p = &y // بواسطة المتغير العام p، فسيُخصَّصان
// معاً على الكومة.
// الآن، لا يشير أي شيء إلى x، لذا
// يمكن جمع كتلة ذاكرته الآن.
p = nil
// الآن، لا يشير أي شيء إلى y أيضاً،
// لذا يمكن جمع كتلة ذاكرته الآن.
done <- true
}()
<-done
// الآن يخرج goroutine أعلاه، ولم تعد القناة
// done تُستخدم، وقد يعتبر مصرِّف ذكي أنها
// يمكن جمعها الآن.
// ...
}
أحياناً، قد يجري المصرِّف الذكي، مثل المصرِّف القياسي لـ Go، بعض التحسينات بحيث تُزال بعض الإشارات أبكر مما نتوقع. وفي ما يلي مثال على ذلك.
package main
import "fmt"
func main() {
// افترض أن طول الشريحة كبير جداً
// بحيث يجب تخصيص عناصرها على الكومة.
bs := make([]byte, 1 << 31)
// يستطيع مصرِّف ذكي اكتشاف أن
// الجزء الكامن للشريحة bs لن يُستخدم
// لاحقاً أبداً، لذا يمكن جمع الجزء الكامن
// للشريحة bs كنفايات بأمان الآن.
fmt.Println(len(bs))
}
اقرأ أجزاء القيمة لتتعرّف على البنى الداخلية لقيم الشرائح.
بالمناسبة، أحياناً قد نأمل أن نضمن عدم جمع الشريحة bs كنفايات حتى يُستدعى fmt.Println، وعندئذ يمكننا استخدام نداء الدالة runtime.KeepAlive لنخبر جامعي النفايات بأن الشريحة bs وأجزاء القيمة التي تشير إليها لا تزال قيد الاستخدام.
على سبيل المثال،
package main
import "fmt"
import "runtime"
func main() {
bs := make([]int, 1000000)
fmt.Println(len(bs))
// نداء runtime.KeepAlive(bs) صالح أيضاً
// لهذا المثال المحدد.
runtime.KeepAlive(&bs)
}
كيف تُكتشف كتل الذاكرة غير المستخدمة؟
يستخدم المصرِّف القياسي الحالي لـ Go (v1.25.n) جامع نفايات متزامناً ثلاثي الألوان يعمل بخوارزمية التعليم والكنس. وهنا سيقتصر هذا المقال على شرح مبسّط للخوارزمية.
تُقسَّم عملية جمع النفايات (GC) إلى مرحلتين: مرحلة التعليم ومرحلة الكنس. في مرحلة التعليم، يستخدم الجامع (وهو مجموعة من goroutines في الواقع) الخوارزمية ثلاثية الألوان لتحليل كتل الذاكرة غير المستخدمة.
الاقتباس التالي مأخوذ من مقال في مدونة Go ومُعدَّل قليلاً ليكون أوضح.
في بداية دورة GC تكون جميع كتل ذاكرة الكومة بيضاء. يزور GC جميع الجذور، وهي كائنات يمكن للتطبيق الوصول إليها مباشرة مثل المتغيرات العامة والأشياء الموجودة على المكدس، ويلوّنها بالرمادي. ثم يختار GC كائناً رمادياً، فيسوّده، ثم يمسحه بحثاً عن مؤشرات إلى كائنات أخرى. وعندما يجد هذا المسح مؤشراً إلى كتلة ذاكرة بيضاء، يحوّل ذلك الكائن إلى رمادي. وتتكرر هذه العملية حتى لا تبقى كائنات رمادية. عند هذه النقطة، تُعرف كتل ذاكرة (الكومة) البيضاء بأنها غير قابلة للوصول ويمكن إعادة استخدامها.
(لمعرفة سبب استخدام الخوارزمية ثلاثة ألوان بدلاً من لونين، ابحث عن «write barrier golang» للتفاصيل. وهنا نقدّم مرجعين فقط: إلغاء إعادة مسح المكدس في STW وmbarrier.go.)
في مرحلة الكنس، تُجمع كتل الذاكرة غير المستخدمة المعلَّمة.
قد لا تُعاد كتلة ذاكرة غير مستخدمة إلى نظام التشغيل فور جمعها، كي يمكن إعادة استخدامها لأجزاء قيمة جديدة. لا تقلق، فوقت تشغيل Go الرسمي أقل جشعاً في استهلاك الذاكرة بكثير من معظم أوقات تشغيل Java.
خوارزمية GC غير مُدمِجة (non-compacting)، لذا لن تنقل كتل الذاكرة لإعادة ترتيبها.
متى تبدأ عملية جمع نفايات جديدة؟
تستهلك عمليات جمع النفايات الكثير من موارد CPU وبعض موارد الذاكرة. لذا لا توجد دائماً عملية جمع نفايات قيد التشغيل. ولن تُطلق عملية جمع نفايات جديدة إلا عندما تبلغ بعض مقاييس وقت التشغيل شروطاً معينة. وكيفية تعريف هذه الشروط هي مسألة منظّم إيقاع جمع النفايات (GC pacer).
لا يزال تطبيق منظّم إيقاع جمع النفايات في وقت التشغيل القياسي الرسمي لـ Go يُحسَّن من إصدار إلى آخر. لذا يصعب وصف التطبيق بدقة والحفاظ على حداثة الأوصاف في الوقت نفسه. وهنا أسرد فقط بعض المقالات المرجعية حول هذا الموضوع:
- عن متغيري البيئة
GOGCوGOMEMLIMIT(لاحظ أن متغير البيئةGOMEMLIMITمدعوم فقط منذ Go 1.19). - دليل جامع النفايات في Go.
- إعادة تصميم منظّم إيقاع GC.
- فصل «جمع النفايات» في كتابي Go Optimizations 101 (وهو كتاب مدفوع).