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

سيناريوهات تسريب الذاكرة

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

تسريب شكلي للذاكرة ناتج عن النصوص الجزئية

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

على سبيل المثال، بعد استدعاء الدالة demo في المثال التالي، سيحدث تسريب (شكلي) لنحو 1M بايت من الذاكرة، حتى يُعدَّل المتغير s0 على مستوى الحزمة مرة أخرى في مكان آخر.


var s0 string // متغير على مستوى الحزمة

// دالة لأغراض العرض التوضيحي.
func f(s1 string) {
	s0 = s1[:50]
	// الآن، يتشارك s0 نفس كتلة الذاكرة الأساسية
	// مع s1. رغم أن s1 لم يعد حيًّا الآن، لكن s0
	// لا يزال حيًّا، لذا لا يمكن جمع كتلة الذاكرة
	// التي يتشاركانها، مع أن 50 بايت فقط تُستخدم
	// في الكتلة وكل البايتات الأخرى في الكتلة
	// أصبحت غير قابلة للوصول.
}

func demo() {
	s := createStringWithLengthOnHeap(1 << 20) // 1M بايت
	f(s)
}

لتجنب هذا التسريب الشكلي للذاكرة، يمكننا تحويل النص الجزئي إلى قيمة []byte ثم تحويل قيمة []byte مرة أخرى إلى string.


func f(s1 string) {
	s0 = string([]byte(s1[:50]))
}

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

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


func f(s1 string) {
	s0 = (" " + s1[:50])[1:]
}

عيب الطريقة أعلاه أن تحسين المصرِّف قد يصبح غير صالح لاحقًا، وقد لا يكون التحسين متاحًا في مصرِّفات أخرى.

الطريقة الثالثة لتجنب التسريب الشكلي للذاكرة هي استخدام strings.Builder المدعوم منذ Go 1.10.


import "strings"

func f(s1 string) {
	var b strings.Builder
	b.Grow(50)
	b.WriteString(s1[:50])
	s0 = b.String()
}

عيب الطريقة الثالثة أنها مطوّلة قليلًا (مقارنة بالطريقتين الأولى والثانية). والخبر السار أنه منذ Go 1.12، يمكننا استدعاء الدالة Repeat مع الوسيط count بالقيمة 1 في الحزمة القياسية strings لنسخ نص. منذ Go 1.12، سيستفيد التنفيذ الأساسي لـ strings.Repeat من strings.Builder لتجنب نسخة مكررة واحدة غير ضرورية.

منذ Go 1.18، أُضيفت الدالة Clone إلى الحزمة القياسية strings. وقد أصبحت أفضل طريقة لإنجاز هذه المهمة.

تسريب شكلي للذاكرة ناتج عن الشرائح الجزئية

على غرار النصوص الجزئية، قد تسبب الشرائح الجزئية أيضًا تسريبًا شكليًا للذاكرة. في الشيفرة التالية، بعد استدعاء الدالة g، ستُفقد معظم الذاكرة التي تشغلها كتلة الذاكرة المستضيفة لعناصر s1 (إن لم تعد هناك قيم أخرى تشير إلى كتلة الذاكرة).


var s0 []int

func g(s1 []int) {
	// افترض أن طول s1 أكبر بكثير من 30.
	s0 = s1[len(s1)-30:]
}

إذا أردنا تجنب التسريب الشكلي للذاكرة، يجب علينا نسخ العناصر الثلاثين الخاصة بـ s0، بحيث لا تمنع حيوية s0 كتلة الذاكرة المستضيفة لعناصر s1 من أن تُجمع.


func g(s1 []int) {
	s0 = make([]int, 30)
	copy(s0, s1[len(s1)-30:])
	// الآن، يمكن جمع كتلة الذاكرة المستضيفة
	// لعناصر s1 إن لم تكن هناك قيم أخرى
	// تشير إلى كتلة الذاكرة.
}

تسريب شكلي للذاكرة ناتج عن عدم تصفير المؤشرات في عناصر الشريحة المفقودة

في الشيفرة التالية، بعد استدعاء الدالة h، ستُفقد كتلة الذاكرة المخصصة للعنصرين الأول والأخير من الشريحة s.


func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// افعل شيئًا ما مع s ...

	return s[1:3:3]
}

طالما أن الشريحة المُرجَعة لا تزال حية، فإنها ستمنع جمع أي عنصر من عناصر s، وهذا بدوره يمنع جمع كتلتي الذاكرة المخصصتين لقيمتي int المشار إليهما بواسطة العنصرين الأول والأخير من s.

إذا أردنا تجنب هذا التسريب الشكلي للذاكرة، يجب علينا تصفير المؤشرات المخزنة في العناصر المفقودة.


func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// افعل شيئًا ما مع s ...

	// صفّر قيم المؤشرات.
	s[0], s[len(s)-1] = nil, nil
	return s[1:3:3]
}

غالبًا ما نحتاج إلى تصفير المؤشرات لبعض عناصر الشريحة القديمة في عمليات حذف عناصر الشريحة.

تسريب حقيقي للذاكرة ناتج عن goroutines المعلّقة

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

هناك سببان لعدم قتل وقت تشغيل Go للـ goroutines المعلّقة. الأول أنه يصعب أحيانًا على وقت تشغيل Go الحكم على ما إذا كانت goroutine محجوبة ستبقى محجوبة إلى الأبد أم لا. والآخر أننا نعلّق goroutine عن قصد أحيانًا. على سبيل المثال، قد ندع أحيانًا الـ goroutine الرئيسية لبرنامج Go معلّقة لتجنب خروج البرنامج.

يجب علينا تجنب الـ goroutines المعلّقة الناتجة عن بعض الأخطاء المنطقية في تصميم الشيفرة.

تسريب حقيقي للذاكرة ناتج عن عدم إيقاف قيم time.Ticker التي لم تعد مستخدمة

عندما لا تُستخدم قيمة time.Timer بعد الآن، ستُجمع كنفايات بعد مدة. لكن هذا لا ينطبق على قيمة time.Ticker. يجب علينا إيقاف قيمة time.Ticker عندما لا تُستخدم بعد الآن.

تسريب حقيقي للذاكرة ناتج عن الاستخدام غير السليم للمُنهيات (finalizers)

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

على سبيل المثال، بعد استدعاء الدالة التالية وخروجها، لا تُضمن كتل الذاكرة المخصصة لـ x و y أن تُجمع كنفايات في عمليات جمع النفايات المستقبلية.


func memoryLeaking() {
	type T struct {
		v [1<<20]int
		t *T
	}

	var finalizer = func(t *T) {
		 fmt.Println("finalizer called")
	}

	var x, y T

	// استدعاء SetFinalizer يجعل x يهرب إلى الكومة.
	runtime.SetFinalizer(&x, finalizer)

	// السطر التالي يشكّل مجموعة مراجع
	// دورية بعضوين، هما x و y.
	// هذا يجعل x و y غير قابلين للجمع.
	x.t, y.t = &y, &x // y يهرب أيضًا إلى الكومة.
}

لذا، يرجى تجنب تعيين المُنهيات لقيم في مجموعة مراجع دورية.

بالمناسبة، يجب ألا نستخدم المُنهيات كمدمّرات للكائنات.

تسريب شكلي للموارد ناتج عن تأجيل استدعاءات الدوال

يرجى قراءة هذا المقال للتفاصيل.