Escape Analysis في Go: ليه متغير محلي بيهرب من الـ Stack للـ Heap
المستوى: محترف. المقال ده بيفترض إنك بتكتب Go فعلًا، عارف الفرق بين القيمة والمؤشر، وقريت output benchmark قبل كده. لو لسه مبتدئ، اقرأ المثال التشبيهي في الأول وكمّل عادي.
لو خدمة Go عندك بتعمل allocations أكتر من المتوقع والـ Garbage Collector بياخد نسبة CPU محسوسة، السبب الأكثر شيوعًا مش الكود الواضح. السبب إن متغيرات محلية بتهرب للـ Heap من غير ما تقصد. هتتعلم هنا تكشف الهروب بأمر واحد، وتقيس تكلفته بالأرقام، وتمنعه في الحالات اللي تستاهل.
المشكلة باختصار
في Go إنت مش بتقرر المتغير يتخزن فين. الكومبايلر هو اللي بيقرر: على الـ Stack ولا على الـ Heap. القرار ده اسمه Escape Analysis. لو الكومبايلر قرر إن متغير ممكن «يعيش» بعد انتهاء الدالة اللي عملته، بيحطه على الـ Heap. والـ Heap معناه شغل زيادة للـ GC، يعني CPU وlatency. المشكلة إنك مش شايف القرار ده في الكود، فبتدفع تمنه من غير ما تعرف.
المفهوم الأول بمثال بسيط
تخيّل مكتبك في الشغل. الورقة اللي بتكتب عليها حسبة سريعة وترميها لما تخلص المهمة دي هي الـ Stack: مكانها معروف، وبتتفضّى تلقائيًا أول ما تقوم من المكتب. بسيطة ورخيصة.
دلوقتي تخيّل ورقة لازم زميلك ياخدها بعد ما تمشي. مينفعش ترميها مع مكتبك، فبتحطها في مخزن مشترك في الشركة. المخزن ده هو الـ Heap. مرن، بيستحمل أي حاجة تعيش طويل، بس محتاج حارس (الـ GC) يلف عليه كل فترة يشيل اللي محدش محتاجه. الحارس ده شغله مش ببلاش.
السؤال اللي Escape Analysis بيجاوب عليه: «هل في حد هيحتاج الورقة دي بعد ما الموظف يمشي؟» لو آه، الورقة بتهرب (escapes) للمخزن.
التعريف الدقيق
Escape Analysis هو تحليل بيعمله الكومبايلر وقت الـ build، بيتتبّع كل قيمة ويحدد هل عمرها الافتراضي محصور جوه الدالة (lifetime محلي) ولا ممكن يتجاوزها (escapes). لو الـ lifetime محلي، القيمة بتتحط على الـ Stack frame بتاع الدالة، وبتختفي تلقائيًا عند الـ return بدون أي تدخّل من الـ GC. لو القيمة ممكن يتم الوصول لها بعد انتهاء الدالة، الكومبايلر مجبور يحطها على الـ Heap. توثيق Go الرسمي بيقول الكلام ده صراحةً: «لو الكومبايلر مقدرش يثبت إن المتغير مش بيتم الرجوع له بعد انتهاء الدالة، بيخصصه على الـ Heap».
ازاي تشوف القرار بنفسك
الكومبايلر بيطبع قراراته لو طلبت منه بـ -gcflags="-m". خد المثال ده:
package main
// مش بيهرب: القيمة بترجع by value، عمرها بينتهي مع الدالة
func sumLocal() int {
x := 42
return x
}
// بيهرب: بنرجّع مؤشر لمتغير محلي، فلازم يعيش بعد الدالة
func newCounter() *int {
c := 0
return &c
}
func main() {
_ = sumLocal()
_ = newCounter()
}